1mod project_panel_settings;
2mod utils;
3
4use anyhow::{Context as _, Result, anyhow};
5use client::{ErrorCode, ErrorExt};
6use collections::{BTreeSet, HashMap, hash_map};
7use command_palette_hooks::CommandPaletteFilter;
8use db::kvp::KEY_VALUE_STORE;
9use editor::{
10 Editor, EditorEvent, EditorSettings, ShowScrollbar,
11 items::{
12 entry_diagnostic_aware_icon_decoration_and_color,
13 entry_diagnostic_aware_icon_name_and_color, entry_git_aware_label_color,
14 },
15 scroll::{Autoscroll, ScrollbarAutoHide},
16};
17use file_icons::FileIcons;
18use git::status::GitSummary;
19use gpui::{
20 Action, AnyElement, App, ArcCow, AsyncWindowContext, Bounds, ClipboardItem, Context,
21 DismissEvent, Div, DragMoveEvent, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable,
22 Hsla, InteractiveElement, KeyContext, ListHorizontalSizingBehavior, ListSizingBehavior,
23 MouseButton, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel, Render, ScrollStrategy,
24 Stateful, Styled, Subscription, Task, UniformListScrollHandle, WeakEntity, Window, actions,
25 anchored, deferred, div, impl_actions, point, px, size, uniform_list,
26};
27use indexmap::IndexMap;
28use language::DiagnosticSeverity;
29use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
30use project::{
31 Entry, EntryKind, Fs, GitEntry, GitEntryRef, GitTraversal, Project, ProjectEntryId,
32 ProjectPath, Worktree, WorktreeId,
33 git_store::{GitStoreEvent, git_traversal::ChildEntriesGitIter},
34 relativize_path,
35};
36use project_panel_settings::{
37 ProjectPanelDockPosition, ProjectPanelSettings, ShowDiagnostics, ShowIndentGuides,
38};
39use schemars::JsonSchema;
40use serde::{Deserialize, Serialize};
41use settings::{Settings, SettingsStore, update_settings_file};
42use smallvec::SmallVec;
43use std::any::TypeId;
44use std::{
45 cell::OnceCell,
46 cmp,
47 collections::HashSet,
48 ffi::OsStr,
49 ops::Range,
50 path::{Path, PathBuf},
51 sync::Arc,
52 time::Duration,
53};
54use theme::ThemeSettings;
55use ui::{
56 Color, ContextMenu, DecoratedIcon, Icon, IconDecoration, IconDecorationKind, IndentGuideColors,
57 IndentGuideLayout, KeyBinding, Label, LabelSize, ListItem, ListItemSpacing, Scrollbar,
58 ScrollbarState, Tooltip, prelude::*, v_flex,
59};
60use util::{ResultExt, TakeUntilExt, TryFutureExt, maybe, paths::compare_paths};
61use workspace::{
62 DraggedSelection, OpenInTerminal, OpenOptions, OpenVisible, PreviewTabsSettings, SelectedEntry,
63 Workspace,
64 dock::{DockPosition, Panel, PanelEvent},
65 notifications::{DetachAndPromptErr, NotifyTaskExt},
66};
67use worktree::CreatedEntry;
68
69const PROJECT_PANEL_KEY: &str = "ProjectPanel";
70const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
71
72pub struct ProjectPanel {
73 project: Entity<Project>,
74 fs: Arc<dyn Fs>,
75 focus_handle: FocusHandle,
76 scroll_handle: UniformListScrollHandle,
77 // An update loop that keeps incrementing/decrementing scroll offset while there is a dragged entry that's
78 // hovered over the start/end of a list.
79 hover_scroll_task: Option<Task<()>>,
80 visible_entries: Vec<(WorktreeId, Vec<GitEntry>, OnceCell<HashSet<Arc<Path>>>)>,
81 /// Maps from leaf project entry ID to the currently selected ancestor.
82 /// Relevant only for auto-fold dirs, where a single project panel entry may actually consist of several
83 /// project entries (and all non-leaf nodes are guaranteed to be directories).
84 ancestors: HashMap<ProjectEntryId, FoldedAncestors>,
85 folded_directory_drag_target: Option<FoldedDirectoryDragTarget>,
86 last_worktree_root_id: Option<ProjectEntryId>,
87 last_selection_drag_over_entry: Option<ProjectEntryId>,
88 last_external_paths_drag_over_entry: Option<ProjectEntryId>,
89 expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
90 unfolded_dir_ids: HashSet<ProjectEntryId>,
91 // Currently selected leaf entry (see auto-folding for a definition of that) in a file tree
92 selection: Option<SelectedEntry>,
93 marked_entries: BTreeSet<SelectedEntry>,
94 context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
95 edit_state: Option<EditState>,
96 filename_editor: Entity<Editor>,
97 clipboard: Option<ClipboardEntry>,
98 _dragged_entry_destination: Option<Arc<Path>>,
99 workspace: WeakEntity<Workspace>,
100 width: Option<Pixels>,
101 pending_serialization: Task<Option<()>>,
102 show_scrollbar: bool,
103 vertical_scrollbar_state: ScrollbarState,
104 horizontal_scrollbar_state: ScrollbarState,
105 hide_scrollbar_task: Option<Task<()>>,
106 diagnostics: HashMap<(WorktreeId, PathBuf), DiagnosticSeverity>,
107 max_width_item_index: Option<usize>,
108 // We keep track of the mouse down state on entries so we don't flash the UI
109 // in case a user clicks to open a file.
110 mouse_down: bool,
111 hover_expand_task: Option<Task<()>>,
112}
113
114#[derive(Copy, Clone, Debug)]
115struct FoldedDirectoryDragTarget {
116 entry_id: ProjectEntryId,
117 index: usize,
118 /// Whether we are dragging over the delimiter rather than the component itself.
119 is_delimiter_target: bool,
120}
121
122#[derive(Clone, Debug)]
123enum ValidationState {
124 None,
125 Warning(String),
126 Error(String),
127}
128
129#[derive(Clone, Debug)]
130struct EditState {
131 worktree_id: WorktreeId,
132 entry_id: ProjectEntryId,
133 leaf_entry_id: Option<ProjectEntryId>,
134 is_dir: bool,
135 depth: usize,
136 processing_filename: Option<String>,
137 previously_focused: Option<SelectedEntry>,
138 validation_state: ValidationState,
139}
140
141impl EditState {
142 fn is_new_entry(&self) -> bool {
143 self.leaf_entry_id.is_none()
144 }
145}
146
147#[derive(Clone, Debug)]
148enum ClipboardEntry {
149 Copied(BTreeSet<SelectedEntry>),
150 Cut(BTreeSet<SelectedEntry>),
151}
152
153#[derive(Debug, PartialEq, Eq, Clone)]
154struct EntryDetails {
155 filename: String,
156 icon: Option<SharedString>,
157 path: Arc<Path>,
158 depth: usize,
159 kind: EntryKind,
160 is_ignored: bool,
161 is_expanded: bool,
162 is_selected: bool,
163 is_marked: bool,
164 is_editing: bool,
165 is_processing: bool,
166 is_cut: bool,
167 filename_text_color: Color,
168 diagnostic_severity: Option<DiagnosticSeverity>,
169 git_status: GitSummary,
170 is_private: bool,
171 worktree_id: WorktreeId,
172 canonical_path: Option<Arc<Path>>,
173}
174
175#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema)]
176#[serde(deny_unknown_fields)]
177struct Delete {
178 #[serde(default)]
179 pub skip_prompt: bool,
180}
181
182#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema)]
183#[serde(deny_unknown_fields)]
184struct Trash {
185 #[serde(default)]
186 pub skip_prompt: bool,
187}
188
189impl_actions!(project_panel, [Delete, Trash]);
190
191actions!(
192 project_panel,
193 [
194 ExpandSelectedEntry,
195 CollapseSelectedEntry,
196 CollapseAllEntries,
197 NewDirectory,
198 NewFile,
199 Copy,
200 Duplicate,
201 RevealInFileManager,
202 RemoveFromProject,
203 OpenWithSystem,
204 Cut,
205 Paste,
206 Rename,
207 Open,
208 OpenPermanent,
209 ToggleFocus,
210 ToggleHideGitIgnore,
211 NewSearchInDirectory,
212 UnfoldDirectory,
213 FoldDirectory,
214 SelectParent,
215 SelectNextGitEntry,
216 SelectPrevGitEntry,
217 SelectNextDiagnostic,
218 SelectPrevDiagnostic,
219 SelectNextDirectory,
220 SelectPrevDirectory,
221 ]
222);
223
224#[derive(Debug, Default)]
225struct FoldedAncestors {
226 current_ancestor_depth: usize,
227 ancestors: Vec<ProjectEntryId>,
228}
229
230impl FoldedAncestors {
231 fn max_ancestor_depth(&self) -> usize {
232 self.ancestors.len()
233 }
234}
235
236pub fn init_settings(cx: &mut App) {
237 ProjectPanelSettings::register(cx);
238}
239
240pub fn init(cx: &mut App) {
241 init_settings(cx);
242
243 cx.observe_new(|workspace: &mut Workspace, _, _| {
244 workspace.register_action(|workspace, _: &ToggleFocus, window, cx| {
245 workspace.toggle_panel_focus::<ProjectPanel>(window, cx);
246 });
247
248 workspace.register_action(|workspace, _: &ToggleHideGitIgnore, _, cx| {
249 let fs = workspace.app_state().fs.clone();
250 update_settings_file::<ProjectPanelSettings>(fs, cx, move |setting, _| {
251 setting.hide_gitignore = Some(!setting.hide_gitignore.unwrap_or(false));
252 })
253 });
254 })
255 .detach();
256}
257
258#[derive(Debug)]
259pub enum Event {
260 OpenedEntry {
261 entry_id: ProjectEntryId,
262 focus_opened_item: bool,
263 allow_preview: bool,
264 },
265 SplitEntry {
266 entry_id: ProjectEntryId,
267 },
268 Focus,
269}
270
271#[derive(Serialize, Deserialize)]
272struct SerializedProjectPanel {
273 width: Option<Pixels>,
274}
275
276struct DraggedProjectEntryView {
277 selection: SelectedEntry,
278 details: EntryDetails,
279 click_offset: Point<Pixels>,
280 selections: Arc<BTreeSet<SelectedEntry>>,
281}
282
283struct ItemColors {
284 default: Hsla,
285 hover: Hsla,
286 drag_over: Hsla,
287 marked: Hsla,
288 focused: Hsla,
289}
290
291fn get_item_color(cx: &App) -> ItemColors {
292 let colors = cx.theme().colors();
293
294 ItemColors {
295 default: colors.panel_background,
296 hover: colors.element_hover,
297 marked: colors.element_selected,
298 focused: colors.panel_focused_border,
299 drag_over: colors.drop_target_background,
300 }
301}
302
303impl ProjectPanel {
304 fn new(
305 workspace: &mut Workspace,
306 window: &mut Window,
307 cx: &mut Context<Workspace>,
308 ) -> Entity<Self> {
309 let project = workspace.project().clone();
310 let git_store = project.read(cx).git_store().clone();
311 let project_panel = cx.new(|cx| {
312 let focus_handle = cx.focus_handle();
313 cx.on_focus(&focus_handle, window, Self::focus_in).detach();
314 cx.on_focus_out(&focus_handle, window, |this, _, window, cx| {
315 this.focus_out(window, cx);
316 this.hide_scrollbar(window, cx);
317 })
318 .detach();
319
320 cx.subscribe(&git_store, |this, _, event, cx| match event {
321 GitStoreEvent::RepositoryUpdated(_, _, _)
322 | GitStoreEvent::RepositoryAdded(_)
323 | GitStoreEvent::RepositoryRemoved(_) => {
324 this.update_visible_entries(None, cx);
325 cx.notify();
326 }
327 _ => {}
328 })
329 .detach();
330
331 cx.subscribe(&project, |this, project, event, cx| match event {
332 project::Event::ActiveEntryChanged(Some(entry_id)) => {
333 if ProjectPanelSettings::get_global(cx).auto_reveal_entries {
334 this.reveal_entry(project.clone(), *entry_id, true, cx).ok();
335 }
336 }
337 project::Event::ActiveEntryChanged(None) => {
338 this.marked_entries.clear();
339 }
340 project::Event::RevealInProjectPanel(entry_id) => {
341 if let Some(()) = this
342 .reveal_entry(project.clone(), *entry_id, false, cx)
343 .log_err()
344 {
345 cx.emit(PanelEvent::Activate);
346 }
347 }
348 project::Event::ActivateProjectPanel => {
349 cx.emit(PanelEvent::Activate);
350 }
351 project::Event::DiskBasedDiagnosticsFinished { .. }
352 | project::Event::DiagnosticsUpdated { .. } => {
353 if ProjectPanelSettings::get_global(cx).show_diagnostics != ShowDiagnostics::Off
354 {
355 this.update_diagnostics(cx);
356 cx.notify();
357 }
358 }
359 project::Event::WorktreeRemoved(id) => {
360 this.expanded_dir_ids.remove(id);
361 this.update_visible_entries(None, cx);
362 cx.notify();
363 }
364 project::Event::WorktreeUpdatedEntries(_, _)
365 | project::Event::WorktreeAdded(_)
366 | project::Event::WorktreeOrderChanged => {
367 this.update_visible_entries(None, cx);
368 cx.notify();
369 }
370 project::Event::ExpandedAllForEntry(worktree_id, entry_id) => {
371 if let Some((worktree, expanded_dir_ids)) = project
372 .read(cx)
373 .worktree_for_id(*worktree_id, cx)
374 .zip(this.expanded_dir_ids.get_mut(&worktree_id))
375 {
376 let worktree = worktree.read(cx);
377
378 let Some(entry) = worktree.entry_for_id(*entry_id) else {
379 return;
380 };
381 let include_ignored_dirs = !entry.is_ignored;
382
383 let mut dirs_to_expand = vec![*entry_id];
384 while let Some(current_id) = dirs_to_expand.pop() {
385 let Some(current_entry) = worktree.entry_for_id(current_id) else {
386 continue;
387 };
388 for child in worktree.child_entries(¤t_entry.path) {
389 if !child.is_dir() || (include_ignored_dirs && child.is_ignored) {
390 continue;
391 }
392
393 dirs_to_expand.push(child.id);
394
395 if let Err(ix) = expanded_dir_ids.binary_search(&child.id) {
396 expanded_dir_ids.insert(ix, child.id);
397 }
398 this.unfolded_dir_ids.insert(child.id);
399 }
400 }
401 this.update_visible_entries(None, cx);
402 cx.notify();
403 }
404 }
405 _ => {}
406 })
407 .detach();
408
409 let trash_action = [TypeId::of::<Trash>()];
410 let is_remote = project.read(cx).is_via_collab();
411
412 if is_remote {
413 CommandPaletteFilter::update_global(cx, |filter, _cx| {
414 filter.hide_action_types(&trash_action);
415 });
416 }
417
418 let filename_editor = cx.new(|cx| Editor::single_line(window, cx));
419
420 cx.subscribe(
421 &filename_editor,
422 |project_panel, _, editor_event, cx| match editor_event {
423 EditorEvent::BufferEdited => {
424 project_panel.populate_validation_error(cx);
425 project_panel.autoscroll(cx);
426 }
427 EditorEvent::SelectionsChanged { .. } => {
428 project_panel.autoscroll(cx);
429 }
430 EditorEvent::Blurred => {
431 if project_panel
432 .edit_state
433 .as_ref()
434 .map_or(false, |state| state.processing_filename.is_none())
435 {
436 project_panel.edit_state = None;
437 project_panel.update_visible_entries(None, cx);
438 cx.notify();
439 }
440 }
441 _ => {}
442 },
443 )
444 .detach();
445
446 cx.observe_global::<FileIcons>(|_, cx| {
447 cx.notify();
448 })
449 .detach();
450
451 let mut project_panel_settings = *ProjectPanelSettings::get_global(cx);
452 cx.observe_global::<SettingsStore>(move |this, cx| {
453 let new_settings = *ProjectPanelSettings::get_global(cx);
454 if project_panel_settings != new_settings {
455 if project_panel_settings.hide_gitignore != new_settings.hide_gitignore {
456 this.update_visible_entries(None, cx);
457 }
458 project_panel_settings = new_settings;
459 this.update_diagnostics(cx);
460 cx.notify();
461 }
462 })
463 .detach();
464
465 let scroll_handle = UniformListScrollHandle::new();
466 let mut this = Self {
467 project: project.clone(),
468 hover_scroll_task: None,
469 fs: workspace.app_state().fs.clone(),
470 focus_handle,
471 visible_entries: Default::default(),
472 ancestors: Default::default(),
473 folded_directory_drag_target: None,
474 last_worktree_root_id: Default::default(),
475 last_external_paths_drag_over_entry: None,
476 last_selection_drag_over_entry: None,
477 expanded_dir_ids: Default::default(),
478 unfolded_dir_ids: Default::default(),
479 selection: None,
480 marked_entries: Default::default(),
481 edit_state: None,
482 context_menu: None,
483 filename_editor,
484 clipboard: None,
485 _dragged_entry_destination: None,
486 workspace: workspace.weak_handle(),
487 width: None,
488 pending_serialization: Task::ready(None),
489 show_scrollbar: !Self::should_autohide_scrollbar(cx),
490 hide_scrollbar_task: None,
491 vertical_scrollbar_state: ScrollbarState::new(scroll_handle.clone())
492 .parent_entity(&cx.entity()),
493 horizontal_scrollbar_state: ScrollbarState::new(scroll_handle.clone())
494 .parent_entity(&cx.entity()),
495 max_width_item_index: None,
496 diagnostics: Default::default(),
497 scroll_handle,
498 mouse_down: false,
499 hover_expand_task: None,
500 };
501 this.update_visible_entries(None, cx);
502
503 this
504 });
505
506 cx.subscribe_in(&project_panel, window, {
507 let project_panel = project_panel.downgrade();
508 move |workspace, _, event, window, cx| match event {
509 &Event::OpenedEntry {
510 entry_id,
511 focus_opened_item,
512 allow_preview,
513 } => {
514 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
515 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
516 let file_path = entry.path.clone();
517 let worktree_id = worktree.read(cx).id();
518 let entry_id = entry.id;
519 let is_via_ssh = project.read(cx).is_via_ssh();
520
521 workspace
522 .open_path_preview(
523 ProjectPath {
524 worktree_id,
525 path: file_path.clone(),
526 },
527 None,
528 focus_opened_item,
529 allow_preview,
530 true,
531 window, cx,
532 )
533 .detach_and_prompt_err("Failed to open file", window, cx, move |e, _, _| {
534 match e.error_code() {
535 ErrorCode::Disconnected => if is_via_ssh {
536 Some("Disconnected from SSH host".to_string())
537 } else {
538 Some("Disconnected from remote project".to_string())
539 },
540 ErrorCode::UnsharedItem => Some(format!(
541 "{} is not shared by the host. This could be because it has been marked as `private`",
542 file_path.display()
543 )),
544 // See note in worktree.rs where this error originates. Returning Some in this case prevents
545 // the error popup from saying "Try Again", which is a red herring in this case
546 ErrorCode::Internal if e.to_string().contains("File is too large to load") => Some(e.to_string()),
547 _ => None,
548 }
549 });
550
551 if let Some(project_panel) = project_panel.upgrade() {
552 // Always select and mark the entry, regardless of whether it is opened or not.
553 project_panel.update(cx, |project_panel, _| {
554 let entry = SelectedEntry { worktree_id, entry_id };
555 project_panel.marked_entries.clear();
556 project_panel.marked_entries.insert(entry);
557 project_panel.selection = Some(entry);
558 });
559 if !focus_opened_item {
560 let focus_handle = project_panel.read(cx).focus_handle.clone();
561 window.focus(&focus_handle);
562 }
563 }
564 }
565 }
566 }
567 &Event::SplitEntry { entry_id } => {
568 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
569 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
570 workspace
571 .split_path(
572 ProjectPath {
573 worktree_id: worktree.read(cx).id(),
574 path: entry.path.clone(),
575 },
576 window, cx,
577 )
578 .detach_and_log_err(cx);
579 }
580 }
581 }
582
583 _ => {}
584 }
585 })
586 .detach();
587
588 project_panel
589 }
590
591 pub async fn load(
592 workspace: WeakEntity<Workspace>,
593 mut cx: AsyncWindowContext,
594 ) -> Result<Entity<Self>> {
595 let serialized_panel = cx
596 .background_spawn(async move { KEY_VALUE_STORE.read_kvp(PROJECT_PANEL_KEY) })
597 .await
598 .map_err(|e| anyhow!("Failed to load project panel: {}", e))
599 .log_err()
600 .flatten()
601 .map(|panel| serde_json::from_str::<SerializedProjectPanel>(&panel))
602 .transpose()
603 .log_err()
604 .flatten();
605
606 workspace.update_in(&mut cx, |workspace, window, cx| {
607 let panel = ProjectPanel::new(workspace, window, cx);
608 if let Some(serialized_panel) = serialized_panel {
609 panel.update(cx, |panel, cx| {
610 panel.width = serialized_panel.width.map(|px| px.round());
611 cx.notify();
612 });
613 }
614 panel
615 })
616 }
617
618 fn update_diagnostics(&mut self, cx: &mut Context<Self>) {
619 let mut diagnostics: HashMap<(WorktreeId, PathBuf), DiagnosticSeverity> =
620 Default::default();
621 let show_diagnostics_setting = ProjectPanelSettings::get_global(cx).show_diagnostics;
622
623 if show_diagnostics_setting != ShowDiagnostics::Off {
624 self.project
625 .read(cx)
626 .diagnostic_summaries(false, cx)
627 .filter_map(|(path, _, diagnostic_summary)| {
628 if diagnostic_summary.error_count > 0 {
629 Some((path, DiagnosticSeverity::ERROR))
630 } else if show_diagnostics_setting == ShowDiagnostics::All
631 && diagnostic_summary.warning_count > 0
632 {
633 Some((path, DiagnosticSeverity::WARNING))
634 } else {
635 None
636 }
637 })
638 .for_each(|(project_path, diagnostic_severity)| {
639 let mut path_buffer = PathBuf::new();
640 Self::update_strongest_diagnostic_severity(
641 &mut diagnostics,
642 &project_path,
643 path_buffer.clone(),
644 diagnostic_severity,
645 );
646
647 for component in project_path.path.components() {
648 path_buffer.push(component);
649 Self::update_strongest_diagnostic_severity(
650 &mut diagnostics,
651 &project_path,
652 path_buffer.clone(),
653 diagnostic_severity,
654 );
655 }
656 });
657 }
658 self.diagnostics = diagnostics;
659 }
660
661 fn update_strongest_diagnostic_severity(
662 diagnostics: &mut HashMap<(WorktreeId, PathBuf), DiagnosticSeverity>,
663 project_path: &ProjectPath,
664 path_buffer: PathBuf,
665 diagnostic_severity: DiagnosticSeverity,
666 ) {
667 diagnostics
668 .entry((project_path.worktree_id, path_buffer.clone()))
669 .and_modify(|strongest_diagnostic_severity| {
670 *strongest_diagnostic_severity =
671 cmp::min(*strongest_diagnostic_severity, diagnostic_severity);
672 })
673 .or_insert(diagnostic_severity);
674 }
675
676 fn serialize(&mut self, cx: &mut Context<Self>) {
677 let width = self.width;
678 self.pending_serialization = cx.background_spawn(
679 async move {
680 KEY_VALUE_STORE
681 .write_kvp(
682 PROJECT_PANEL_KEY.into(),
683 serde_json::to_string(&SerializedProjectPanel { width })?,
684 )
685 .await?;
686 anyhow::Ok(())
687 }
688 .log_err(),
689 );
690 }
691
692 fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
693 if !self.focus_handle.contains_focused(window, cx) {
694 cx.emit(Event::Focus);
695 }
696 }
697
698 fn focus_out(&mut self, window: &mut Window, cx: &mut Context<Self>) {
699 if !self.focus_handle.is_focused(window) {
700 self.confirm(&Confirm, window, cx);
701 }
702 }
703
704 fn deploy_context_menu(
705 &mut self,
706 position: Point<Pixels>,
707 entry_id: ProjectEntryId,
708 window: &mut Window,
709 cx: &mut Context<Self>,
710 ) {
711 let project = self.project.read(cx);
712
713 let worktree_id = if let Some(id) = project.worktree_id_for_entry(entry_id, cx) {
714 id
715 } else {
716 return;
717 };
718
719 self.selection = Some(SelectedEntry {
720 worktree_id,
721 entry_id,
722 });
723
724 if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
725 let auto_fold_dirs = ProjectPanelSettings::get_global(cx).auto_fold_dirs;
726 let worktree = worktree.read(cx);
727 let is_root = Some(entry) == worktree.root_entry();
728 let is_dir = entry.is_dir();
729 let is_foldable = auto_fold_dirs && self.is_foldable(entry, worktree);
730 let is_unfoldable = auto_fold_dirs && self.is_unfoldable(entry, worktree);
731 let is_read_only = project.is_read_only(cx);
732 let is_remote = project.is_via_collab();
733 let is_local = project.is_local();
734
735 let context_menu = ContextMenu::build(window, cx, |menu, _, _| {
736 menu.context(self.focus_handle.clone()).map(|menu| {
737 if is_read_only {
738 menu.when(is_dir, |menu| {
739 menu.action("Search Inside", Box::new(NewSearchInDirectory))
740 })
741 } else {
742 menu.action("New File", Box::new(NewFile))
743 .action("New Folder", Box::new(NewDirectory))
744 .separator()
745 .when(is_local && cfg!(target_os = "macos"), |menu| {
746 menu.action("Reveal in Finder", Box::new(RevealInFileManager))
747 })
748 .when(is_local && cfg!(not(target_os = "macos")), |menu| {
749 menu.action("Reveal in File Manager", Box::new(RevealInFileManager))
750 })
751 .when(is_local, |menu| {
752 menu.action("Open in Default App", Box::new(OpenWithSystem))
753 })
754 .action("Open in Terminal", Box::new(OpenInTerminal))
755 .when(is_dir, |menu| {
756 menu.separator()
757 .action("Find in Folder…", Box::new(NewSearchInDirectory))
758 })
759 .when(is_unfoldable, |menu| {
760 menu.action("Unfold Directory", Box::new(UnfoldDirectory))
761 })
762 .when(is_foldable, |menu| {
763 menu.action("Fold Directory", Box::new(FoldDirectory))
764 })
765 .separator()
766 .action("Cut", Box::new(Cut))
767 .action("Copy", Box::new(Copy))
768 .action("Duplicate", Box::new(Duplicate))
769 // TODO: Paste should always be visible, cbut disabled when clipboard is empty
770 .map(|menu| {
771 if self.clipboard.as_ref().is_some() {
772 menu.action("Paste", Box::new(Paste))
773 } else {
774 menu.disabled_action("Paste", Box::new(Paste))
775 }
776 })
777 .separator()
778 .action("Copy Path", Box::new(zed_actions::workspace::CopyPath))
779 .action(
780 "Copy Relative Path",
781 Box::new(zed_actions::workspace::CopyRelativePath),
782 )
783 .separator()
784 .when(!is_root || !cfg!(target_os = "windows"), |menu| {
785 menu.action("Rename", Box::new(Rename))
786 })
787 .when(!is_root & !is_remote, |menu| {
788 menu.action("Trash", Box::new(Trash { skip_prompt: false }))
789 })
790 .when(!is_root, |menu| {
791 menu.action("Delete", Box::new(Delete { skip_prompt: false }))
792 })
793 .when(!is_remote & is_root, |menu| {
794 menu.separator()
795 .action(
796 "Add Folder to Project…",
797 Box::new(workspace::AddFolderToProject),
798 )
799 .action("Remove from Project", Box::new(RemoveFromProject))
800 })
801 .when(is_root, |menu| {
802 menu.separator()
803 .action("Collapse All", Box::new(CollapseAllEntries))
804 })
805 }
806 })
807 });
808
809 window.focus(&context_menu.focus_handle(cx));
810 let subscription = cx.subscribe(&context_menu, |this, _, _: &DismissEvent, cx| {
811 this.context_menu.take();
812 cx.notify();
813 });
814 self.context_menu = Some((context_menu, position, subscription));
815 }
816
817 cx.notify();
818 }
819
820 fn is_unfoldable(&self, entry: &Entry, worktree: &Worktree) -> bool {
821 if !entry.is_dir() || self.unfolded_dir_ids.contains(&entry.id) {
822 return false;
823 }
824
825 if let Some(parent_path) = entry.path.parent() {
826 let snapshot = worktree.snapshot();
827 let mut child_entries = snapshot.child_entries(parent_path);
828 if let Some(child) = child_entries.next() {
829 if child_entries.next().is_none() {
830 return child.kind.is_dir();
831 }
832 }
833 };
834 false
835 }
836
837 fn is_foldable(&self, entry: &Entry, worktree: &Worktree) -> bool {
838 if entry.is_dir() {
839 let snapshot = worktree.snapshot();
840
841 let mut child_entries = snapshot.child_entries(&entry.path);
842 if let Some(child) = child_entries.next() {
843 if child_entries.next().is_none() {
844 return child.kind.is_dir();
845 }
846 }
847 }
848 false
849 }
850
851 fn expand_selected_entry(
852 &mut self,
853 _: &ExpandSelectedEntry,
854 window: &mut Window,
855 cx: &mut Context<Self>,
856 ) {
857 if let Some((worktree, entry)) = self.selected_entry(cx) {
858 if let Some(folded_ancestors) = self.ancestors.get_mut(&entry.id) {
859 if folded_ancestors.current_ancestor_depth > 0 {
860 folded_ancestors.current_ancestor_depth -= 1;
861 cx.notify();
862 return;
863 }
864 }
865 if entry.is_dir() {
866 let worktree_id = worktree.id();
867 let entry_id = entry.id;
868 let expanded_dir_ids =
869 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
870 expanded_dir_ids
871 } else {
872 return;
873 };
874
875 match expanded_dir_ids.binary_search(&entry_id) {
876 Ok(_) => self.select_next(&SelectNext, window, cx),
877 Err(ix) => {
878 self.project.update(cx, |project, cx| {
879 project.expand_entry(worktree_id, entry_id, cx);
880 });
881
882 expanded_dir_ids.insert(ix, entry_id);
883 self.update_visible_entries(None, cx);
884 cx.notify();
885 }
886 }
887 }
888 }
889 }
890
891 fn collapse_selected_entry(
892 &mut self,
893 _: &CollapseSelectedEntry,
894 _: &mut Window,
895 cx: &mut Context<Self>,
896 ) {
897 let Some((worktree, entry)) = self.selected_entry_handle(cx) else {
898 return;
899 };
900 self.collapse_entry(entry.clone(), worktree, cx)
901 }
902
903 fn collapse_entry(&mut self, entry: Entry, worktree: Entity<Worktree>, cx: &mut Context<Self>) {
904 let worktree = worktree.read(cx);
905 if let Some(folded_ancestors) = self.ancestors.get_mut(&entry.id) {
906 if folded_ancestors.current_ancestor_depth + 1 < folded_ancestors.max_ancestor_depth() {
907 folded_ancestors.current_ancestor_depth += 1;
908 cx.notify();
909 return;
910 }
911 }
912 let worktree_id = worktree.id();
913 let expanded_dir_ids =
914 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
915 expanded_dir_ids
916 } else {
917 return;
918 };
919
920 let mut entry = &entry;
921 loop {
922 let entry_id = entry.id;
923 match expanded_dir_ids.binary_search(&entry_id) {
924 Ok(ix) => {
925 expanded_dir_ids.remove(ix);
926 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
927 cx.notify();
928 break;
929 }
930 Err(_) => {
931 if let Some(parent_entry) =
932 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
933 {
934 entry = parent_entry;
935 } else {
936 break;
937 }
938 }
939 }
940 }
941 }
942
943 pub fn collapse_all_entries(
944 &mut self,
945 _: &CollapseAllEntries,
946 _: &mut Window,
947 cx: &mut Context<Self>,
948 ) {
949 // By keeping entries for fully collapsed worktrees, we avoid expanding them within update_visible_entries
950 // (which is it's default behavior when there's no entry for a worktree in expanded_dir_ids).
951 self.expanded_dir_ids
952 .retain(|_, expanded_entries| expanded_entries.is_empty());
953 self.update_visible_entries(None, cx);
954 cx.notify();
955 }
956
957 fn toggle_expanded(
958 &mut self,
959 entry_id: ProjectEntryId,
960 window: &mut Window,
961 cx: &mut Context<Self>,
962 ) {
963 if let Some(worktree_id) = self.project.read(cx).worktree_id_for_entry(entry_id, cx) {
964 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
965 self.project.update(cx, |project, cx| {
966 match expanded_dir_ids.binary_search(&entry_id) {
967 Ok(ix) => {
968 expanded_dir_ids.remove(ix);
969 }
970 Err(ix) => {
971 project.expand_entry(worktree_id, entry_id, cx);
972 expanded_dir_ids.insert(ix, entry_id);
973 }
974 }
975 });
976 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
977 window.focus(&self.focus_handle);
978 cx.notify();
979 }
980 }
981 }
982
983 fn toggle_expand_all(
984 &mut self,
985 entry_id: ProjectEntryId,
986 window: &mut Window,
987 cx: &mut Context<Self>,
988 ) {
989 if let Some(worktree_id) = self.project.read(cx).worktree_id_for_entry(entry_id, cx) {
990 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
991 match expanded_dir_ids.binary_search(&entry_id) {
992 Ok(_ix) => {
993 self.collapse_all_for_entry(worktree_id, entry_id, cx);
994 }
995 Err(_ix) => {
996 self.expand_all_for_entry(worktree_id, entry_id, cx);
997 }
998 }
999 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
1000 window.focus(&self.focus_handle);
1001 cx.notify();
1002 }
1003 }
1004 }
1005
1006 fn expand_all_for_entry(
1007 &mut self,
1008 worktree_id: WorktreeId,
1009 entry_id: ProjectEntryId,
1010 cx: &mut Context<Self>,
1011 ) {
1012 self.project.update(cx, |project, cx| {
1013 if let Some((worktree, expanded_dir_ids)) = project
1014 .worktree_for_id(worktree_id, cx)
1015 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
1016 {
1017 if let Some(task) = project.expand_all_for_entry(worktree_id, entry_id, cx) {
1018 task.detach();
1019 }
1020
1021 let worktree = worktree.read(cx);
1022
1023 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
1024 loop {
1025 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
1026 expanded_dir_ids.insert(ix, entry.id);
1027 }
1028
1029 if let Some(parent_entry) =
1030 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
1031 {
1032 entry = parent_entry;
1033 } else {
1034 break;
1035 }
1036 }
1037 }
1038 }
1039 });
1040 }
1041
1042 fn collapse_all_for_entry(
1043 &mut self,
1044 worktree_id: WorktreeId,
1045 entry_id: ProjectEntryId,
1046 cx: &mut Context<Self>,
1047 ) {
1048 self.project.update(cx, |project, cx| {
1049 if let Some((worktree, expanded_dir_ids)) = project
1050 .worktree_for_id(worktree_id, cx)
1051 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
1052 {
1053 let worktree = worktree.read(cx);
1054 let mut dirs_to_collapse = vec![entry_id];
1055 let auto_fold_enabled = ProjectPanelSettings::get_global(cx).auto_fold_dirs;
1056 while let Some(current_id) = dirs_to_collapse.pop() {
1057 let Some(current_entry) = worktree.entry_for_id(current_id) else {
1058 continue;
1059 };
1060 if let Ok(ix) = expanded_dir_ids.binary_search(¤t_id) {
1061 expanded_dir_ids.remove(ix);
1062 }
1063 if auto_fold_enabled {
1064 self.unfolded_dir_ids.remove(¤t_id);
1065 }
1066 for child in worktree.child_entries(¤t_entry.path) {
1067 if child.is_dir() {
1068 dirs_to_collapse.push(child.id);
1069 }
1070 }
1071 }
1072 }
1073 });
1074 }
1075
1076 fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
1077 if let Some(edit_state) = &self.edit_state {
1078 if edit_state.processing_filename.is_none() {
1079 self.filename_editor.update(cx, |editor, cx| {
1080 editor.move_to_beginning_of_line(
1081 &editor::actions::MoveToBeginningOfLine {
1082 stop_at_soft_wraps: false,
1083 stop_at_indent: false,
1084 },
1085 window,
1086 cx,
1087 );
1088 });
1089 return;
1090 }
1091 }
1092 if let Some(selection) = self.selection {
1093 let (mut worktree_ix, mut entry_ix, _) =
1094 self.index_for_selection(selection).unwrap_or_default();
1095 if entry_ix > 0 {
1096 entry_ix -= 1;
1097 } else if worktree_ix > 0 {
1098 worktree_ix -= 1;
1099 entry_ix = self.visible_entries[worktree_ix].1.len() - 1;
1100 } else {
1101 return;
1102 }
1103
1104 let (worktree_id, worktree_entries, _) = &self.visible_entries[worktree_ix];
1105 let selection = SelectedEntry {
1106 worktree_id: *worktree_id,
1107 entry_id: worktree_entries[entry_ix].id,
1108 };
1109 self.selection = Some(selection);
1110 if window.modifiers().shift {
1111 self.marked_entries.insert(selection);
1112 }
1113 self.autoscroll(cx);
1114 cx.notify();
1115 } else {
1116 self.select_first(&SelectFirst {}, window, cx);
1117 }
1118 }
1119
1120 fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
1121 if let Some(task) = self.confirm_edit(window, cx) {
1122 task.detach_and_notify_err(window, cx);
1123 }
1124 }
1125
1126 fn open(&mut self, _: &Open, window: &mut Window, cx: &mut Context<Self>) {
1127 let preview_tabs_enabled = PreviewTabsSettings::get_global(cx).enabled;
1128 self.open_internal(true, !preview_tabs_enabled, window, cx);
1129 }
1130
1131 fn open_permanent(&mut self, _: &OpenPermanent, window: &mut Window, cx: &mut Context<Self>) {
1132 self.open_internal(false, true, window, cx);
1133 }
1134
1135 fn open_internal(
1136 &mut self,
1137 allow_preview: bool,
1138 focus_opened_item: bool,
1139 window: &mut Window,
1140 cx: &mut Context<Self>,
1141 ) {
1142 if let Some((_, entry)) = self.selected_entry(cx) {
1143 if entry.is_file() {
1144 self.open_entry(entry.id, focus_opened_item, allow_preview, cx);
1145 cx.notify();
1146 } else {
1147 self.toggle_expanded(entry.id, window, cx);
1148 }
1149 }
1150 }
1151
1152 fn populate_validation_error(&mut self, cx: &mut Context<Self>) {
1153 let edit_state = match self.edit_state.as_mut() {
1154 Some(state) => state,
1155 None => return,
1156 };
1157 let filename = self.filename_editor.read(cx).text(cx);
1158 if !filename.is_empty() {
1159 if let Some(worktree) = self
1160 .project
1161 .read(cx)
1162 .worktree_for_id(edit_state.worktree_id, cx)
1163 {
1164 if let Some(entry) = worktree.read(cx).entry_for_id(edit_state.entry_id) {
1165 let mut already_exists = false;
1166 if edit_state.is_new_entry() {
1167 let new_path = entry.path.join(filename.trim_start_matches('/'));
1168 if worktree
1169 .read(cx)
1170 .entry_for_path(new_path.as_path())
1171 .is_some()
1172 {
1173 already_exists = true;
1174 }
1175 } else {
1176 let new_path = if let Some(parent) = entry.path.clone().parent() {
1177 parent.join(&filename)
1178 } else {
1179 filename.clone().into()
1180 };
1181 if let Some(existing) = worktree.read(cx).entry_for_path(new_path.as_path())
1182 {
1183 if existing.id != entry.id {
1184 already_exists = true;
1185 }
1186 }
1187 };
1188 if already_exists {
1189 edit_state.validation_state = ValidationState::Error(format!(
1190 "File or directory '{}' already exists at location. Please choose a different name.",
1191 filename
1192 ));
1193 cx.notify();
1194 return;
1195 }
1196 }
1197 }
1198 let trimmed_filename = filename.trim();
1199 if trimmed_filename.is_empty() {
1200 edit_state.validation_state =
1201 ValidationState::Error("File or directory name cannot be empty.".to_string());
1202 cx.notify();
1203 return;
1204 }
1205 if trimmed_filename != filename {
1206 edit_state.validation_state = ValidationState::Warning(
1207 "File or directory name contains leading or trailing whitespace.".to_string(),
1208 );
1209 cx.notify();
1210 return;
1211 }
1212 }
1213 edit_state.validation_state = ValidationState::None;
1214 cx.notify();
1215 }
1216
1217 fn confirm_edit(
1218 &mut self,
1219 window: &mut Window,
1220 cx: &mut Context<Self>,
1221 ) -> Option<Task<Result<()>>> {
1222 let edit_state = self.edit_state.as_mut()?;
1223 let worktree_id = edit_state.worktree_id;
1224 let is_new_entry = edit_state.is_new_entry();
1225 let filename = self.filename_editor.read(cx).text(cx);
1226 if filename.trim().is_empty() {
1227 return None;
1228 }
1229 #[cfg(not(target_os = "windows"))]
1230 let filename_indicates_dir = filename.ends_with("/");
1231 // On Windows, path separator could be either `/` or `\`.
1232 #[cfg(target_os = "windows")]
1233 let filename_indicates_dir = filename.ends_with("/") || filename.ends_with("\\");
1234 edit_state.is_dir =
1235 edit_state.is_dir || (edit_state.is_new_entry() && filename_indicates_dir);
1236 let is_dir = edit_state.is_dir;
1237 let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
1238 let entry = worktree.read(cx).entry_for_id(edit_state.entry_id)?.clone();
1239
1240 let edit_task;
1241 let edited_entry_id;
1242 if is_new_entry {
1243 self.selection = Some(SelectedEntry {
1244 worktree_id,
1245 entry_id: NEW_ENTRY_ID,
1246 });
1247 let new_path = entry.path.join(filename.trim_start_matches('/'));
1248 if worktree
1249 .read(cx)
1250 .entry_for_path(new_path.as_path())
1251 .is_some()
1252 {
1253 return None;
1254 }
1255
1256 edited_entry_id = NEW_ENTRY_ID;
1257 edit_task = self.project.update(cx, |project, cx| {
1258 project.create_entry((worktree_id, &new_path), is_dir, cx)
1259 });
1260 } else {
1261 let new_path = if let Some(parent) = entry.path.clone().parent() {
1262 parent.join(&filename)
1263 } else {
1264 filename.clone().into()
1265 };
1266 if let Some(existing) = worktree.read(cx).entry_for_path(new_path.as_path()) {
1267 if existing.id == entry.id {
1268 window.focus(&self.focus_handle);
1269 }
1270 return None;
1271 }
1272 edited_entry_id = entry.id;
1273 edit_task = self.project.update(cx, |project, cx| {
1274 project.rename_entry(entry.id, new_path.as_path(), cx)
1275 });
1276 };
1277
1278 window.focus(&self.focus_handle);
1279 edit_state.processing_filename = Some(filename);
1280 cx.notify();
1281
1282 Some(cx.spawn_in(window, async move |project_panel, cx| {
1283 let new_entry = edit_task.await;
1284 project_panel.update(cx, |project_panel, cx| {
1285 project_panel.edit_state = None;
1286 cx.notify();
1287 })?;
1288
1289 match new_entry {
1290 Err(e) => {
1291 project_panel.update( cx, |project_panel, cx| {
1292 project_panel.marked_entries.clear();
1293 project_panel.update_visible_entries(None, cx);
1294 }).ok();
1295 Err(e)?;
1296 }
1297 Ok(CreatedEntry::Included(new_entry)) => {
1298 project_panel.update( cx, |project_panel, cx| {
1299 if let Some(selection) = &mut project_panel.selection {
1300 if selection.entry_id == edited_entry_id {
1301 selection.worktree_id = worktree_id;
1302 selection.entry_id = new_entry.id;
1303 project_panel.marked_entries.clear();
1304 project_panel.expand_to_selection(cx);
1305 }
1306 }
1307 project_panel.update_visible_entries(None, cx);
1308 if is_new_entry && !is_dir {
1309 project_panel.open_entry(new_entry.id, true, false, cx);
1310 }
1311 cx.notify();
1312 })?;
1313 }
1314 Ok(CreatedEntry::Excluded { abs_path }) => {
1315 if let Some(open_task) = project_panel
1316 .update_in( cx, |project_panel, window, cx| {
1317 project_panel.marked_entries.clear();
1318 project_panel.update_visible_entries(None, cx);
1319
1320 if is_dir {
1321 project_panel.project.update(cx, |_, cx| {
1322 cx.emit(project::Event::Toast {
1323 notification_id: "excluded-directory".into(),
1324 message: format!("Created an excluded directory at {abs_path:?}.\nAlter `file_scan_exclusions` in the settings to show it in the panel")
1325 })
1326 });
1327 None
1328 } else {
1329 project_panel
1330 .workspace
1331 .update(cx, |workspace, cx| {
1332 workspace.open_abs_path(abs_path, OpenOptions { visible: Some(OpenVisible::All), ..Default::default() }, window, cx)
1333 })
1334 .ok()
1335 }
1336 })
1337 .ok()
1338 .flatten()
1339 {
1340 let _ = open_task.await?;
1341 }
1342 }
1343 }
1344 Ok(())
1345 }))
1346 }
1347
1348 fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context<Self>) {
1349 let previous_edit_state = self.edit_state.take();
1350 self.update_visible_entries(None, cx);
1351 self.marked_entries.clear();
1352
1353 if let Some(previously_focused) =
1354 previous_edit_state.and_then(|edit_state| edit_state.previously_focused)
1355 {
1356 self.selection = Some(previously_focused);
1357 self.autoscroll(cx);
1358 }
1359
1360 window.focus(&self.focus_handle);
1361 cx.notify();
1362 }
1363
1364 fn open_entry(
1365 &mut self,
1366 entry_id: ProjectEntryId,
1367 focus_opened_item: bool,
1368 allow_preview: bool,
1369
1370 cx: &mut Context<Self>,
1371 ) {
1372 cx.emit(Event::OpenedEntry {
1373 entry_id,
1374 focus_opened_item,
1375 allow_preview,
1376 });
1377 }
1378
1379 fn split_entry(&mut self, entry_id: ProjectEntryId, cx: &mut Context<Self>) {
1380 cx.emit(Event::SplitEntry { entry_id });
1381 }
1382
1383 fn new_file(&mut self, _: &NewFile, window: &mut Window, cx: &mut Context<Self>) {
1384 self.add_entry(false, window, cx)
1385 }
1386
1387 fn new_directory(&mut self, _: &NewDirectory, window: &mut Window, cx: &mut Context<Self>) {
1388 self.add_entry(true, window, cx)
1389 }
1390
1391 fn add_entry(&mut self, is_dir: bool, window: &mut Window, cx: &mut Context<Self>) {
1392 if let Some(SelectedEntry {
1393 worktree_id,
1394 entry_id,
1395 }) = self.selection
1396 {
1397 let directory_id;
1398 let new_entry_id = self.resolve_entry(entry_id);
1399 if let Some((worktree, expanded_dir_ids)) = self
1400 .project
1401 .read(cx)
1402 .worktree_for_id(worktree_id, cx)
1403 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
1404 {
1405 let worktree = worktree.read(cx);
1406 if let Some(mut entry) = worktree.entry_for_id(new_entry_id) {
1407 loop {
1408 if entry.is_dir() {
1409 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
1410 expanded_dir_ids.insert(ix, entry.id);
1411 }
1412 directory_id = entry.id;
1413 break;
1414 } else {
1415 if let Some(parent_path) = entry.path.parent() {
1416 if let Some(parent_entry) = worktree.entry_for_path(parent_path) {
1417 entry = parent_entry;
1418 continue;
1419 }
1420 }
1421 return;
1422 }
1423 }
1424 } else {
1425 return;
1426 };
1427 } else {
1428 return;
1429 };
1430 self.marked_entries.clear();
1431 self.edit_state = Some(EditState {
1432 worktree_id,
1433 entry_id: directory_id,
1434 leaf_entry_id: None,
1435 is_dir,
1436 processing_filename: None,
1437 previously_focused: self.selection,
1438 depth: 0,
1439 validation_state: ValidationState::None,
1440 });
1441 self.filename_editor.update(cx, |editor, cx| {
1442 editor.clear(window, cx);
1443 window.focus(&editor.focus_handle(cx));
1444 });
1445 self.update_visible_entries(Some((worktree_id, NEW_ENTRY_ID)), cx);
1446 self.autoscroll(cx);
1447 cx.notify();
1448 }
1449 }
1450
1451 fn unflatten_entry_id(&self, leaf_entry_id: ProjectEntryId) -> ProjectEntryId {
1452 if let Some(ancestors) = self.ancestors.get(&leaf_entry_id) {
1453 ancestors
1454 .ancestors
1455 .get(ancestors.current_ancestor_depth)
1456 .copied()
1457 .unwrap_or(leaf_entry_id)
1458 } else {
1459 leaf_entry_id
1460 }
1461 }
1462
1463 fn rename_impl(
1464 &mut self,
1465 selection: Option<Range<usize>>,
1466 window: &mut Window,
1467 cx: &mut Context<Self>,
1468 ) {
1469 if let Some(SelectedEntry {
1470 worktree_id,
1471 entry_id,
1472 }) = self.selection
1473 {
1474 if let Some(worktree) = self.project.read(cx).worktree_for_id(worktree_id, cx) {
1475 let sub_entry_id = self.unflatten_entry_id(entry_id);
1476 if let Some(entry) = worktree.read(cx).entry_for_id(sub_entry_id) {
1477 #[cfg(target_os = "windows")]
1478 if Some(entry) == worktree.read(cx).root_entry() {
1479 return;
1480 }
1481 self.edit_state = Some(EditState {
1482 worktree_id,
1483 entry_id: sub_entry_id,
1484 leaf_entry_id: Some(entry_id),
1485 is_dir: entry.is_dir(),
1486 processing_filename: None,
1487 previously_focused: None,
1488 depth: 0,
1489 validation_state: ValidationState::None,
1490 });
1491 let file_name = entry
1492 .path
1493 .file_name()
1494 .map(|s| s.to_string_lossy())
1495 .unwrap_or_default()
1496 .to_string();
1497 let selection = selection.unwrap_or_else(|| {
1498 let file_stem = entry.path.file_stem().map(|s| s.to_string_lossy());
1499 let selection_end =
1500 file_stem.map_or(file_name.len(), |file_stem| file_stem.len());
1501 0..selection_end
1502 });
1503 self.filename_editor.update(cx, |editor, cx| {
1504 editor.set_text(file_name, window, cx);
1505 editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| {
1506 s.select_ranges([selection])
1507 });
1508 window.focus(&editor.focus_handle(cx));
1509 });
1510 self.update_visible_entries(None, cx);
1511 self.autoscroll(cx);
1512 cx.notify();
1513 }
1514 }
1515 }
1516 }
1517
1518 fn rename(&mut self, _: &Rename, window: &mut Window, cx: &mut Context<Self>) {
1519 self.rename_impl(None, window, cx);
1520 }
1521
1522 fn trash(&mut self, action: &Trash, window: &mut Window, cx: &mut Context<Self>) {
1523 self.remove(true, action.skip_prompt, window, cx);
1524 }
1525
1526 fn delete(&mut self, action: &Delete, window: &mut Window, cx: &mut Context<Self>) {
1527 self.remove(false, action.skip_prompt, window, cx);
1528 }
1529
1530 fn remove(
1531 &mut self,
1532 trash: bool,
1533 skip_prompt: bool,
1534 window: &mut Window,
1535 cx: &mut Context<ProjectPanel>,
1536 ) {
1537 maybe!({
1538 let items_to_delete = self.disjoint_entries(cx);
1539 if items_to_delete.is_empty() {
1540 return None;
1541 }
1542 let project = self.project.read(cx);
1543
1544 let mut dirty_buffers = 0;
1545 let file_paths = items_to_delete
1546 .iter()
1547 .filter_map(|selection| {
1548 let project_path = project.path_for_entry(selection.entry_id, cx)?;
1549 dirty_buffers +=
1550 project.dirty_buffers(cx).any(|path| path == project_path) as usize;
1551 Some((
1552 selection.entry_id,
1553 project_path
1554 .path
1555 .file_name()?
1556 .to_string_lossy()
1557 .into_owned(),
1558 ))
1559 })
1560 .collect::<Vec<_>>();
1561 if file_paths.is_empty() {
1562 return None;
1563 }
1564 let answer = if !skip_prompt {
1565 let operation = if trash { "Trash" } else { "Delete" };
1566 let prompt = match file_paths.first() {
1567 Some((_, path)) if file_paths.len() == 1 => {
1568 let unsaved_warning = if dirty_buffers > 0 {
1569 "\n\nIt has unsaved changes, which will be lost."
1570 } else {
1571 ""
1572 };
1573
1574 format!("{operation} {path}?{unsaved_warning}")
1575 }
1576 _ => {
1577 const CUTOFF_POINT: usize = 10;
1578 let names = if file_paths.len() > CUTOFF_POINT {
1579 let truncated_path_counts = file_paths.len() - CUTOFF_POINT;
1580 let mut paths = file_paths
1581 .iter()
1582 .map(|(_, path)| path.clone())
1583 .take(CUTOFF_POINT)
1584 .collect::<Vec<_>>();
1585 paths.truncate(CUTOFF_POINT);
1586 if truncated_path_counts == 1 {
1587 paths.push(".. 1 file not shown".into());
1588 } else {
1589 paths.push(format!(".. {} files not shown", truncated_path_counts));
1590 }
1591 paths
1592 } else {
1593 file_paths.iter().map(|(_, path)| path.clone()).collect()
1594 };
1595 let unsaved_warning = if dirty_buffers == 0 {
1596 String::new()
1597 } else if dirty_buffers == 1 {
1598 "\n\n1 of these has unsaved changes, which will be lost.".to_string()
1599 } else {
1600 format!(
1601 "\n\n{dirty_buffers} of these have unsaved changes, which will be lost."
1602 )
1603 };
1604
1605 format!(
1606 "Do you want to {} the following {} files?\n{}{unsaved_warning}",
1607 operation.to_lowercase(),
1608 file_paths.len(),
1609 names.join("\n")
1610 )
1611 }
1612 };
1613 Some(window.prompt(PromptLevel::Info, &prompt, None, &[operation, "Cancel"], cx))
1614 } else {
1615 None
1616 };
1617 let next_selection = self.find_next_selection_after_deletion(items_to_delete, cx);
1618 cx.spawn_in(window, async move |panel, cx| {
1619 if let Some(answer) = answer {
1620 if answer.await != Ok(0) {
1621 return anyhow::Ok(());
1622 }
1623 }
1624 for (entry_id, _) in file_paths {
1625 panel
1626 .update(cx, |panel, cx| {
1627 panel
1628 .project
1629 .update(cx, |project, cx| project.delete_entry(entry_id, trash, cx))
1630 .context("no such entry")
1631 })??
1632 .await?;
1633 }
1634 panel.update_in(cx, |panel, window, cx| {
1635 if let Some(next_selection) = next_selection {
1636 panel.selection = Some(next_selection);
1637 panel.autoscroll(cx);
1638 } else {
1639 panel.select_last(&SelectLast {}, window, cx);
1640 }
1641 })?;
1642 Ok(())
1643 })
1644 .detach_and_log_err(cx);
1645 Some(())
1646 });
1647 }
1648
1649 fn find_next_selection_after_deletion(
1650 &self,
1651 sanitized_entries: BTreeSet<SelectedEntry>,
1652 cx: &mut Context<Self>,
1653 ) -> Option<SelectedEntry> {
1654 if sanitized_entries.is_empty() {
1655 return None;
1656 }
1657 let project = self.project.read(cx);
1658 let (worktree_id, worktree) = sanitized_entries
1659 .iter()
1660 .map(|entry| entry.worktree_id)
1661 .filter_map(|id| project.worktree_for_id(id, cx).map(|w| (id, w.read(cx))))
1662 .max_by(|(_, a), (_, b)| a.root_name().cmp(b.root_name()))?;
1663 let git_store = project.git_store().read(cx);
1664
1665 let marked_entries_in_worktree = sanitized_entries
1666 .iter()
1667 .filter(|e| e.worktree_id == worktree_id)
1668 .collect::<HashSet<_>>();
1669 let latest_entry = marked_entries_in_worktree
1670 .iter()
1671 .max_by(|a, b| {
1672 match (
1673 worktree.entry_for_id(a.entry_id),
1674 worktree.entry_for_id(b.entry_id),
1675 ) {
1676 (Some(a), Some(b)) => {
1677 compare_paths((&a.path, a.is_file()), (&b.path, b.is_file()))
1678 }
1679 _ => cmp::Ordering::Equal,
1680 }
1681 })
1682 .and_then(|e| worktree.entry_for_id(e.entry_id))?;
1683
1684 let parent_path = latest_entry.path.parent()?;
1685 let parent_entry = worktree.entry_for_path(parent_path)?;
1686
1687 // Remove all siblings that are being deleted except the last marked entry
1688 let repo_snapshots = git_store.repo_snapshots(cx);
1689 let worktree_snapshot = worktree.snapshot();
1690 let hide_gitignore = ProjectPanelSettings::get_global(cx).hide_gitignore;
1691 let mut siblings: Vec<_> =
1692 ChildEntriesGitIter::new(&repo_snapshots, &worktree_snapshot, parent_path)
1693 .filter(|sibling| {
1694 (sibling.id == latest_entry.id)
1695 || (!marked_entries_in_worktree.contains(&&SelectedEntry {
1696 worktree_id,
1697 entry_id: sibling.id,
1698 }) && (!hide_gitignore || !sibling.is_ignored))
1699 })
1700 .map(|entry| entry.to_owned())
1701 .collect();
1702
1703 project::sort_worktree_entries(&mut siblings);
1704 let sibling_entry_index = siblings
1705 .iter()
1706 .position(|sibling| sibling.id == latest_entry.id)?;
1707
1708 if let Some(next_sibling) = sibling_entry_index
1709 .checked_add(1)
1710 .and_then(|i| siblings.get(i))
1711 {
1712 return Some(SelectedEntry {
1713 worktree_id,
1714 entry_id: next_sibling.id,
1715 });
1716 }
1717 if let Some(prev_sibling) = sibling_entry_index
1718 .checked_sub(1)
1719 .and_then(|i| siblings.get(i))
1720 {
1721 return Some(SelectedEntry {
1722 worktree_id,
1723 entry_id: prev_sibling.id,
1724 });
1725 }
1726 // No neighbour sibling found, fall back to parent
1727 Some(SelectedEntry {
1728 worktree_id,
1729 entry_id: parent_entry.id,
1730 })
1731 }
1732
1733 fn unfold_directory(&mut self, _: &UnfoldDirectory, _: &mut Window, cx: &mut Context<Self>) {
1734 if let Some((worktree, entry)) = self.selected_entry(cx) {
1735 self.unfolded_dir_ids.insert(entry.id);
1736
1737 let snapshot = worktree.snapshot();
1738 let mut parent_path = entry.path.parent();
1739 while let Some(path) = parent_path {
1740 if let Some(parent_entry) = worktree.entry_for_path(path) {
1741 let mut children_iter = snapshot.child_entries(path);
1742
1743 if children_iter.by_ref().take(2).count() > 1 {
1744 break;
1745 }
1746
1747 self.unfolded_dir_ids.insert(parent_entry.id);
1748 parent_path = path.parent();
1749 } else {
1750 break;
1751 }
1752 }
1753
1754 self.update_visible_entries(None, cx);
1755 self.autoscroll(cx);
1756 cx.notify();
1757 }
1758 }
1759
1760 fn fold_directory(&mut self, _: &FoldDirectory, _: &mut Window, cx: &mut Context<Self>) {
1761 if let Some((worktree, entry)) = self.selected_entry(cx) {
1762 self.unfolded_dir_ids.remove(&entry.id);
1763
1764 let snapshot = worktree.snapshot();
1765 let mut path = &*entry.path;
1766 loop {
1767 let mut child_entries_iter = snapshot.child_entries(path);
1768 if let Some(child) = child_entries_iter.next() {
1769 if child_entries_iter.next().is_none() && child.is_dir() {
1770 self.unfolded_dir_ids.remove(&child.id);
1771 path = &*child.path;
1772 } else {
1773 break;
1774 }
1775 } else {
1776 break;
1777 }
1778 }
1779
1780 self.update_visible_entries(None, cx);
1781 self.autoscroll(cx);
1782 cx.notify();
1783 }
1784 }
1785
1786 fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
1787 if let Some(edit_state) = &self.edit_state {
1788 if edit_state.processing_filename.is_none() {
1789 self.filename_editor.update(cx, |editor, cx| {
1790 editor.move_to_end_of_line(
1791 &editor::actions::MoveToEndOfLine {
1792 stop_at_soft_wraps: false,
1793 },
1794 window,
1795 cx,
1796 );
1797 });
1798 return;
1799 }
1800 }
1801 if let Some(selection) = self.selection {
1802 let (mut worktree_ix, mut entry_ix, _) =
1803 self.index_for_selection(selection).unwrap_or_default();
1804 if let Some((_, worktree_entries, _)) = self.visible_entries.get(worktree_ix) {
1805 if entry_ix + 1 < worktree_entries.len() {
1806 entry_ix += 1;
1807 } else {
1808 worktree_ix += 1;
1809 entry_ix = 0;
1810 }
1811 }
1812
1813 if let Some((worktree_id, worktree_entries, _)) = self.visible_entries.get(worktree_ix)
1814 {
1815 if let Some(entry) = worktree_entries.get(entry_ix) {
1816 let selection = SelectedEntry {
1817 worktree_id: *worktree_id,
1818 entry_id: entry.id,
1819 };
1820 self.selection = Some(selection);
1821 if window.modifiers().shift {
1822 self.marked_entries.insert(selection);
1823 }
1824
1825 self.autoscroll(cx);
1826 cx.notify();
1827 }
1828 }
1829 } else {
1830 self.select_first(&SelectFirst {}, window, cx);
1831 }
1832 }
1833
1834 fn select_prev_diagnostic(
1835 &mut self,
1836 _: &SelectPrevDiagnostic,
1837 _: &mut Window,
1838 cx: &mut Context<Self>,
1839 ) {
1840 let selection = self.find_entry(
1841 self.selection.as_ref(),
1842 true,
1843 |entry, worktree_id| {
1844 (self.selection.is_none()
1845 || self.selection.is_some_and(|selection| {
1846 if selection.worktree_id == worktree_id {
1847 selection.entry_id != entry.id
1848 } else {
1849 true
1850 }
1851 }))
1852 && entry.is_file()
1853 && self
1854 .diagnostics
1855 .contains_key(&(worktree_id, entry.path.to_path_buf()))
1856 },
1857 cx,
1858 );
1859
1860 if let Some(selection) = selection {
1861 self.selection = Some(selection);
1862 self.expand_entry(selection.worktree_id, selection.entry_id, cx);
1863 self.update_visible_entries(Some((selection.worktree_id, selection.entry_id)), cx);
1864 self.autoscroll(cx);
1865 cx.notify();
1866 }
1867 }
1868
1869 fn select_next_diagnostic(
1870 &mut self,
1871 _: &SelectNextDiagnostic,
1872 _: &mut Window,
1873 cx: &mut Context<Self>,
1874 ) {
1875 let selection = self.find_entry(
1876 self.selection.as_ref(),
1877 false,
1878 |entry, worktree_id| {
1879 (self.selection.is_none()
1880 || self.selection.is_some_and(|selection| {
1881 if selection.worktree_id == worktree_id {
1882 selection.entry_id != entry.id
1883 } else {
1884 true
1885 }
1886 }))
1887 && entry.is_file()
1888 && self
1889 .diagnostics
1890 .contains_key(&(worktree_id, entry.path.to_path_buf()))
1891 },
1892 cx,
1893 );
1894
1895 if let Some(selection) = selection {
1896 self.selection = Some(selection);
1897 self.expand_entry(selection.worktree_id, selection.entry_id, cx);
1898 self.update_visible_entries(Some((selection.worktree_id, selection.entry_id)), cx);
1899 self.autoscroll(cx);
1900 cx.notify();
1901 }
1902 }
1903
1904 fn select_prev_git_entry(
1905 &mut self,
1906 _: &SelectPrevGitEntry,
1907 _: &mut Window,
1908 cx: &mut Context<Self>,
1909 ) {
1910 let selection = self.find_entry(
1911 self.selection.as_ref(),
1912 true,
1913 |entry, worktree_id| {
1914 (self.selection.is_none()
1915 || self.selection.is_some_and(|selection| {
1916 if selection.worktree_id == worktree_id {
1917 selection.entry_id != entry.id
1918 } else {
1919 true
1920 }
1921 }))
1922 && entry.is_file()
1923 && entry.git_summary.index.modified + entry.git_summary.worktree.modified > 0
1924 },
1925 cx,
1926 );
1927
1928 if let Some(selection) = selection {
1929 self.selection = Some(selection);
1930 self.expand_entry(selection.worktree_id, selection.entry_id, cx);
1931 self.update_visible_entries(Some((selection.worktree_id, selection.entry_id)), cx);
1932 self.autoscroll(cx);
1933 cx.notify();
1934 }
1935 }
1936
1937 fn select_prev_directory(
1938 &mut self,
1939 _: &SelectPrevDirectory,
1940 _: &mut Window,
1941 cx: &mut Context<Self>,
1942 ) {
1943 let selection = self.find_visible_entry(
1944 self.selection.as_ref(),
1945 true,
1946 |entry, worktree_id| {
1947 (self.selection.is_none()
1948 || self.selection.is_some_and(|selection| {
1949 if selection.worktree_id == worktree_id {
1950 selection.entry_id != entry.id
1951 } else {
1952 true
1953 }
1954 }))
1955 && entry.is_dir()
1956 },
1957 cx,
1958 );
1959
1960 if let Some(selection) = selection {
1961 self.selection = Some(selection);
1962 self.autoscroll(cx);
1963 cx.notify();
1964 }
1965 }
1966
1967 fn select_next_directory(
1968 &mut self,
1969 _: &SelectNextDirectory,
1970 _: &mut Window,
1971 cx: &mut Context<Self>,
1972 ) {
1973 let selection = self.find_visible_entry(
1974 self.selection.as_ref(),
1975 false,
1976 |entry, worktree_id| {
1977 (self.selection.is_none()
1978 || self.selection.is_some_and(|selection| {
1979 if selection.worktree_id == worktree_id {
1980 selection.entry_id != entry.id
1981 } else {
1982 true
1983 }
1984 }))
1985 && entry.is_dir()
1986 },
1987 cx,
1988 );
1989
1990 if let Some(selection) = selection {
1991 self.selection = Some(selection);
1992 self.autoscroll(cx);
1993 cx.notify();
1994 }
1995 }
1996
1997 fn select_next_git_entry(
1998 &mut self,
1999 _: &SelectNextGitEntry,
2000 _: &mut Window,
2001 cx: &mut Context<Self>,
2002 ) {
2003 let selection = self.find_entry(
2004 self.selection.as_ref(),
2005 false,
2006 |entry, worktree_id| {
2007 (self.selection.is_none()
2008 || self.selection.is_some_and(|selection| {
2009 if selection.worktree_id == worktree_id {
2010 selection.entry_id != entry.id
2011 } else {
2012 true
2013 }
2014 }))
2015 && entry.is_file()
2016 && entry.git_summary.index.modified + entry.git_summary.worktree.modified > 0
2017 },
2018 cx,
2019 );
2020
2021 if let Some(selection) = selection {
2022 self.selection = Some(selection);
2023 self.expand_entry(selection.worktree_id, selection.entry_id, cx);
2024 self.update_visible_entries(Some((selection.worktree_id, selection.entry_id)), cx);
2025 self.autoscroll(cx);
2026 cx.notify();
2027 }
2028 }
2029
2030 fn select_parent(&mut self, _: &SelectParent, window: &mut Window, cx: &mut Context<Self>) {
2031 if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
2032 if let Some(parent) = entry.path.parent() {
2033 let worktree = worktree.read(cx);
2034 if let Some(parent_entry) = worktree.entry_for_path(parent) {
2035 self.selection = Some(SelectedEntry {
2036 worktree_id: worktree.id(),
2037 entry_id: parent_entry.id,
2038 });
2039 self.autoscroll(cx);
2040 cx.notify();
2041 }
2042 }
2043 } else {
2044 self.select_first(&SelectFirst {}, window, cx);
2045 }
2046 }
2047
2048 fn select_first(&mut self, _: &SelectFirst, window: &mut Window, cx: &mut Context<Self>) {
2049 let worktree = self
2050 .visible_entries
2051 .first()
2052 .and_then(|(worktree_id, _, _)| {
2053 self.project.read(cx).worktree_for_id(*worktree_id, cx)
2054 });
2055 if let Some(worktree) = worktree {
2056 let worktree = worktree.read(cx);
2057 let worktree_id = worktree.id();
2058 if let Some(root_entry) = worktree.root_entry() {
2059 let selection = SelectedEntry {
2060 worktree_id,
2061 entry_id: root_entry.id,
2062 };
2063 self.selection = Some(selection);
2064 if window.modifiers().shift {
2065 self.marked_entries.insert(selection);
2066 }
2067 self.autoscroll(cx);
2068 cx.notify();
2069 }
2070 }
2071 }
2072
2073 fn select_last(&mut self, _: &SelectLast, _: &mut Window, cx: &mut Context<Self>) {
2074 if let Some((worktree_id, visible_worktree_entries, _)) = self.visible_entries.last() {
2075 let worktree = self.project.read(cx).worktree_for_id(*worktree_id, cx);
2076 if let (Some(worktree), Some(entry)) = (worktree, visible_worktree_entries.last()) {
2077 let worktree = worktree.read(cx);
2078 if let Some(entry) = worktree.entry_for_id(entry.id) {
2079 let selection = SelectedEntry {
2080 worktree_id: *worktree_id,
2081 entry_id: entry.id,
2082 };
2083 self.selection = Some(selection);
2084 self.autoscroll(cx);
2085 cx.notify();
2086 }
2087 }
2088 }
2089 }
2090
2091 fn autoscroll(&mut self, cx: &mut Context<Self>) {
2092 if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) {
2093 self.scroll_handle
2094 .scroll_to_item(index, ScrollStrategy::Center);
2095 cx.notify();
2096 }
2097 }
2098
2099 fn cut(&mut self, _: &Cut, _: &mut Window, cx: &mut Context<Self>) {
2100 let entries = self.disjoint_entries(cx);
2101 if !entries.is_empty() {
2102 self.clipboard = Some(ClipboardEntry::Cut(entries));
2103 cx.notify();
2104 }
2105 }
2106
2107 fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context<Self>) {
2108 let entries = self.disjoint_entries(cx);
2109 if !entries.is_empty() {
2110 self.clipboard = Some(ClipboardEntry::Copied(entries));
2111 cx.notify();
2112 }
2113 }
2114
2115 fn create_paste_path(
2116 &self,
2117 source: &SelectedEntry,
2118 (worktree, target_entry): (Entity<Worktree>, &Entry),
2119 cx: &App,
2120 ) -> Option<(PathBuf, Option<Range<usize>>)> {
2121 let mut new_path = target_entry.path.to_path_buf();
2122 // If we're pasting into a file, or a directory into itself, go up one level.
2123 if target_entry.is_file() || (target_entry.is_dir() && target_entry.id == source.entry_id) {
2124 new_path.pop();
2125 }
2126 let clipboard_entry_file_name = self
2127 .project
2128 .read(cx)
2129 .path_for_entry(source.entry_id, cx)?
2130 .path
2131 .file_name()?
2132 .to_os_string();
2133 new_path.push(&clipboard_entry_file_name);
2134 let extension = new_path.extension().map(|e| e.to_os_string());
2135 let file_name_without_extension = Path::new(&clipboard_entry_file_name).file_stem()?;
2136 let file_name_len = file_name_without_extension.to_string_lossy().len();
2137 let mut disambiguation_range = None;
2138 let mut ix = 0;
2139 {
2140 let worktree = worktree.read(cx);
2141 while worktree.entry_for_path(&new_path).is_some() {
2142 new_path.pop();
2143
2144 let mut new_file_name = file_name_without_extension.to_os_string();
2145
2146 let disambiguation = " copy";
2147 let mut disambiguation_len = disambiguation.len();
2148
2149 new_file_name.push(disambiguation);
2150
2151 if ix > 0 {
2152 let extra_disambiguation = format!(" {}", ix);
2153 disambiguation_len += extra_disambiguation.len();
2154
2155 new_file_name.push(extra_disambiguation);
2156 }
2157 if let Some(extension) = extension.as_ref() {
2158 new_file_name.push(".");
2159 new_file_name.push(extension);
2160 }
2161
2162 new_path.push(new_file_name);
2163 disambiguation_range = Some(file_name_len..(file_name_len + disambiguation_len));
2164 ix += 1;
2165 }
2166 }
2167 Some((new_path, disambiguation_range))
2168 }
2169
2170 fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
2171 maybe!({
2172 let (worktree, entry) = self.selected_entry_handle(cx)?;
2173 let entry = entry.clone();
2174 let worktree_id = worktree.read(cx).id();
2175 let clipboard_entries = self
2176 .clipboard
2177 .as_ref()
2178 .filter(|clipboard| !clipboard.items().is_empty())?;
2179 enum PasteTask {
2180 Rename(Task<Result<CreatedEntry>>),
2181 Copy(Task<Result<Option<Entry>>>),
2182 }
2183 let mut paste_entry_tasks: IndexMap<(ProjectEntryId, bool), PasteTask> =
2184 IndexMap::default();
2185 let mut disambiguation_range = None;
2186 let clip_is_cut = clipboard_entries.is_cut();
2187 for clipboard_entry in clipboard_entries.items() {
2188 let (new_path, new_disambiguation_range) =
2189 self.create_paste_path(clipboard_entry, self.selected_sub_entry(cx)?, cx)?;
2190 let clip_entry_id = clipboard_entry.entry_id;
2191 let is_same_worktree = clipboard_entry.worktree_id == worktree_id;
2192 let relative_worktree_source_path = if !is_same_worktree {
2193 let target_base_path = worktree.read(cx).abs_path();
2194 let clipboard_project_path =
2195 self.project.read(cx).path_for_entry(clip_entry_id, cx)?;
2196 let clipboard_abs_path = self
2197 .project
2198 .read(cx)
2199 .absolute_path(&clipboard_project_path, cx)?;
2200 Some(relativize_path(
2201 &target_base_path,
2202 clipboard_abs_path.as_path(),
2203 ))
2204 } else {
2205 None
2206 };
2207 let task = if clip_is_cut && is_same_worktree {
2208 let task = self.project.update(cx, |project, cx| {
2209 project.rename_entry(clip_entry_id, new_path, cx)
2210 });
2211 PasteTask::Rename(task)
2212 } else {
2213 let entry_id = if is_same_worktree {
2214 clip_entry_id
2215 } else {
2216 entry.id
2217 };
2218 let task = self.project.update(cx, |project, cx| {
2219 project.copy_entry(entry_id, relative_worktree_source_path, new_path, cx)
2220 });
2221 PasteTask::Copy(task)
2222 };
2223 let needs_delete = !is_same_worktree && clip_is_cut;
2224 paste_entry_tasks.insert((clip_entry_id, needs_delete), task);
2225 disambiguation_range = new_disambiguation_range.or(disambiguation_range);
2226 }
2227
2228 let item_count = paste_entry_tasks.len();
2229
2230 cx.spawn_in(window, async move |project_panel, cx| {
2231 let mut last_succeed = None;
2232 let mut need_delete_ids = Vec::new();
2233 for ((entry_id, need_delete), task) in paste_entry_tasks.into_iter() {
2234 match task {
2235 PasteTask::Rename(task) => {
2236 if let Some(CreatedEntry::Included(entry)) = task.await.log_err() {
2237 last_succeed = Some(entry);
2238 }
2239 }
2240 PasteTask::Copy(task) => {
2241 if let Some(Some(entry)) = task.await.log_err() {
2242 last_succeed = Some(entry);
2243 if need_delete {
2244 need_delete_ids.push(entry_id);
2245 }
2246 }
2247 }
2248 }
2249 }
2250 // remove entry for cut in difference worktree
2251 for entry_id in need_delete_ids {
2252 project_panel
2253 .update(cx, |project_panel, cx| {
2254 project_panel
2255 .project
2256 .update(cx, |project, cx| project.delete_entry(entry_id, true, cx))
2257 .ok_or_else(|| anyhow!("no such entry"))
2258 })??
2259 .await?;
2260 }
2261 // update selection
2262 if let Some(entry) = last_succeed {
2263 project_panel
2264 .update_in(cx, |project_panel, window, cx| {
2265 project_panel.selection = Some(SelectedEntry {
2266 worktree_id,
2267 entry_id: entry.id,
2268 });
2269
2270 if item_count == 1 {
2271 // open entry if not dir, and only focus if rename is not pending
2272 if !entry.is_dir() {
2273 project_panel.open_entry(
2274 entry.id,
2275 disambiguation_range.is_none(),
2276 false,
2277 cx,
2278 );
2279 }
2280
2281 // if only one entry was pasted and it was disambiguated, open the rename editor
2282 if disambiguation_range.is_some() {
2283 cx.defer_in(window, |this, window, cx| {
2284 this.rename_impl(disambiguation_range, window, cx);
2285 });
2286 }
2287 }
2288 })
2289 .ok();
2290 }
2291
2292 anyhow::Ok(())
2293 })
2294 .detach_and_log_err(cx);
2295
2296 self.expand_entry(worktree_id, entry.id, cx);
2297 Some(())
2298 });
2299 }
2300
2301 fn duplicate(&mut self, _: &Duplicate, window: &mut Window, cx: &mut Context<Self>) {
2302 self.copy(&Copy {}, window, cx);
2303 self.paste(&Paste {}, window, cx);
2304 }
2305
2306 fn copy_path(
2307 &mut self,
2308 _: &zed_actions::workspace::CopyPath,
2309 _: &mut Window,
2310 cx: &mut Context<Self>,
2311 ) {
2312 let abs_file_paths = {
2313 let project = self.project.read(cx);
2314 self.effective_entries()
2315 .into_iter()
2316 .filter_map(|entry| {
2317 let entry_path = project.path_for_entry(entry.entry_id, cx)?.path;
2318 Some(
2319 project
2320 .worktree_for_id(entry.worktree_id, cx)?
2321 .read(cx)
2322 .abs_path()
2323 .join(entry_path)
2324 .to_string_lossy()
2325 .to_string(),
2326 )
2327 })
2328 .collect::<Vec<_>>()
2329 };
2330 if !abs_file_paths.is_empty() {
2331 cx.write_to_clipboard(ClipboardItem::new_string(abs_file_paths.join("\n")));
2332 }
2333 }
2334
2335 fn copy_relative_path(
2336 &mut self,
2337 _: &zed_actions::workspace::CopyRelativePath,
2338 _: &mut Window,
2339 cx: &mut Context<Self>,
2340 ) {
2341 let file_paths = {
2342 let project = self.project.read(cx);
2343 self.effective_entries()
2344 .into_iter()
2345 .filter_map(|entry| {
2346 Some(
2347 project
2348 .path_for_entry(entry.entry_id, cx)?
2349 .path
2350 .to_string_lossy()
2351 .to_string(),
2352 )
2353 })
2354 .collect::<Vec<_>>()
2355 };
2356 if !file_paths.is_empty() {
2357 cx.write_to_clipboard(ClipboardItem::new_string(file_paths.join("\n")));
2358 }
2359 }
2360
2361 fn reveal_in_finder(
2362 &mut self,
2363 _: &RevealInFileManager,
2364 _: &mut Window,
2365 cx: &mut Context<Self>,
2366 ) {
2367 if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
2368 cx.reveal_path(&worktree.read(cx).abs_path().join(&entry.path));
2369 }
2370 }
2371
2372 fn remove_from_project(
2373 &mut self,
2374 _: &RemoveFromProject,
2375 _window: &mut Window,
2376 cx: &mut Context<Self>,
2377 ) {
2378 for entry in self.effective_entries().iter() {
2379 let worktree_id = entry.worktree_id;
2380 self.project
2381 .update(cx, |project, cx| project.remove_worktree(worktree_id, cx));
2382 }
2383 }
2384
2385 fn open_system(&mut self, _: &OpenWithSystem, _: &mut Window, cx: &mut Context<Self>) {
2386 if let Some((worktree, entry)) = self.selected_entry(cx) {
2387 let abs_path = worktree.abs_path().join(&entry.path);
2388 cx.open_with_system(&abs_path);
2389 }
2390 }
2391
2392 fn open_in_terminal(
2393 &mut self,
2394 _: &OpenInTerminal,
2395 window: &mut Window,
2396 cx: &mut Context<Self>,
2397 ) {
2398 if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
2399 let abs_path = match &entry.canonical_path {
2400 Some(canonical_path) => Some(canonical_path.to_path_buf()),
2401 None => worktree.read(cx).absolutize(&entry.path).ok(),
2402 };
2403
2404 let working_directory = if entry.is_dir() {
2405 abs_path
2406 } else {
2407 abs_path.and_then(|path| Some(path.parent()?.to_path_buf()))
2408 };
2409 if let Some(working_directory) = working_directory {
2410 window.dispatch_action(
2411 workspace::OpenTerminal { working_directory }.boxed_clone(),
2412 cx,
2413 )
2414 }
2415 }
2416 }
2417
2418 pub fn new_search_in_directory(
2419 &mut self,
2420 _: &NewSearchInDirectory,
2421 window: &mut Window,
2422 cx: &mut Context<Self>,
2423 ) {
2424 if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
2425 let dir_path = if entry.is_dir() {
2426 entry.path.clone()
2427 } else {
2428 // entry is a file, use its parent directory
2429 match entry.path.parent() {
2430 Some(parent) => Arc::from(parent),
2431 None => {
2432 // File at root, open search with empty filter
2433 self.workspace
2434 .update(cx, |workspace, cx| {
2435 search::ProjectSearchView::new_search_in_directory(
2436 workspace,
2437 Path::new(""),
2438 window,
2439 cx,
2440 );
2441 })
2442 .ok();
2443 return;
2444 }
2445 }
2446 };
2447
2448 let include_root = self.project.read(cx).visible_worktrees(cx).count() > 1;
2449 let dir_path = if include_root {
2450 let mut full_path = PathBuf::from(worktree.read(cx).root_name());
2451 full_path.push(&dir_path);
2452 Arc::from(full_path)
2453 } else {
2454 dir_path
2455 };
2456
2457 self.workspace
2458 .update(cx, |workspace, cx| {
2459 search::ProjectSearchView::new_search_in_directory(
2460 workspace, &dir_path, window, cx,
2461 );
2462 })
2463 .ok();
2464 }
2465 }
2466
2467 fn move_entry(
2468 &mut self,
2469 entry_to_move: ProjectEntryId,
2470 destination: ProjectEntryId,
2471 destination_is_file: bool,
2472 cx: &mut Context<Self>,
2473 ) {
2474 if self
2475 .project
2476 .read(cx)
2477 .entry_is_worktree_root(entry_to_move, cx)
2478 {
2479 self.move_worktree_root(entry_to_move, destination, cx)
2480 } else {
2481 self.move_worktree_entry(entry_to_move, destination, destination_is_file, cx)
2482 }
2483 }
2484
2485 fn move_worktree_root(
2486 &mut self,
2487 entry_to_move: ProjectEntryId,
2488 destination: ProjectEntryId,
2489 cx: &mut Context<Self>,
2490 ) {
2491 self.project.update(cx, |project, cx| {
2492 let Some(worktree_to_move) = project.worktree_for_entry(entry_to_move, cx) else {
2493 return;
2494 };
2495 let Some(destination_worktree) = project.worktree_for_entry(destination, cx) else {
2496 return;
2497 };
2498
2499 let worktree_id = worktree_to_move.read(cx).id();
2500 let destination_id = destination_worktree.read(cx).id();
2501
2502 project
2503 .move_worktree(worktree_id, destination_id, cx)
2504 .log_err();
2505 });
2506 }
2507
2508 fn move_worktree_entry(
2509 &mut self,
2510 entry_to_move: ProjectEntryId,
2511 destination: ProjectEntryId,
2512 destination_is_file: bool,
2513 cx: &mut Context<Self>,
2514 ) {
2515 if entry_to_move == destination {
2516 return;
2517 }
2518
2519 let destination_worktree = self.project.update(cx, |project, cx| {
2520 let entry_path = project.path_for_entry(entry_to_move, cx)?;
2521 let destination_entry_path = project.path_for_entry(destination, cx)?.path.clone();
2522
2523 let mut destination_path = destination_entry_path.as_ref();
2524 if destination_is_file {
2525 destination_path = destination_path.parent()?;
2526 }
2527
2528 let mut new_path = destination_path.to_path_buf();
2529 new_path.push(entry_path.path.file_name()?);
2530 if new_path != entry_path.path.as_ref() {
2531 let task = project.rename_entry(entry_to_move, new_path, cx);
2532 cx.foreground_executor().spawn(task).detach_and_log_err(cx);
2533 }
2534
2535 project.worktree_id_for_entry(destination, cx)
2536 });
2537
2538 if let Some(destination_worktree) = destination_worktree {
2539 self.expand_entry(destination_worktree, destination, cx);
2540 }
2541 }
2542
2543 fn index_for_selection(&self, selection: SelectedEntry) -> Option<(usize, usize, usize)> {
2544 let mut entry_index = 0;
2545 let mut visible_entries_index = 0;
2546 for (worktree_index, (worktree_id, worktree_entries, _)) in
2547 self.visible_entries.iter().enumerate()
2548 {
2549 if *worktree_id == selection.worktree_id {
2550 for entry in worktree_entries {
2551 if entry.id == selection.entry_id {
2552 return Some((worktree_index, entry_index, visible_entries_index));
2553 } else {
2554 visible_entries_index += 1;
2555 entry_index += 1;
2556 }
2557 }
2558 break;
2559 } else {
2560 visible_entries_index += worktree_entries.len();
2561 }
2562 }
2563 None
2564 }
2565
2566 fn disjoint_entries(&self, cx: &App) -> BTreeSet<SelectedEntry> {
2567 let marked_entries = self.effective_entries();
2568 let mut sanitized_entries = BTreeSet::new();
2569 if marked_entries.is_empty() {
2570 return sanitized_entries;
2571 }
2572
2573 let project = self.project.read(cx);
2574 let marked_entries_by_worktree: HashMap<WorktreeId, Vec<SelectedEntry>> = marked_entries
2575 .into_iter()
2576 .filter(|entry| !project.entry_is_worktree_root(entry.entry_id, cx))
2577 .fold(HashMap::default(), |mut map, entry| {
2578 map.entry(entry.worktree_id).or_default().push(entry);
2579 map
2580 });
2581
2582 for (worktree_id, marked_entries) in marked_entries_by_worktree {
2583 if let Some(worktree) = project.worktree_for_id(worktree_id, cx) {
2584 let worktree = worktree.read(cx);
2585 let marked_dir_paths = marked_entries
2586 .iter()
2587 .filter_map(|entry| {
2588 worktree.entry_for_id(entry.entry_id).and_then(|entry| {
2589 if entry.is_dir() {
2590 Some(entry.path.as_ref())
2591 } else {
2592 None
2593 }
2594 })
2595 })
2596 .collect::<BTreeSet<_>>();
2597
2598 sanitized_entries.extend(marked_entries.into_iter().filter(|entry| {
2599 let Some(entry_info) = worktree.entry_for_id(entry.entry_id) else {
2600 return false;
2601 };
2602 let entry_path = entry_info.path.as_ref();
2603 let inside_marked_dir = marked_dir_paths.iter().any(|&marked_dir_path| {
2604 entry_path != marked_dir_path && entry_path.starts_with(marked_dir_path)
2605 });
2606 !inside_marked_dir
2607 }));
2608 }
2609 }
2610
2611 sanitized_entries
2612 }
2613
2614 fn effective_entries(&self) -> BTreeSet<SelectedEntry> {
2615 if let Some(selection) = self.selection {
2616 let selection = SelectedEntry {
2617 entry_id: self.resolve_entry(selection.entry_id),
2618 worktree_id: selection.worktree_id,
2619 };
2620
2621 // Default to using just the selected item when nothing is marked.
2622 if self.marked_entries.is_empty() {
2623 return BTreeSet::from([selection]);
2624 }
2625
2626 // Allow operating on the selected item even when something else is marked,
2627 // making it easier to perform one-off actions without clearing a mark.
2628 if self.marked_entries.len() == 1 && !self.marked_entries.contains(&selection) {
2629 return BTreeSet::from([selection]);
2630 }
2631 }
2632
2633 // Return only marked entries since we've already handled special cases where
2634 // only selection should take precedence. At this point, marked entries may or
2635 // may not include the current selection, which is intentional.
2636 self.marked_entries
2637 .iter()
2638 .map(|entry| SelectedEntry {
2639 entry_id: self.resolve_entry(entry.entry_id),
2640 worktree_id: entry.worktree_id,
2641 })
2642 .collect::<BTreeSet<_>>()
2643 }
2644
2645 /// Finds the currently selected subentry for a given leaf entry id. If a given entry
2646 /// has no ancestors, the project entry ID that's passed in is returned as-is.
2647 fn resolve_entry(&self, id: ProjectEntryId) -> ProjectEntryId {
2648 self.ancestors
2649 .get(&id)
2650 .and_then(|ancestors| {
2651 if ancestors.current_ancestor_depth == 0 {
2652 return None;
2653 }
2654 ancestors.ancestors.get(ancestors.current_ancestor_depth)
2655 })
2656 .copied()
2657 .unwrap_or(id)
2658 }
2659
2660 pub fn selected_entry<'a>(&self, cx: &'a App) -> Option<(&'a Worktree, &'a project::Entry)> {
2661 let (worktree, entry) = self.selected_entry_handle(cx)?;
2662 Some((worktree.read(cx), entry))
2663 }
2664
2665 /// Compared to selected_entry, this function resolves to the currently
2666 /// selected subentry if dir auto-folding is enabled.
2667 fn selected_sub_entry<'a>(
2668 &self,
2669 cx: &'a App,
2670 ) -> Option<(Entity<Worktree>, &'a project::Entry)> {
2671 let (worktree, mut entry) = self.selected_entry_handle(cx)?;
2672
2673 let resolved_id = self.resolve_entry(entry.id);
2674 if resolved_id != entry.id {
2675 let worktree = worktree.read(cx);
2676 entry = worktree.entry_for_id(resolved_id)?;
2677 }
2678 Some((worktree, entry))
2679 }
2680 fn selected_entry_handle<'a>(
2681 &self,
2682 cx: &'a App,
2683 ) -> Option<(Entity<Worktree>, &'a project::Entry)> {
2684 let selection = self.selection?;
2685 let project = self.project.read(cx);
2686 let worktree = project.worktree_for_id(selection.worktree_id, cx)?;
2687 let entry = worktree.read(cx).entry_for_id(selection.entry_id)?;
2688 Some((worktree, entry))
2689 }
2690
2691 fn expand_to_selection(&mut self, cx: &mut Context<Self>) -> Option<()> {
2692 let (worktree, entry) = self.selected_entry(cx)?;
2693 let expanded_dir_ids = self.expanded_dir_ids.entry(worktree.id()).or_default();
2694
2695 for path in entry.path.ancestors() {
2696 let Some(entry) = worktree.entry_for_path(path) else {
2697 continue;
2698 };
2699 if entry.is_dir() {
2700 if let Err(idx) = expanded_dir_ids.binary_search(&entry.id) {
2701 expanded_dir_ids.insert(idx, entry.id);
2702 }
2703 }
2704 }
2705
2706 Some(())
2707 }
2708
2709 fn update_visible_entries(
2710 &mut self,
2711 new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
2712 cx: &mut Context<Self>,
2713 ) {
2714 let settings = ProjectPanelSettings::get_global(cx);
2715 let auto_collapse_dirs = settings.auto_fold_dirs;
2716 let hide_gitignore = settings.hide_gitignore;
2717 let project = self.project.read(cx);
2718 let repo_snapshots = project.git_store().read(cx).repo_snapshots(cx);
2719 self.last_worktree_root_id = project
2720 .visible_worktrees(cx)
2721 .next_back()
2722 .and_then(|worktree| worktree.read(cx).root_entry())
2723 .map(|entry| entry.id);
2724
2725 let old_ancestors = std::mem::take(&mut self.ancestors);
2726 self.visible_entries.clear();
2727 let mut max_width_item = None;
2728 for worktree in project.visible_worktrees(cx) {
2729 let worktree_snapshot = worktree.read(cx).snapshot();
2730 let worktree_id = worktree_snapshot.id();
2731
2732 let expanded_dir_ids = match self.expanded_dir_ids.entry(worktree_id) {
2733 hash_map::Entry::Occupied(e) => e.into_mut(),
2734 hash_map::Entry::Vacant(e) => {
2735 // The first time a worktree's root entry becomes available,
2736 // mark that root entry as expanded.
2737 if let Some(entry) = worktree_snapshot.root_entry() {
2738 e.insert(vec![entry.id]).as_slice()
2739 } else {
2740 &[]
2741 }
2742 }
2743 };
2744
2745 let mut new_entry_parent_id = None;
2746 let mut new_entry_kind = EntryKind::Dir;
2747 if let Some(edit_state) = &self.edit_state {
2748 if edit_state.worktree_id == worktree_id && edit_state.is_new_entry() {
2749 new_entry_parent_id = Some(edit_state.entry_id);
2750 new_entry_kind = if edit_state.is_dir {
2751 EntryKind::Dir
2752 } else {
2753 EntryKind::File
2754 };
2755 }
2756 }
2757
2758 let mut visible_worktree_entries = Vec::new();
2759 let mut entry_iter =
2760 GitTraversal::new(&repo_snapshots, worktree_snapshot.entries(true, 0));
2761 let mut auto_folded_ancestors = vec![];
2762 while let Some(entry) = entry_iter.entry() {
2763 if auto_collapse_dirs && entry.kind.is_dir() {
2764 auto_folded_ancestors.push(entry.id);
2765 if !self.unfolded_dir_ids.contains(&entry.id) {
2766 if let Some(root_path) = worktree_snapshot.root_entry() {
2767 let mut child_entries = worktree_snapshot.child_entries(&entry.path);
2768 if let Some(child) = child_entries.next() {
2769 if entry.path != root_path.path
2770 && child_entries.next().is_none()
2771 && child.kind.is_dir()
2772 {
2773 entry_iter.advance();
2774
2775 continue;
2776 }
2777 }
2778 }
2779 }
2780 let depth = old_ancestors
2781 .get(&entry.id)
2782 .map(|ancestor| ancestor.current_ancestor_depth)
2783 .unwrap_or_default()
2784 .min(auto_folded_ancestors.len());
2785 if let Some(edit_state) = &mut self.edit_state {
2786 if edit_state.entry_id == entry.id {
2787 edit_state.depth = depth;
2788 }
2789 }
2790 let mut ancestors = std::mem::take(&mut auto_folded_ancestors);
2791 if ancestors.len() > 1 {
2792 ancestors.reverse();
2793 self.ancestors.insert(
2794 entry.id,
2795 FoldedAncestors {
2796 current_ancestor_depth: depth,
2797 ancestors,
2798 },
2799 );
2800 }
2801 }
2802 auto_folded_ancestors.clear();
2803 if !hide_gitignore || !entry.is_ignored {
2804 visible_worktree_entries.push(entry.to_owned());
2805 }
2806 let precedes_new_entry = if let Some(new_entry_id) = new_entry_parent_id {
2807 entry.id == new_entry_id || {
2808 self.ancestors.get(&entry.id).map_or(false, |entries| {
2809 entries
2810 .ancestors
2811 .iter()
2812 .any(|entry_id| *entry_id == new_entry_id)
2813 })
2814 }
2815 } else {
2816 false
2817 };
2818 if precedes_new_entry && (!hide_gitignore || !entry.is_ignored) {
2819 visible_worktree_entries.push(GitEntry {
2820 entry: Entry {
2821 id: NEW_ENTRY_ID,
2822 kind: new_entry_kind,
2823 path: entry.path.join("\0").into(),
2824 inode: 0,
2825 mtime: entry.mtime,
2826 size: entry.size,
2827 is_ignored: entry.is_ignored,
2828 is_external: false,
2829 is_private: false,
2830 is_always_included: entry.is_always_included,
2831 canonical_path: entry.canonical_path.clone(),
2832 char_bag: entry.char_bag,
2833 is_fifo: entry.is_fifo,
2834 },
2835 git_summary: entry.git_summary,
2836 });
2837 }
2838 let worktree_abs_path = worktree.read(cx).abs_path();
2839 let (depth, path) = if Some(entry.entry) == worktree.read(cx).root_entry() {
2840 let Some(path_name) = worktree_abs_path
2841 .file_name()
2842 .with_context(|| {
2843 format!("Worktree abs path has no file name, root entry: {entry:?}")
2844 })
2845 .log_err()
2846 else {
2847 continue;
2848 };
2849 let path = ArcCow::Borrowed(Path::new(path_name));
2850 let depth = 0;
2851 (depth, path)
2852 } else if entry.is_file() {
2853 let Some(path_name) = entry
2854 .path
2855 .file_name()
2856 .with_context(|| format!("Non-root entry has no file name: {entry:?}"))
2857 .log_err()
2858 else {
2859 continue;
2860 };
2861 let path = ArcCow::Borrowed(Path::new(path_name));
2862 let depth = entry.path.ancestors().count() - 1;
2863 (depth, path)
2864 } else {
2865 let path = self
2866 .ancestors
2867 .get(&entry.id)
2868 .and_then(|ancestors| {
2869 let outermost_ancestor = ancestors.ancestors.last()?;
2870 let root_folded_entry = worktree
2871 .read(cx)
2872 .entry_for_id(*outermost_ancestor)?
2873 .path
2874 .as_ref();
2875 entry
2876 .path
2877 .strip_prefix(root_folded_entry)
2878 .ok()
2879 .and_then(|suffix| {
2880 let full_path = Path::new(root_folded_entry.file_name()?);
2881 Some(ArcCow::Owned(Arc::<Path>::from(full_path.join(suffix))))
2882 })
2883 })
2884 .or_else(|| entry.path.file_name().map(Path::new).map(ArcCow::Borrowed))
2885 .unwrap_or_else(|| ArcCow::Owned(entry.path.clone()));
2886 let depth = path.components().count();
2887 (depth, path)
2888 };
2889 let width_estimate = item_width_estimate(
2890 depth,
2891 path.to_string_lossy().chars().count(),
2892 entry.canonical_path.is_some(),
2893 );
2894
2895 match max_width_item.as_mut() {
2896 Some((id, worktree_id, width)) => {
2897 if *width < width_estimate {
2898 *id = entry.id;
2899 *worktree_id = worktree.read(cx).id();
2900 *width = width_estimate;
2901 }
2902 }
2903 None => {
2904 max_width_item = Some((entry.id, worktree.read(cx).id(), width_estimate))
2905 }
2906 }
2907
2908 if expanded_dir_ids.binary_search(&entry.id).is_err()
2909 && entry_iter.advance_to_sibling()
2910 {
2911 continue;
2912 }
2913 entry_iter.advance();
2914 }
2915
2916 project::sort_worktree_entries(&mut visible_worktree_entries);
2917
2918 self.visible_entries
2919 .push((worktree_id, visible_worktree_entries, OnceCell::new()));
2920 }
2921
2922 if let Some((project_entry_id, worktree_id, _)) = max_width_item {
2923 let mut visited_worktrees_length = 0;
2924 let index = self.visible_entries.iter().find_map(|(id, entries, _)| {
2925 if worktree_id == *id {
2926 entries
2927 .iter()
2928 .position(|entry| entry.id == project_entry_id)
2929 } else {
2930 visited_worktrees_length += entries.len();
2931 None
2932 }
2933 });
2934 if let Some(index) = index {
2935 self.max_width_item_index = Some(visited_worktrees_length + index);
2936 }
2937 }
2938 if let Some((worktree_id, entry_id)) = new_selected_entry {
2939 self.selection = Some(SelectedEntry {
2940 worktree_id,
2941 entry_id,
2942 });
2943 }
2944 }
2945
2946 fn expand_entry(
2947 &mut self,
2948 worktree_id: WorktreeId,
2949 entry_id: ProjectEntryId,
2950 cx: &mut Context<Self>,
2951 ) {
2952 self.project.update(cx, |project, cx| {
2953 if let Some((worktree, expanded_dir_ids)) = project
2954 .worktree_for_id(worktree_id, cx)
2955 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
2956 {
2957 project.expand_entry(worktree_id, entry_id, cx);
2958 let worktree = worktree.read(cx);
2959
2960 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
2961 loop {
2962 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
2963 expanded_dir_ids.insert(ix, entry.id);
2964 }
2965
2966 if let Some(parent_entry) =
2967 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
2968 {
2969 entry = parent_entry;
2970 } else {
2971 break;
2972 }
2973 }
2974 }
2975 }
2976 });
2977 }
2978
2979 fn drop_external_files(
2980 &mut self,
2981 paths: &[PathBuf],
2982 entry_id: ProjectEntryId,
2983 window: &mut Window,
2984 cx: &mut Context<Self>,
2985 ) {
2986 let mut paths: Vec<Arc<Path>> = paths.iter().map(|path| Arc::from(path.clone())).collect();
2987
2988 let open_file_after_drop = paths.len() == 1 && paths[0].is_file();
2989
2990 let Some((target_directory, worktree, fs)) = maybe!({
2991 let project = self.project.read(cx);
2992 let fs = project.fs().clone();
2993 let worktree = project.worktree_for_entry(entry_id, cx)?;
2994 let entry = worktree.read(cx).entry_for_id(entry_id)?;
2995 let path = entry.path.clone();
2996 let target_directory = if entry.is_dir() {
2997 path.to_path_buf()
2998 } else {
2999 path.parent()?.to_path_buf()
3000 };
3001 Some((target_directory, worktree, fs))
3002 }) else {
3003 return;
3004 };
3005
3006 let mut paths_to_replace = Vec::new();
3007 for path in &paths {
3008 if let Some(name) = path.file_name() {
3009 let mut target_path = target_directory.clone();
3010 target_path.push(name);
3011 if target_path.exists() {
3012 paths_to_replace.push((name.to_string_lossy().to_string(), path.clone()));
3013 }
3014 }
3015 }
3016
3017 cx.spawn_in(window, async move |this, cx| {
3018 async move {
3019 for (filename, original_path) in &paths_to_replace {
3020 let answer = cx.update(|window, cx| {
3021 window
3022 .prompt(
3023 PromptLevel::Info,
3024 format!("A file or folder with name {filename} already exists in the destination folder. Do you want to replace it?").as_str(),
3025 None,
3026 &["Replace", "Cancel"],
3027 cx,
3028 )
3029 })?.await?;
3030
3031 if answer == 1 {
3032 if let Some(item_idx) = paths.iter().position(|p| p == original_path) {
3033 paths.remove(item_idx);
3034 }
3035 }
3036 }
3037
3038 if paths.is_empty() {
3039 return Ok(());
3040 }
3041
3042 let task = worktree.update( cx, |worktree, cx| {
3043 worktree.copy_external_entries(target_directory.into(), paths, fs, cx)
3044 })?;
3045
3046 let opened_entries = task.await.with_context(|| "failed to copy external paths")?;
3047 this.update(cx, |this, cx| {
3048 if open_file_after_drop && !opened_entries.is_empty() {
3049 this.open_entry(opened_entries[0], true, false, cx);
3050 }
3051 })
3052 }
3053 .log_err().await
3054 })
3055 .detach();
3056 }
3057
3058 fn drag_onto(
3059 &mut self,
3060 selections: &DraggedSelection,
3061 target_entry_id: ProjectEntryId,
3062 is_file: bool,
3063 window: &mut Window,
3064 cx: &mut Context<Self>,
3065 ) {
3066 let should_copy = window.modifiers().alt;
3067 if should_copy {
3068 let _ = maybe!({
3069 let project = self.project.read(cx);
3070 let target_worktree = project.worktree_for_entry(target_entry_id, cx)?;
3071 let worktree_id = target_worktree.read(cx).id();
3072 let target_entry = target_worktree
3073 .read(cx)
3074 .entry_for_id(target_entry_id)?
3075 .clone();
3076
3077 let mut copy_tasks = Vec::new();
3078 let mut disambiguation_range = None;
3079 for selection in selections.items() {
3080 let (new_path, new_disambiguation_range) = self.create_paste_path(
3081 selection,
3082 (target_worktree.clone(), &target_entry),
3083 cx,
3084 )?;
3085
3086 let task = self.project.update(cx, |project, cx| {
3087 project.copy_entry(selection.entry_id, None, new_path, cx)
3088 });
3089 copy_tasks.push(task);
3090 disambiguation_range = new_disambiguation_range.or(disambiguation_range);
3091 }
3092
3093 let item_count = copy_tasks.len();
3094
3095 cx.spawn_in(window, async move |project_panel, cx| {
3096 let mut last_succeed = None;
3097 for task in copy_tasks.into_iter() {
3098 if let Some(Some(entry)) = task.await.log_err() {
3099 last_succeed = Some(entry.id);
3100 }
3101 }
3102 // update selection
3103 if let Some(entry_id) = last_succeed {
3104 project_panel
3105 .update_in(cx, |project_panel, window, cx| {
3106 project_panel.selection = Some(SelectedEntry {
3107 worktree_id,
3108 entry_id,
3109 });
3110
3111 // if only one entry was dragged and it was disambiguated, open the rename editor
3112 if item_count == 1 && disambiguation_range.is_some() {
3113 project_panel.rename_impl(disambiguation_range, window, cx);
3114 }
3115 })
3116 .ok();
3117 }
3118 })
3119 .detach();
3120 Some(())
3121 });
3122 } else {
3123 for selection in selections.items() {
3124 self.move_entry(selection.entry_id, target_entry_id, is_file, cx);
3125 }
3126 }
3127 }
3128
3129 fn index_for_entry(
3130 &self,
3131 entry_id: ProjectEntryId,
3132 worktree_id: WorktreeId,
3133 ) -> Option<(usize, usize, usize)> {
3134 let mut worktree_ix = 0;
3135 let mut total_ix = 0;
3136 for (current_worktree_id, visible_worktree_entries, _) in &self.visible_entries {
3137 if worktree_id != *current_worktree_id {
3138 total_ix += visible_worktree_entries.len();
3139 worktree_ix += 1;
3140 continue;
3141 }
3142
3143 return visible_worktree_entries
3144 .iter()
3145 .enumerate()
3146 .find(|(_, entry)| entry.id == entry_id)
3147 .map(|(ix, _)| (worktree_ix, ix, total_ix + ix));
3148 }
3149 None
3150 }
3151
3152 fn entry_at_index(&self, index: usize) -> Option<(WorktreeId, GitEntryRef)> {
3153 let mut offset = 0;
3154 for (worktree_id, visible_worktree_entries, _) in &self.visible_entries {
3155 if visible_worktree_entries.len() > offset + index {
3156 return visible_worktree_entries
3157 .get(index)
3158 .map(|entry| (*worktree_id, entry.to_ref()));
3159 }
3160 offset += visible_worktree_entries.len();
3161 }
3162 None
3163 }
3164
3165 fn iter_visible_entries(
3166 &self,
3167 range: Range<usize>,
3168 window: &mut Window,
3169 cx: &mut Context<ProjectPanel>,
3170 mut callback: impl FnMut(&Entry, &HashSet<Arc<Path>>, &mut Window, &mut Context<ProjectPanel>),
3171 ) {
3172 let mut ix = 0;
3173 for (_, visible_worktree_entries, entries_paths) in &self.visible_entries {
3174 if ix >= range.end {
3175 return;
3176 }
3177
3178 if ix + visible_worktree_entries.len() <= range.start {
3179 ix += visible_worktree_entries.len();
3180 continue;
3181 }
3182
3183 let end_ix = range.end.min(ix + visible_worktree_entries.len());
3184 let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
3185 let entries = entries_paths.get_or_init(|| {
3186 visible_worktree_entries
3187 .iter()
3188 .map(|e| (e.path.clone()))
3189 .collect()
3190 });
3191 for entry in visible_worktree_entries[entry_range].iter() {
3192 callback(&entry, entries, window, cx);
3193 }
3194 ix = end_ix;
3195 }
3196 }
3197
3198 fn for_each_visible_entry(
3199 &self,
3200 range: Range<usize>,
3201 window: &mut Window,
3202 cx: &mut Context<ProjectPanel>,
3203 mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut Window, &mut Context<ProjectPanel>),
3204 ) {
3205 let mut ix = 0;
3206 for (worktree_id, visible_worktree_entries, entries_paths) in &self.visible_entries {
3207 if ix >= range.end {
3208 return;
3209 }
3210
3211 if ix + visible_worktree_entries.len() <= range.start {
3212 ix += visible_worktree_entries.len();
3213 continue;
3214 }
3215
3216 let end_ix = range.end.min(ix + visible_worktree_entries.len());
3217 let (git_status_setting, show_file_icons, show_folder_icons) = {
3218 let settings = ProjectPanelSettings::get_global(cx);
3219 (
3220 settings.git_status,
3221 settings.file_icons,
3222 settings.folder_icons,
3223 )
3224 };
3225 if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
3226 let snapshot = worktree.read(cx).snapshot();
3227 let root_name = OsStr::new(snapshot.root_name());
3228 let expanded_entry_ids = self
3229 .expanded_dir_ids
3230 .get(&snapshot.id())
3231 .map(Vec::as_slice)
3232 .unwrap_or(&[]);
3233
3234 let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
3235 let entries = entries_paths.get_or_init(|| {
3236 visible_worktree_entries
3237 .iter()
3238 .map(|e| (e.path.clone()))
3239 .collect()
3240 });
3241 for entry in visible_worktree_entries[entry_range].iter() {
3242 let status = git_status_setting
3243 .then_some(entry.git_summary)
3244 .unwrap_or_default();
3245 let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok();
3246 let icon = match entry.kind {
3247 EntryKind::File => {
3248 if show_file_icons {
3249 FileIcons::get_icon(&entry.path, cx)
3250 } else {
3251 None
3252 }
3253 }
3254 _ => {
3255 if show_folder_icons {
3256 FileIcons::get_folder_icon(is_expanded, cx)
3257 } else {
3258 FileIcons::get_chevron_icon(is_expanded, cx)
3259 }
3260 }
3261 };
3262
3263 let (depth, difference) =
3264 ProjectPanel::calculate_depth_and_difference(&entry, entries);
3265
3266 let filename = match difference {
3267 diff if diff > 1 => entry
3268 .path
3269 .iter()
3270 .skip(entry.path.components().count() - diff)
3271 .collect::<PathBuf>()
3272 .to_str()
3273 .unwrap_or_default()
3274 .to_string(),
3275 _ => entry
3276 .path
3277 .file_name()
3278 .map(|name| name.to_string_lossy().into_owned())
3279 .unwrap_or_else(|| root_name.to_string_lossy().to_string()),
3280 };
3281 let selection = SelectedEntry {
3282 worktree_id: snapshot.id(),
3283 entry_id: entry.id,
3284 };
3285
3286 let is_marked = self.marked_entries.contains(&selection);
3287
3288 let diagnostic_severity = self
3289 .diagnostics
3290 .get(&(*worktree_id, entry.path.to_path_buf()))
3291 .cloned();
3292
3293 let filename_text_color =
3294 entry_git_aware_label_color(status, entry.is_ignored, is_marked);
3295
3296 let mut details = EntryDetails {
3297 filename,
3298 icon,
3299 path: entry.path.clone(),
3300 depth,
3301 kind: entry.kind,
3302 is_ignored: entry.is_ignored,
3303 is_expanded,
3304 is_selected: self.selection == Some(selection),
3305 is_marked,
3306 is_editing: false,
3307 is_processing: false,
3308 is_cut: self
3309 .clipboard
3310 .as_ref()
3311 .map_or(false, |e| e.is_cut() && e.items().contains(&selection)),
3312 filename_text_color,
3313 diagnostic_severity,
3314 git_status: status,
3315 is_private: entry.is_private,
3316 worktree_id: *worktree_id,
3317 canonical_path: entry.canonical_path.clone(),
3318 };
3319
3320 if let Some(edit_state) = &self.edit_state {
3321 let is_edited_entry = if edit_state.is_new_entry() {
3322 entry.id == NEW_ENTRY_ID
3323 } else {
3324 entry.id == edit_state.entry_id
3325 || self
3326 .ancestors
3327 .get(&entry.id)
3328 .is_some_and(|auto_folded_dirs| {
3329 auto_folded_dirs
3330 .ancestors
3331 .iter()
3332 .any(|entry_id| *entry_id == edit_state.entry_id)
3333 })
3334 };
3335
3336 if is_edited_entry {
3337 if let Some(processing_filename) = &edit_state.processing_filename {
3338 details.is_processing = true;
3339 if let Some(ancestors) = edit_state
3340 .leaf_entry_id
3341 .and_then(|entry| self.ancestors.get(&entry))
3342 {
3343 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;
3344 let all_components = ancestors.ancestors.len();
3345
3346 let prefix_components = all_components - position;
3347 let suffix_components = position.checked_sub(1);
3348 let mut previous_components =
3349 Path::new(&details.filename).components();
3350 let mut new_path = previous_components
3351 .by_ref()
3352 .take(prefix_components)
3353 .collect::<PathBuf>();
3354 if let Some(last_component) =
3355 Path::new(processing_filename).components().next_back()
3356 {
3357 new_path.push(last_component);
3358 previous_components.next();
3359 }
3360
3361 if let Some(_) = suffix_components {
3362 new_path.push(previous_components);
3363 }
3364 if let Some(str) = new_path.to_str() {
3365 details.filename.clear();
3366 details.filename.push_str(str);
3367 }
3368 } else {
3369 details.filename.clear();
3370 details.filename.push_str(processing_filename);
3371 }
3372 } else {
3373 if edit_state.is_new_entry() {
3374 details.filename.clear();
3375 }
3376 details.is_editing = true;
3377 }
3378 }
3379 }
3380
3381 callback(entry.id, details, window, cx);
3382 }
3383 }
3384 ix = end_ix;
3385 }
3386 }
3387
3388 fn find_entry_in_worktree(
3389 &self,
3390 worktree_id: WorktreeId,
3391 reverse_search: bool,
3392 only_visible_entries: bool,
3393 predicate: impl Fn(GitEntryRef, WorktreeId) -> bool,
3394 cx: &mut Context<Self>,
3395 ) -> Option<GitEntry> {
3396 if only_visible_entries {
3397 let entries = self
3398 .visible_entries
3399 .iter()
3400 .find_map(|(tree_id, entries, _)| {
3401 if worktree_id == *tree_id {
3402 Some(entries)
3403 } else {
3404 None
3405 }
3406 })?
3407 .clone();
3408
3409 return utils::ReversibleIterable::new(entries.iter(), reverse_search)
3410 .find(|ele| predicate(ele.to_ref(), worktree_id))
3411 .cloned();
3412 }
3413
3414 let repo_snapshots = self
3415 .project
3416 .read(cx)
3417 .git_store()
3418 .read(cx)
3419 .repo_snapshots(cx);
3420 let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
3421 worktree.update(cx, |tree, _| {
3422 utils::ReversibleIterable::new(
3423 GitTraversal::new(&repo_snapshots, tree.entries(true, 0usize)),
3424 reverse_search,
3425 )
3426 .find_single_ended(|ele| predicate(*ele, worktree_id))
3427 .map(|ele| ele.to_owned())
3428 })
3429 }
3430
3431 fn find_entry(
3432 &self,
3433 start: Option<&SelectedEntry>,
3434 reverse_search: bool,
3435 predicate: impl Fn(GitEntryRef, WorktreeId) -> bool,
3436 cx: &mut Context<Self>,
3437 ) -> Option<SelectedEntry> {
3438 let mut worktree_ids: Vec<_> = self
3439 .visible_entries
3440 .iter()
3441 .map(|(worktree_id, _, _)| *worktree_id)
3442 .collect();
3443 let repo_snapshots = self
3444 .project
3445 .read(cx)
3446 .git_store()
3447 .read(cx)
3448 .repo_snapshots(cx);
3449
3450 let mut last_found: Option<SelectedEntry> = None;
3451
3452 if let Some(start) = start {
3453 let worktree = self
3454 .project
3455 .read(cx)
3456 .worktree_for_id(start.worktree_id, cx)?;
3457
3458 let search = worktree.update(cx, |tree, _| {
3459 let entry = tree.entry_for_id(start.entry_id)?;
3460 let root_entry = tree.root_entry()?;
3461 let tree_id = tree.id();
3462
3463 let mut first_iter = GitTraversal::new(
3464 &repo_snapshots,
3465 tree.traverse_from_path(true, true, true, entry.path.as_ref()),
3466 );
3467
3468 if reverse_search {
3469 first_iter.next();
3470 }
3471
3472 let first = first_iter
3473 .enumerate()
3474 .take_until(|(count, entry)| entry.entry == root_entry && *count != 0usize)
3475 .map(|(_, entry)| entry)
3476 .find(|ele| predicate(*ele, tree_id))
3477 .map(|ele| ele.to_owned());
3478
3479 let second_iter = GitTraversal::new(&repo_snapshots, tree.entries(true, 0usize));
3480
3481 let second = if reverse_search {
3482 second_iter
3483 .take_until(|ele| ele.id == start.entry_id)
3484 .filter(|ele| predicate(*ele, tree_id))
3485 .last()
3486 .map(|ele| ele.to_owned())
3487 } else {
3488 second_iter
3489 .take_while(|ele| ele.id != start.entry_id)
3490 .filter(|ele| predicate(*ele, tree_id))
3491 .last()
3492 .map(|ele| ele.to_owned())
3493 };
3494
3495 if reverse_search {
3496 Some((second, first))
3497 } else {
3498 Some((first, second))
3499 }
3500 });
3501
3502 if let Some((first, second)) = search {
3503 let first = first.map(|entry| SelectedEntry {
3504 worktree_id: start.worktree_id,
3505 entry_id: entry.id,
3506 });
3507
3508 let second = second.map(|entry| SelectedEntry {
3509 worktree_id: start.worktree_id,
3510 entry_id: entry.id,
3511 });
3512
3513 if first.is_some() {
3514 return first;
3515 }
3516 last_found = second;
3517
3518 let idx = worktree_ids
3519 .iter()
3520 .enumerate()
3521 .find(|(_, ele)| **ele == start.worktree_id)
3522 .map(|(idx, _)| idx);
3523
3524 if let Some(idx) = idx {
3525 worktree_ids.rotate_left(idx + 1usize);
3526 worktree_ids.pop();
3527 }
3528 }
3529 }
3530
3531 for tree_id in worktree_ids.into_iter() {
3532 if let Some(found) =
3533 self.find_entry_in_worktree(tree_id, reverse_search, false, &predicate, cx)
3534 {
3535 return Some(SelectedEntry {
3536 worktree_id: tree_id,
3537 entry_id: found.id,
3538 });
3539 }
3540 }
3541
3542 last_found
3543 }
3544
3545 fn find_visible_entry(
3546 &self,
3547 start: Option<&SelectedEntry>,
3548 reverse_search: bool,
3549 predicate: impl Fn(GitEntryRef, WorktreeId) -> bool,
3550 cx: &mut Context<Self>,
3551 ) -> Option<SelectedEntry> {
3552 let mut worktree_ids: Vec<_> = self
3553 .visible_entries
3554 .iter()
3555 .map(|(worktree_id, _, _)| *worktree_id)
3556 .collect();
3557
3558 let mut last_found: Option<SelectedEntry> = None;
3559
3560 if let Some(start) = start {
3561 let entries = self
3562 .visible_entries
3563 .iter()
3564 .find(|(worktree_id, _, _)| *worktree_id == start.worktree_id)
3565 .map(|(_, entries, _)| entries)?;
3566
3567 let mut start_idx = entries
3568 .iter()
3569 .enumerate()
3570 .find(|(_, ele)| ele.id == start.entry_id)
3571 .map(|(idx, _)| idx)?;
3572
3573 if reverse_search {
3574 start_idx = start_idx.saturating_add(1usize);
3575 }
3576
3577 let (left, right) = entries.split_at_checked(start_idx)?;
3578
3579 let (first_iter, second_iter) = if reverse_search {
3580 (
3581 utils::ReversibleIterable::new(left.iter(), reverse_search),
3582 utils::ReversibleIterable::new(right.iter(), reverse_search),
3583 )
3584 } else {
3585 (
3586 utils::ReversibleIterable::new(right.iter(), reverse_search),
3587 utils::ReversibleIterable::new(left.iter(), reverse_search),
3588 )
3589 };
3590
3591 let first_search = first_iter.find(|ele| predicate(ele.to_ref(), start.worktree_id));
3592 let second_search = second_iter.find(|ele| predicate(ele.to_ref(), start.worktree_id));
3593
3594 if first_search.is_some() {
3595 return first_search.map(|entry| SelectedEntry {
3596 worktree_id: start.worktree_id,
3597 entry_id: entry.id,
3598 });
3599 }
3600
3601 last_found = second_search.map(|entry| SelectedEntry {
3602 worktree_id: start.worktree_id,
3603 entry_id: entry.id,
3604 });
3605
3606 let idx = worktree_ids
3607 .iter()
3608 .enumerate()
3609 .find(|(_, ele)| **ele == start.worktree_id)
3610 .map(|(idx, _)| idx);
3611
3612 if let Some(idx) = idx {
3613 worktree_ids.rotate_left(idx + 1usize);
3614 worktree_ids.pop();
3615 }
3616 }
3617
3618 for tree_id in worktree_ids.into_iter() {
3619 if let Some(found) =
3620 self.find_entry_in_worktree(tree_id, reverse_search, true, &predicate, cx)
3621 {
3622 return Some(SelectedEntry {
3623 worktree_id: tree_id,
3624 entry_id: found.id,
3625 });
3626 }
3627 }
3628
3629 last_found
3630 }
3631
3632 fn calculate_depth_and_difference(
3633 entry: &Entry,
3634 visible_worktree_entries: &HashSet<Arc<Path>>,
3635 ) -> (usize, usize) {
3636 let (depth, difference) = entry
3637 .path
3638 .ancestors()
3639 .skip(1) // Skip the entry itself
3640 .find_map(|ancestor| {
3641 if let Some(parent_entry) = visible_worktree_entries.get(ancestor) {
3642 let entry_path_components_count = entry.path.components().count();
3643 let parent_path_components_count = parent_entry.components().count();
3644 let difference = entry_path_components_count - parent_path_components_count;
3645 let depth = parent_entry
3646 .ancestors()
3647 .skip(1)
3648 .filter(|ancestor| visible_worktree_entries.contains(*ancestor))
3649 .count();
3650 Some((depth + 1, difference))
3651 } else {
3652 None
3653 }
3654 })
3655 .unwrap_or((0, 0));
3656
3657 (depth, difference)
3658 }
3659
3660 fn render_entry(
3661 &self,
3662 entry_id: ProjectEntryId,
3663 details: EntryDetails,
3664 window: &mut Window,
3665 cx: &mut Context<Self>,
3666 ) -> Stateful<Div> {
3667 const GROUP_NAME: &str = "project_entry";
3668
3669 let kind = details.kind;
3670 let settings = ProjectPanelSettings::get_global(cx);
3671 let show_editor = details.is_editing && !details.is_processing;
3672
3673 let selection = SelectedEntry {
3674 worktree_id: details.worktree_id,
3675 entry_id,
3676 };
3677
3678 let is_marked = self.marked_entries.contains(&selection);
3679 let is_active = self
3680 .selection
3681 .map_or(false, |selection| selection.entry_id == entry_id);
3682
3683 let file_name = details.filename.clone();
3684
3685 let mut icon = details.icon.clone();
3686 if settings.file_icons && show_editor && details.kind.is_file() {
3687 let filename = self.filename_editor.read(cx).text(cx);
3688 if filename.len() > 2 {
3689 icon = FileIcons::get_icon(Path::new(&filename), cx);
3690 }
3691 }
3692
3693 let filename_text_color = details.filename_text_color;
3694 let diagnostic_severity = details.diagnostic_severity;
3695 let item_colors = get_item_color(cx);
3696
3697 let canonical_path = details
3698 .canonical_path
3699 .as_ref()
3700 .map(|f| f.to_string_lossy().to_string());
3701 let path = details.path.clone();
3702
3703 let depth = details.depth;
3704 let worktree_id = details.worktree_id;
3705 let selections = Arc::new(self.marked_entries.clone());
3706
3707 let dragged_selection = DraggedSelection {
3708 active_selection: selection,
3709 marked_selections: selections,
3710 };
3711
3712 let bg_color = if is_marked {
3713 item_colors.marked
3714 } else {
3715 item_colors.default
3716 };
3717
3718 let bg_hover_color = if is_marked {
3719 item_colors.marked
3720 } else {
3721 item_colors.hover
3722 };
3723
3724 let validation_color_and_message = if show_editor {
3725 match self
3726 .edit_state
3727 .as_ref()
3728 .map_or(ValidationState::None, |e| e.validation_state.clone())
3729 {
3730 ValidationState::Error(msg) => Some((Color::Error.color(cx), msg.clone())),
3731 ValidationState::Warning(msg) => Some((Color::Warning.color(cx), msg.clone())),
3732 ValidationState::None => None,
3733 }
3734 } else {
3735 None
3736 };
3737
3738 let border_color =
3739 if !self.mouse_down && is_active && self.focus_handle.contains_focused(window, cx) {
3740 match validation_color_and_message {
3741 Some((color, _)) => color,
3742 None => item_colors.focused,
3743 }
3744 } else {
3745 bg_color
3746 };
3747
3748 let border_hover_color =
3749 if !self.mouse_down && is_active && self.focus_handle.contains_focused(window, cx) {
3750 match validation_color_and_message {
3751 Some((color, _)) => color,
3752 None => item_colors.focused,
3753 }
3754 } else {
3755 bg_hover_color
3756 };
3757
3758 let folded_directory_drag_target = self.folded_directory_drag_target;
3759
3760 div()
3761 .id(entry_id.to_proto() as usize)
3762 .group(GROUP_NAME)
3763 .cursor_pointer()
3764 .rounded_none()
3765 .bg(bg_color)
3766 .border_1()
3767 .border_r_2()
3768 .border_color(border_color)
3769 .hover(|style| style.bg(bg_hover_color).border_color(border_hover_color))
3770 .on_drag_move::<ExternalPaths>(cx.listener(
3771 move |this, event: &DragMoveEvent<ExternalPaths>, _, cx| {
3772 if event.bounds.contains(&event.event.position) {
3773 if this.last_external_paths_drag_over_entry == Some(entry_id) {
3774 return;
3775 }
3776 this.last_external_paths_drag_over_entry = Some(entry_id);
3777 this.marked_entries.clear();
3778
3779 let Some((worktree, path, entry)) = maybe!({
3780 let worktree = this
3781 .project
3782 .read(cx)
3783 .worktree_for_id(selection.worktree_id, cx)?;
3784 let worktree = worktree.read(cx);
3785 let entry = worktree.entry_for_path(&path)?;
3786 let path = if entry.is_dir() {
3787 path.as_ref()
3788 } else {
3789 path.parent()?
3790 };
3791 Some((worktree, path, entry))
3792 }) else {
3793 return;
3794 };
3795
3796 this.marked_entries.insert(SelectedEntry {
3797 entry_id: entry.id,
3798 worktree_id: worktree.id(),
3799 });
3800
3801 for entry in worktree.child_entries(path) {
3802 this.marked_entries.insert(SelectedEntry {
3803 entry_id: entry.id,
3804 worktree_id: worktree.id(),
3805 });
3806 }
3807
3808 cx.notify();
3809 }
3810 },
3811 ))
3812 .on_drop(cx.listener(
3813 move |this, external_paths: &ExternalPaths, window, cx| {
3814 this.hover_scroll_task.take();
3815 this.last_external_paths_drag_over_entry = None;
3816 this.marked_entries.clear();
3817 this.drop_external_files(external_paths.paths(), entry_id, window, cx);
3818 cx.stop_propagation();
3819 },
3820 ))
3821 .on_drag_move::<DraggedSelection>(cx.listener(
3822 move |this, event: &DragMoveEvent<DraggedSelection>, window, cx| {
3823 if event.bounds.contains(&event.event.position) {
3824 if this.last_selection_drag_over_entry == Some(entry_id) {
3825 return;
3826 }
3827 this.last_selection_drag_over_entry = Some(entry_id);
3828 this.hover_expand_task.take();
3829
3830 if !kind.is_dir()
3831 || this
3832 .expanded_dir_ids
3833 .get(&details.worktree_id)
3834 .map_or(false, |ids| ids.binary_search(&entry_id).is_ok())
3835 {
3836 return;
3837 }
3838
3839 let bounds = event.bounds;
3840 this.hover_expand_task =
3841 Some(cx.spawn_in(window, async move |this, cx| {
3842 cx.background_executor()
3843 .timer(Duration::from_millis(500))
3844 .await;
3845 this.update_in(cx, |this, window, cx| {
3846 this.hover_expand_task.take();
3847 if this.last_selection_drag_over_entry == Some(entry_id)
3848 && bounds.contains(&window.mouse_position())
3849 {
3850 this.expand_entry(worktree_id, entry_id, cx);
3851 this.update_visible_entries(
3852 Some((worktree_id, entry_id)),
3853 cx,
3854 );
3855 cx.notify();
3856 }
3857 })
3858 .ok();
3859 }));
3860 }
3861 },
3862 ))
3863 .on_drag(
3864 dragged_selection,
3865 move |selection, click_offset, _window, cx| {
3866 cx.new(|_| DraggedProjectEntryView {
3867 details: details.clone(),
3868 click_offset,
3869 selection: selection.active_selection,
3870 selections: selection.marked_selections.clone(),
3871 })
3872 },
3873 )
3874 .drag_over::<DraggedSelection>(move |style, _, _, _| {
3875 if folded_directory_drag_target.is_some() {
3876 return style;
3877 }
3878 style.bg(item_colors.drag_over)
3879 })
3880 .on_drop(
3881 cx.listener(move |this, selections: &DraggedSelection, window, cx| {
3882 this.hover_scroll_task.take();
3883 this.hover_expand_task.take();
3884 if folded_directory_drag_target.is_some() {
3885 return;
3886 }
3887 this.drag_onto(selections, entry_id, kind.is_file(), window, cx);
3888 }),
3889 )
3890 .on_mouse_down(
3891 MouseButton::Left,
3892 cx.listener(move |this, _, _, cx| {
3893 this.mouse_down = true;
3894 cx.propagate();
3895 }),
3896 )
3897 .on_click(
3898 cx.listener(move |this, event: &gpui::ClickEvent, window, cx| {
3899 if event.down.button == MouseButton::Right
3900 || event.down.first_mouse
3901 || show_editor
3902 {
3903 return;
3904 }
3905 if event.down.button == MouseButton::Left {
3906 this.mouse_down = false;
3907 }
3908 cx.stop_propagation();
3909
3910 if let Some(selection) = this.selection.filter(|_| event.modifiers().shift) {
3911 let current_selection = this.index_for_selection(selection);
3912 let clicked_entry = SelectedEntry {
3913 entry_id,
3914 worktree_id,
3915 };
3916 let target_selection = this.index_for_selection(clicked_entry);
3917 if let Some(((_, _, source_index), (_, _, target_index))) =
3918 current_selection.zip(target_selection)
3919 {
3920 let range_start = source_index.min(target_index);
3921 let range_end = source_index.max(target_index) + 1; // Make the range inclusive.
3922 let mut new_selections = BTreeSet::new();
3923 this.for_each_visible_entry(
3924 range_start..range_end,
3925 window,
3926 cx,
3927 |entry_id, details, _, _| {
3928 new_selections.insert(SelectedEntry {
3929 entry_id,
3930 worktree_id: details.worktree_id,
3931 });
3932 },
3933 );
3934
3935 this.marked_entries = this
3936 .marked_entries
3937 .union(&new_selections)
3938 .cloned()
3939 .collect();
3940
3941 this.selection = Some(clicked_entry);
3942 this.marked_entries.insert(clicked_entry);
3943 }
3944 } else if event.modifiers().secondary() {
3945 if event.down.click_count > 1 {
3946 this.split_entry(entry_id, cx);
3947 } else {
3948 this.selection = Some(selection);
3949 if !this.marked_entries.insert(selection) {
3950 this.marked_entries.remove(&selection);
3951 }
3952 }
3953 } else if kind.is_dir() {
3954 this.marked_entries.clear();
3955 if event.modifiers().alt {
3956 this.toggle_expand_all(entry_id, window, cx);
3957 } else {
3958 this.toggle_expanded(entry_id, window, cx);
3959 }
3960 } else {
3961 let preview_tabs_enabled = PreviewTabsSettings::get_global(cx).enabled;
3962 let click_count = event.up.click_count;
3963 let focus_opened_item = !preview_tabs_enabled || click_count > 1;
3964 let allow_preview = preview_tabs_enabled && click_count == 1;
3965 this.open_entry(entry_id, focus_opened_item, allow_preview, cx);
3966 }
3967 }),
3968 )
3969 .child(
3970 ListItem::new(entry_id.to_proto() as usize)
3971 .indent_level(depth)
3972 .indent_step_size(px(settings.indent_size))
3973 .spacing(match settings.entry_spacing {
3974 project_panel_settings::EntrySpacing::Comfortable => ListItemSpacing::Dense,
3975 project_panel_settings::EntrySpacing::Standard => {
3976 ListItemSpacing::ExtraDense
3977 }
3978 })
3979 .selectable(false)
3980 .when_some(canonical_path, |this, path| {
3981 this.end_slot::<AnyElement>(
3982 div()
3983 .id("symlink_icon")
3984 .pr_3()
3985 .tooltip(move |window, cx| {
3986 Tooltip::with_meta(
3987 path.to_string(),
3988 None,
3989 "Symbolic Link",
3990 window,
3991 cx,
3992 )
3993 })
3994 .child(
3995 Icon::new(IconName::ArrowUpRight)
3996 .size(IconSize::Indicator)
3997 .color(filename_text_color),
3998 )
3999 .into_any_element(),
4000 )
4001 })
4002 .child(if let Some(icon) = &icon {
4003 if let Some((_, decoration_color)) =
4004 entry_diagnostic_aware_icon_decoration_and_color(diagnostic_severity)
4005 {
4006 let is_warning = diagnostic_severity
4007 .map(|severity| matches!(severity, DiagnosticSeverity::WARNING))
4008 .unwrap_or(false);
4009 div().child(
4010 DecoratedIcon::new(
4011 Icon::from_path(icon.clone()).color(Color::Muted),
4012 Some(
4013 IconDecoration::new(
4014 if kind.is_file() {
4015 if is_warning {
4016 IconDecorationKind::Triangle
4017 } else {
4018 IconDecorationKind::X
4019 }
4020 } else {
4021 IconDecorationKind::Dot
4022 },
4023 bg_color,
4024 cx,
4025 )
4026 .group_name(Some(GROUP_NAME.into()))
4027 .knockout_hover_color(bg_hover_color)
4028 .color(decoration_color.color(cx))
4029 .position(Point {
4030 x: px(-2.),
4031 y: px(-2.),
4032 }),
4033 ),
4034 )
4035 .into_any_element(),
4036 )
4037 } else {
4038 h_flex().child(Icon::from_path(icon.to_string()).color(Color::Muted))
4039 }
4040 } else {
4041 if let Some((icon_name, color)) =
4042 entry_diagnostic_aware_icon_name_and_color(diagnostic_severity)
4043 {
4044 h_flex()
4045 .size(IconSize::default().rems())
4046 .child(Icon::new(icon_name).color(color).size(IconSize::Small))
4047 } else {
4048 h_flex()
4049 .size(IconSize::default().rems())
4050 .invisible()
4051 .flex_none()
4052 }
4053 })
4054 .child(
4055 if let (Some(editor), true) = (Some(&self.filename_editor), show_editor) {
4056 h_flex().h_6().w_full().child(editor.clone())
4057 } else {
4058 h_flex().h_6().map(|mut this| {
4059 if let Some(folded_ancestors) = self.ancestors.get(&entry_id) {
4060 let components = Path::new(&file_name)
4061 .components()
4062 .map(|comp| {
4063 let comp_str =
4064 comp.as_os_str().to_string_lossy().into_owned();
4065 comp_str
4066 })
4067 .collect::<Vec<_>>();
4068
4069 let components_len = components.len();
4070 let active_index = components_len
4071 - 1
4072 - folded_ancestors.current_ancestor_depth;
4073 const DELIMITER: SharedString =
4074 SharedString::new_static(std::path::MAIN_SEPARATOR_STR);
4075 for (index, component) in components.into_iter().enumerate() {
4076 if index != 0 {
4077 let delimiter_target_index = index - 1;
4078 let target_entry_id = folded_ancestors.ancestors.get(components_len - 1 - delimiter_target_index).cloned();
4079 this = this.child(
4080 div()
4081 .on_drop(cx.listener(move |this, selections: &DraggedSelection, window, cx| {
4082 this.hover_scroll_task.take();
4083 this.folded_directory_drag_target = None;
4084 if let Some(target_entry_id) = target_entry_id {
4085 this.drag_onto(selections, target_entry_id, kind.is_file(), window, cx);
4086 }
4087 }))
4088 .on_drag_move(cx.listener(
4089 move |this, event: &DragMoveEvent<DraggedSelection>, _, _| {
4090 if event.bounds.contains(&event.event.position) {
4091 this.folded_directory_drag_target = Some(
4092 FoldedDirectoryDragTarget {
4093 entry_id,
4094 index: delimiter_target_index,
4095 is_delimiter_target: true,
4096 }
4097 );
4098 } else {
4099 let is_current_target = this.folded_directory_drag_target
4100 .map_or(false, |target|
4101 target.entry_id == entry_id &&
4102 target.index == delimiter_target_index &&
4103 target.is_delimiter_target
4104 );
4105 if is_current_target {
4106 this.folded_directory_drag_target = None;
4107 }
4108 }
4109
4110 },
4111 ))
4112 .child(
4113 Label::new(DELIMITER.clone())
4114 .single_line()
4115 .color(filename_text_color)
4116 )
4117 );
4118 }
4119 let id = SharedString::from(format!(
4120 "project_panel_path_component_{}_{index}",
4121 entry_id.to_usize()
4122 ));
4123 let label = div()
4124 .id(id)
4125 .on_click(cx.listener(move |this, _, _, cx| {
4126 if index != active_index {
4127 if let Some(folds) =
4128 this.ancestors.get_mut(&entry_id)
4129 {
4130 folds.current_ancestor_depth =
4131 components_len - 1 - index;
4132 cx.notify();
4133 }
4134 }
4135 }))
4136 .when(index != components_len - 1, |div|{
4137 let target_entry_id = folded_ancestors.ancestors.get(components_len - 1 - index).cloned();
4138 div
4139 .on_drag_move(cx.listener(
4140 move |this, event: &DragMoveEvent<DraggedSelection>, _, _| {
4141 if event.bounds.contains(&event.event.position) {
4142 this.folded_directory_drag_target = Some(
4143 FoldedDirectoryDragTarget {
4144 entry_id,
4145 index,
4146 is_delimiter_target: false,
4147 }
4148 );
4149 } else {
4150 let is_current_target = this.folded_directory_drag_target
4151 .as_ref()
4152 .map_or(false, |target|
4153 target.entry_id == entry_id &&
4154 target.index == index &&
4155 !target.is_delimiter_target
4156 );
4157 if is_current_target {
4158 this.folded_directory_drag_target = None;
4159 }
4160 }
4161 },
4162 ))
4163 .on_drop(cx.listener(move |this, selections: &DraggedSelection, window,cx| {
4164 this.hover_scroll_task.take();
4165 this.folded_directory_drag_target = None;
4166 if let Some(target_entry_id) = target_entry_id {
4167 this.drag_onto(selections, target_entry_id, kind.is_file(), window, cx);
4168 }
4169 }))
4170 .when(folded_directory_drag_target.map_or(false, |target|
4171 target.entry_id == entry_id &&
4172 target.index == index
4173 ), |this| {
4174 this.bg(item_colors.drag_over)
4175 })
4176 })
4177 .child(
4178 Label::new(component)
4179 .single_line()
4180 .color(filename_text_color)
4181 .when(
4182 index == active_index
4183 && (is_active || is_marked),
4184 |this| this.underline(),
4185 ),
4186 );
4187
4188 this = this.child(label);
4189 }
4190
4191 this
4192 } else {
4193 this.child(
4194 Label::new(file_name)
4195 .single_line()
4196 .color(filename_text_color),
4197 )
4198 }
4199 })
4200 }
4201 .ml_1(),
4202 )
4203 .on_secondary_mouse_down(cx.listener(
4204 move |this, event: &MouseDownEvent, window, cx| {
4205 // Stop propagation to prevent the catch-all context menu for the project
4206 // panel from being deployed.
4207 cx.stop_propagation();
4208 // Some context menu actions apply to all marked entries. If the user
4209 // right-clicks on an entry that is not marked, they may not realize the
4210 // action applies to multiple entries. To avoid inadvertent changes, all
4211 // entries are unmarked.
4212 if !this.marked_entries.contains(&selection) {
4213 this.marked_entries.clear();
4214 }
4215 this.deploy_context_menu(event.position, entry_id, window, cx);
4216 },
4217 ))
4218 .overflow_x(),
4219 )
4220 .when_some(
4221 validation_color_and_message,
4222 |this, (color, message)| {
4223 this
4224 .relative()
4225 .child(
4226 deferred(
4227 div()
4228 .occlude()
4229 .absolute()
4230 .top_full()
4231 .left(px(-1.)) // Used px over rem so that it doesn't change with font size
4232 .right(px(-0.5))
4233 .py_1()
4234 .px_2()
4235 .border_1()
4236 .border_color(color)
4237 .bg(cx.theme().colors().background)
4238 .child(
4239 Label::new(message)
4240 .color(Color::from(color))
4241 .size(LabelSize::Small)
4242 )
4243 )
4244 )
4245 }
4246 )
4247 }
4248
4249 fn render_vertical_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
4250 if !Self::should_show_scrollbar(cx)
4251 || !(self.show_scrollbar || self.vertical_scrollbar_state.is_dragging())
4252 {
4253 return None;
4254 }
4255 Some(
4256 div()
4257 .occlude()
4258 .id("project-panel-vertical-scroll")
4259 .on_mouse_move(cx.listener(|_, _, _, cx| {
4260 cx.notify();
4261 cx.stop_propagation()
4262 }))
4263 .on_hover(|_, _, cx| {
4264 cx.stop_propagation();
4265 })
4266 .on_any_mouse_down(|_, _, cx| {
4267 cx.stop_propagation();
4268 })
4269 .on_mouse_up(
4270 MouseButton::Left,
4271 cx.listener(|this, _, window, cx| {
4272 if !this.vertical_scrollbar_state.is_dragging()
4273 && !this.focus_handle.contains_focused(window, cx)
4274 {
4275 this.hide_scrollbar(window, cx);
4276 cx.notify();
4277 }
4278
4279 cx.stop_propagation();
4280 }),
4281 )
4282 .on_scroll_wheel(cx.listener(|_, _, _, cx| {
4283 cx.notify();
4284 }))
4285 .h_full()
4286 .absolute()
4287 .right_1()
4288 .top_1()
4289 .bottom_1()
4290 .w(px(12.))
4291 .cursor_default()
4292 .children(Scrollbar::vertical(
4293 // percentage as f32..end_offset as f32,
4294 self.vertical_scrollbar_state.clone(),
4295 )),
4296 )
4297 }
4298
4299 fn render_horizontal_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
4300 if !Self::should_show_scrollbar(cx)
4301 || !(self.show_scrollbar || self.horizontal_scrollbar_state.is_dragging())
4302 {
4303 return None;
4304 }
4305
4306 let scroll_handle = self.scroll_handle.0.borrow();
4307 let longest_item_width = scroll_handle
4308 .last_item_size
4309 .filter(|size| size.contents.width > size.item.width)?
4310 .contents
4311 .width
4312 .0 as f64;
4313 if longest_item_width < scroll_handle.base_handle.bounds().size.width.0 as f64 {
4314 return None;
4315 }
4316
4317 Some(
4318 div()
4319 .occlude()
4320 .id("project-panel-horizontal-scroll")
4321 .on_mouse_move(cx.listener(|_, _, _, cx| {
4322 cx.notify();
4323 cx.stop_propagation()
4324 }))
4325 .on_hover(|_, _, cx| {
4326 cx.stop_propagation();
4327 })
4328 .on_any_mouse_down(|_, _, cx| {
4329 cx.stop_propagation();
4330 })
4331 .on_mouse_up(
4332 MouseButton::Left,
4333 cx.listener(|this, _, window, cx| {
4334 if !this.horizontal_scrollbar_state.is_dragging()
4335 && !this.focus_handle.contains_focused(window, cx)
4336 {
4337 this.hide_scrollbar(window, cx);
4338 cx.notify();
4339 }
4340
4341 cx.stop_propagation();
4342 }),
4343 )
4344 .on_scroll_wheel(cx.listener(|_, _, _, cx| {
4345 cx.notify();
4346 }))
4347 .w_full()
4348 .absolute()
4349 .right_1()
4350 .left_1()
4351 .bottom_1()
4352 .h(px(12.))
4353 .cursor_default()
4354 .when(self.width.is_some(), |this| {
4355 this.children(Scrollbar::horizontal(
4356 self.horizontal_scrollbar_state.clone(),
4357 ))
4358 }),
4359 )
4360 }
4361
4362 fn dispatch_context(&self, window: &Window, cx: &Context<Self>) -> KeyContext {
4363 let mut dispatch_context = KeyContext::new_with_defaults();
4364 dispatch_context.add("ProjectPanel");
4365 dispatch_context.add("menu");
4366
4367 let identifier = if self.filename_editor.focus_handle(cx).is_focused(window) {
4368 "editing"
4369 } else {
4370 "not_editing"
4371 };
4372
4373 dispatch_context.add(identifier);
4374 dispatch_context
4375 }
4376
4377 fn should_show_scrollbar(cx: &App) -> bool {
4378 let show = ProjectPanelSettings::get_global(cx)
4379 .scrollbar
4380 .show
4381 .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show);
4382 match show {
4383 ShowScrollbar::Auto => true,
4384 ShowScrollbar::System => true,
4385 ShowScrollbar::Always => true,
4386 ShowScrollbar::Never => false,
4387 }
4388 }
4389
4390 fn should_autohide_scrollbar(cx: &App) -> bool {
4391 let show = ProjectPanelSettings::get_global(cx)
4392 .scrollbar
4393 .show
4394 .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show);
4395 match show {
4396 ShowScrollbar::Auto => true,
4397 ShowScrollbar::System => cx
4398 .try_global::<ScrollbarAutoHide>()
4399 .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0),
4400 ShowScrollbar::Always => false,
4401 ShowScrollbar::Never => true,
4402 }
4403 }
4404
4405 fn hide_scrollbar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
4406 const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
4407 if !Self::should_autohide_scrollbar(cx) {
4408 return;
4409 }
4410 self.hide_scrollbar_task = Some(cx.spawn_in(window, async move |panel, cx| {
4411 cx.background_executor()
4412 .timer(SCROLLBAR_SHOW_INTERVAL)
4413 .await;
4414 panel
4415 .update(cx, |panel, cx| {
4416 panel.show_scrollbar = false;
4417 cx.notify();
4418 })
4419 .log_err();
4420 }))
4421 }
4422
4423 fn reveal_entry(
4424 &mut self,
4425 project: Entity<Project>,
4426 entry_id: ProjectEntryId,
4427 skip_ignored: bool,
4428 cx: &mut Context<Self>,
4429 ) -> Result<()> {
4430 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
4431 let worktree = worktree.read(cx);
4432 if skip_ignored
4433 && worktree
4434 .entry_for_id(entry_id)
4435 .map_or(true, |entry| entry.is_ignored && !entry.is_always_included)
4436 {
4437 return Err(anyhow!(
4438 "can't reveal an ignored entry in the project panel"
4439 ));
4440 }
4441
4442 let worktree_id = worktree.id();
4443 self.expand_entry(worktree_id, entry_id, cx);
4444 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
4445 self.marked_entries.clear();
4446 self.marked_entries.insert(SelectedEntry {
4447 worktree_id,
4448 entry_id,
4449 });
4450 self.autoscroll(cx);
4451 cx.notify();
4452 Ok(())
4453 } else {
4454 Err(anyhow!(
4455 "can't reveal a non-existent entry in the project panel"
4456 ))
4457 }
4458 }
4459
4460 fn find_active_indent_guide(
4461 &self,
4462 indent_guides: &[IndentGuideLayout],
4463 cx: &App,
4464 ) -> Option<usize> {
4465 let (worktree, entry) = self.selected_entry(cx)?;
4466
4467 // Find the parent entry of the indent guide, this will either be the
4468 // expanded folder we have selected, or the parent of the currently
4469 // selected file/collapsed directory
4470 let mut entry = entry;
4471 loop {
4472 let is_expanded_dir = entry.is_dir()
4473 && self
4474 .expanded_dir_ids
4475 .get(&worktree.id())
4476 .map(|ids| ids.binary_search(&entry.id).is_ok())
4477 .unwrap_or(false);
4478 if is_expanded_dir {
4479 break;
4480 }
4481 entry = worktree.entry_for_path(&entry.path.parent()?)?;
4482 }
4483
4484 let (active_indent_range, depth) = {
4485 let (worktree_ix, child_offset, ix) = self.index_for_entry(entry.id, worktree.id())?;
4486 let child_paths = &self.visible_entries[worktree_ix].1;
4487 let mut child_count = 0;
4488 let depth = entry.path.ancestors().count();
4489 while let Some(entry) = child_paths.get(child_offset + child_count + 1) {
4490 if entry.path.ancestors().count() <= depth {
4491 break;
4492 }
4493 child_count += 1;
4494 }
4495
4496 let start = ix + 1;
4497 let end = start + child_count;
4498
4499 let (_, entries, paths) = &self.visible_entries[worktree_ix];
4500 let visible_worktree_entries =
4501 paths.get_or_init(|| entries.iter().map(|e| (e.path.clone())).collect());
4502
4503 // Calculate the actual depth of the entry, taking into account that directories can be auto-folded.
4504 let (depth, _) = Self::calculate_depth_and_difference(entry, visible_worktree_entries);
4505 (start..end, depth)
4506 };
4507
4508 let candidates = indent_guides
4509 .iter()
4510 .enumerate()
4511 .filter(|(_, indent_guide)| indent_guide.offset.x == depth);
4512
4513 for (i, indent) in candidates {
4514 // Find matches that are either an exact match, partially on screen, or inside the enclosing indent
4515 if active_indent_range.start <= indent.offset.y + indent.length
4516 && indent.offset.y <= active_indent_range.end
4517 {
4518 return Some(i);
4519 }
4520 }
4521 None
4522 }
4523}
4524
4525fn item_width_estimate(depth: usize, item_text_chars: usize, is_symlink: bool) -> usize {
4526 const ICON_SIZE_FACTOR: usize = 2;
4527 let mut item_width = depth * ICON_SIZE_FACTOR + item_text_chars;
4528 if is_symlink {
4529 item_width += ICON_SIZE_FACTOR;
4530 }
4531 item_width
4532}
4533
4534impl Render for ProjectPanel {
4535 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
4536 let has_worktree = !self.visible_entries.is_empty();
4537 let project = self.project.read(cx);
4538 let indent_size = ProjectPanelSettings::get_global(cx).indent_size;
4539 let show_indent_guides =
4540 ProjectPanelSettings::get_global(cx).indent_guides.show == ShowIndentGuides::Always;
4541 let is_local = project.is_local();
4542
4543 if has_worktree {
4544 let item_count = self
4545 .visible_entries
4546 .iter()
4547 .map(|(_, worktree_entries, _)| worktree_entries.len())
4548 .sum();
4549
4550 fn handle_drag_move_scroll<T: 'static>(
4551 this: &mut ProjectPanel,
4552 e: &DragMoveEvent<T>,
4553 window: &mut Window,
4554 cx: &mut Context<ProjectPanel>,
4555 ) {
4556 if !e.bounds.contains(&e.event.position) {
4557 return;
4558 }
4559 this.hover_scroll_task.take();
4560 let panel_height = e.bounds.size.height;
4561 if panel_height <= px(0.) {
4562 return;
4563 }
4564
4565 let event_offset = e.event.position.y - e.bounds.origin.y;
4566 // How far along in the project panel is our cursor? (0. is the top of a list, 1. is the bottom)
4567 let hovered_region_offset = event_offset / panel_height;
4568
4569 // We want the scrolling to be a bit faster when the cursor is closer to the edge of a list.
4570 // These pixels offsets were picked arbitrarily.
4571 let vertical_scroll_offset = if hovered_region_offset <= 0.05 {
4572 8.
4573 } else if hovered_region_offset <= 0.15 {
4574 5.
4575 } else if hovered_region_offset >= 0.95 {
4576 -8.
4577 } else if hovered_region_offset >= 0.85 {
4578 -5.
4579 } else {
4580 return;
4581 };
4582 let adjustment = point(px(0.), px(vertical_scroll_offset));
4583 this.hover_scroll_task = Some(cx.spawn_in(window, async move |this, cx| {
4584 loop {
4585 let should_stop_scrolling = this
4586 .update(cx, |this, cx| {
4587 this.hover_scroll_task.as_ref()?;
4588 let handle = this.scroll_handle.0.borrow_mut();
4589 let offset = handle.base_handle.offset();
4590
4591 handle.base_handle.set_offset(offset + adjustment);
4592 cx.notify();
4593 Some(())
4594 })
4595 .ok()
4596 .flatten()
4597 .is_some();
4598 if should_stop_scrolling {
4599 return;
4600 }
4601 cx.background_executor()
4602 .timer(Duration::from_millis(16))
4603 .await;
4604 }
4605 }));
4606 }
4607 h_flex()
4608 .id("project-panel")
4609 .group("project-panel")
4610 .on_drag_move(cx.listener(handle_drag_move_scroll::<ExternalPaths>))
4611 .on_drag_move(cx.listener(handle_drag_move_scroll::<DraggedSelection>))
4612 .size_full()
4613 .relative()
4614 .on_hover(cx.listener(|this, hovered, window, cx| {
4615 if *hovered {
4616 this.show_scrollbar = true;
4617 this.hide_scrollbar_task.take();
4618 cx.notify();
4619 } else if !this.focus_handle.contains_focused(window, cx) {
4620 this.hide_scrollbar(window, cx);
4621 }
4622 }))
4623 .on_click(cx.listener(|this, _event, _, cx| {
4624 cx.stop_propagation();
4625 this.selection = None;
4626 this.marked_entries.clear();
4627 }))
4628 .key_context(self.dispatch_context(window, cx))
4629 .on_action(cx.listener(Self::select_next))
4630 .on_action(cx.listener(Self::select_previous))
4631 .on_action(cx.listener(Self::select_first))
4632 .on_action(cx.listener(Self::select_last))
4633 .on_action(cx.listener(Self::select_parent))
4634 .on_action(cx.listener(Self::select_next_git_entry))
4635 .on_action(cx.listener(Self::select_prev_git_entry))
4636 .on_action(cx.listener(Self::select_next_diagnostic))
4637 .on_action(cx.listener(Self::select_prev_diagnostic))
4638 .on_action(cx.listener(Self::select_next_directory))
4639 .on_action(cx.listener(Self::select_prev_directory))
4640 .on_action(cx.listener(Self::expand_selected_entry))
4641 .on_action(cx.listener(Self::collapse_selected_entry))
4642 .on_action(cx.listener(Self::collapse_all_entries))
4643 .on_action(cx.listener(Self::open))
4644 .on_action(cx.listener(Self::open_permanent))
4645 .on_action(cx.listener(Self::confirm))
4646 .on_action(cx.listener(Self::cancel))
4647 .on_action(cx.listener(Self::copy_path))
4648 .on_action(cx.listener(Self::copy_relative_path))
4649 .on_action(cx.listener(Self::new_search_in_directory))
4650 .on_action(cx.listener(Self::unfold_directory))
4651 .on_action(cx.listener(Self::fold_directory))
4652 .on_action(cx.listener(Self::remove_from_project))
4653 .when(!project.is_read_only(cx), |el| {
4654 el.on_action(cx.listener(Self::new_file))
4655 .on_action(cx.listener(Self::new_directory))
4656 .on_action(cx.listener(Self::rename))
4657 .on_action(cx.listener(Self::delete))
4658 .on_action(cx.listener(Self::trash))
4659 .on_action(cx.listener(Self::cut))
4660 .on_action(cx.listener(Self::copy))
4661 .on_action(cx.listener(Self::paste))
4662 .on_action(cx.listener(Self::duplicate))
4663 .on_click(cx.listener(|this, event: &gpui::ClickEvent, window, cx| {
4664 if event.up.click_count > 1 {
4665 if let Some(entry_id) = this.last_worktree_root_id {
4666 let project = this.project.read(cx);
4667
4668 let worktree_id = if let Some(worktree) =
4669 project.worktree_for_entry(entry_id, cx)
4670 {
4671 worktree.read(cx).id()
4672 } else {
4673 return;
4674 };
4675
4676 this.selection = Some(SelectedEntry {
4677 worktree_id,
4678 entry_id,
4679 });
4680
4681 this.new_file(&NewFile, window, cx);
4682 }
4683 }
4684 }))
4685 })
4686 .when(project.is_local(), |el| {
4687 el.on_action(cx.listener(Self::reveal_in_finder))
4688 .on_action(cx.listener(Self::open_system))
4689 .on_action(cx.listener(Self::open_in_terminal))
4690 })
4691 .when(project.is_via_ssh(), |el| {
4692 el.on_action(cx.listener(Self::open_in_terminal))
4693 })
4694 .on_mouse_down(
4695 MouseButton::Right,
4696 cx.listener(move |this, event: &MouseDownEvent, window, cx| {
4697 // When deploying the context menu anywhere below the last project entry,
4698 // act as if the user clicked the root of the last worktree.
4699 if let Some(entry_id) = this.last_worktree_root_id {
4700 this.deploy_context_menu(event.position, entry_id, window, cx);
4701 }
4702 }),
4703 )
4704 .track_focus(&self.focus_handle(cx))
4705 .child(
4706 uniform_list(cx.entity().clone(), "entries", item_count, {
4707 |this, range, window, cx| {
4708 let mut items = Vec::with_capacity(range.end - range.start);
4709 this.for_each_visible_entry(
4710 range,
4711 window,
4712 cx,
4713 |id, details, window, cx| {
4714 items.push(this.render_entry(id, details, window, cx));
4715 },
4716 );
4717 items
4718 }
4719 })
4720 .when(show_indent_guides, |list| {
4721 list.with_decoration(
4722 ui::indent_guides(
4723 cx.entity().clone(),
4724 px(indent_size),
4725 IndentGuideColors::panel(cx),
4726 |this, range, window, cx| {
4727 let mut items =
4728 SmallVec::with_capacity(range.end - range.start);
4729 this.iter_visible_entries(
4730 range,
4731 window,
4732 cx,
4733 |entry, entries, _, _| {
4734 let (depth, _) = Self::calculate_depth_and_difference(
4735 entry, entries,
4736 );
4737 items.push(depth);
4738 },
4739 );
4740 items
4741 },
4742 )
4743 .on_click(cx.listener(
4744 |this, active_indent_guide: &IndentGuideLayout, window, cx| {
4745 if window.modifiers().secondary() {
4746 let ix = active_indent_guide.offset.y;
4747 let Some((target_entry, worktree)) = maybe!({
4748 let (worktree_id, entry) = this.entry_at_index(ix)?;
4749 let worktree = this
4750 .project
4751 .read(cx)
4752 .worktree_for_id(worktree_id, cx)?;
4753 let target_entry = worktree
4754 .read(cx)
4755 .entry_for_path(&entry.path.parent()?)?;
4756 Some((target_entry, worktree))
4757 }) else {
4758 return;
4759 };
4760
4761 this.collapse_entry(target_entry.clone(), worktree, cx);
4762 }
4763 },
4764 ))
4765 .with_render_fn(
4766 cx.entity().clone(),
4767 move |this, params, _, cx| {
4768 const LEFT_OFFSET: Pixels = px(14.);
4769 const PADDING_Y: Pixels = px(4.);
4770 const HITBOX_OVERDRAW: Pixels = px(3.);
4771
4772 let active_indent_guide_index =
4773 this.find_active_indent_guide(¶ms.indent_guides, cx);
4774
4775 let indent_size = params.indent_size;
4776 let item_height = params.item_height;
4777
4778 params
4779 .indent_guides
4780 .into_iter()
4781 .enumerate()
4782 .map(|(idx, layout)| {
4783 let offset = if layout.continues_offscreen {
4784 px(0.)
4785 } else {
4786 PADDING_Y
4787 };
4788 let bounds = Bounds::new(
4789 point(
4790 layout.offset.x * indent_size + LEFT_OFFSET,
4791 layout.offset.y * item_height + offset,
4792 ),
4793 size(
4794 px(1.),
4795 layout.length * item_height - offset * 2.,
4796 ),
4797 );
4798 ui::RenderedIndentGuide {
4799 bounds,
4800 layout,
4801 is_active: Some(idx) == active_indent_guide_index,
4802 hitbox: Some(Bounds::new(
4803 point(
4804 bounds.origin.x - HITBOX_OVERDRAW,
4805 bounds.origin.y,
4806 ),
4807 size(
4808 bounds.size.width + HITBOX_OVERDRAW * 2.,
4809 bounds.size.height,
4810 ),
4811 )),
4812 }
4813 })
4814 .collect()
4815 },
4816 ),
4817 )
4818 })
4819 .size_full()
4820 .with_sizing_behavior(ListSizingBehavior::Infer)
4821 .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
4822 .with_width_from_item(self.max_width_item_index)
4823 .track_scroll(self.scroll_handle.clone()),
4824 )
4825 .children(self.render_vertical_scrollbar(cx))
4826 .when_some(self.render_horizontal_scrollbar(cx), |this, scrollbar| {
4827 this.pb_4().child(scrollbar)
4828 })
4829 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
4830 deferred(
4831 anchored()
4832 .position(*position)
4833 .anchor(gpui::Corner::TopLeft)
4834 .child(menu.clone()),
4835 )
4836 .with_priority(1)
4837 }))
4838 } else {
4839 v_flex()
4840 .id("empty-project_panel")
4841 .size_full()
4842 .p_4()
4843 .track_focus(&self.focus_handle(cx))
4844 .child(
4845 Button::new("open_project", "Open a project")
4846 .full_width()
4847 .key_binding(KeyBinding::for_action(&workspace::Open, window, cx))
4848 .on_click(cx.listener(|this, _, window, cx| {
4849 this.workspace
4850 .update(cx, |_, cx| {
4851 window.dispatch_action(Box::new(workspace::Open), cx)
4852 })
4853 .log_err();
4854 })),
4855 )
4856 .when(is_local, |div| {
4857 div.drag_over::<ExternalPaths>(|style, _, _, cx| {
4858 style.bg(cx.theme().colors().drop_target_background)
4859 })
4860 .on_drop(cx.listener(
4861 move |this, external_paths: &ExternalPaths, window, cx| {
4862 this.last_external_paths_drag_over_entry = None;
4863 this.marked_entries.clear();
4864 this.hover_scroll_task.take();
4865 if let Some(task) = this
4866 .workspace
4867 .update(cx, |workspace, cx| {
4868 workspace.open_workspace_for_paths(
4869 true,
4870 external_paths.paths().to_owned(),
4871 window,
4872 cx,
4873 )
4874 })
4875 .log_err()
4876 {
4877 task.detach_and_log_err(cx);
4878 }
4879 cx.stop_propagation();
4880 },
4881 ))
4882 })
4883 }
4884 }
4885}
4886
4887impl Render for DraggedProjectEntryView {
4888 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
4889 let ui_font = ThemeSettings::get_global(cx).ui_font.clone();
4890 h_flex()
4891 .font(ui_font)
4892 .pl(self.click_offset.x + px(12.))
4893 .pt(self.click_offset.y + px(12.))
4894 .child(
4895 div()
4896 .flex()
4897 .gap_1()
4898 .items_center()
4899 .py_1()
4900 .px_2()
4901 .rounded_lg()
4902 .bg(cx.theme().colors().background)
4903 .map(|this| {
4904 if self.selections.len() > 1 && self.selections.contains(&self.selection) {
4905 this.child(Label::new(format!("{} entries", self.selections.len())))
4906 } else {
4907 this.child(if let Some(icon) = &self.details.icon {
4908 div().child(Icon::from_path(icon.clone()))
4909 } else {
4910 div()
4911 })
4912 .child(Label::new(self.details.filename.clone()))
4913 }
4914 }),
4915 )
4916 }
4917}
4918
4919impl EventEmitter<Event> for ProjectPanel {}
4920
4921impl EventEmitter<PanelEvent> for ProjectPanel {}
4922
4923impl Panel for ProjectPanel {
4924 fn position(&self, _: &Window, cx: &App) -> DockPosition {
4925 match ProjectPanelSettings::get_global(cx).dock {
4926 ProjectPanelDockPosition::Left => DockPosition::Left,
4927 ProjectPanelDockPosition::Right => DockPosition::Right,
4928 }
4929 }
4930
4931 fn position_is_valid(&self, position: DockPosition) -> bool {
4932 matches!(position, DockPosition::Left | DockPosition::Right)
4933 }
4934
4935 fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
4936 settings::update_settings_file::<ProjectPanelSettings>(
4937 self.fs.clone(),
4938 cx,
4939 move |settings, _| {
4940 let dock = match position {
4941 DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
4942 DockPosition::Right => ProjectPanelDockPosition::Right,
4943 };
4944 settings.dock = Some(dock);
4945 },
4946 );
4947 }
4948
4949 fn size(&self, _: &Window, cx: &App) -> Pixels {
4950 self.width
4951 .unwrap_or_else(|| ProjectPanelSettings::get_global(cx).default_width)
4952 }
4953
4954 fn set_size(&mut self, size: Option<Pixels>, _: &mut Window, cx: &mut Context<Self>) {
4955 self.width = size;
4956 self.serialize(cx);
4957 cx.notify();
4958 }
4959
4960 fn icon(&self, _: &Window, cx: &App) -> Option<IconName> {
4961 ProjectPanelSettings::get_global(cx)
4962 .button
4963 .then_some(IconName::FileTree)
4964 }
4965
4966 fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
4967 Some("Project Panel")
4968 }
4969
4970 fn toggle_action(&self) -> Box<dyn Action> {
4971 Box::new(ToggleFocus)
4972 }
4973
4974 fn persistent_name() -> &'static str {
4975 "Project Panel"
4976 }
4977
4978 fn starts_open(&self, _: &Window, cx: &App) -> bool {
4979 let project = &self.project.read(cx);
4980 project.visible_worktrees(cx).any(|tree| {
4981 tree.read(cx)
4982 .root_entry()
4983 .map_or(false, |entry| entry.is_dir())
4984 })
4985 }
4986
4987 fn activation_priority(&self) -> u32 {
4988 0
4989 }
4990}
4991
4992impl Focusable for ProjectPanel {
4993 fn focus_handle(&self, _cx: &App) -> FocusHandle {
4994 self.focus_handle.clone()
4995 }
4996}
4997
4998impl ClipboardEntry {
4999 fn is_cut(&self) -> bool {
5000 matches!(self, Self::Cut { .. })
5001 }
5002
5003 fn items(&self) -> &BTreeSet<SelectedEntry> {
5004 match self {
5005 ClipboardEntry::Copied(entries) | ClipboardEntry::Cut(entries) => entries,
5006 }
5007 }
5008}
5009
5010#[cfg(test)]
5011mod project_panel_tests;