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)) = maybe!({
2991 let worktree = self.project.read(cx).worktree_for_entry(entry_id, cx)?;
2992 let entry = worktree.read(cx).entry_for_id(entry_id)?;
2993 let path = worktree.read(cx).absolutize(&entry.path).ok()?;
2994 let target_directory = if path.is_dir() {
2995 path
2996 } else {
2997 path.parent()?.to_path_buf()
2998 };
2999 Some((target_directory, worktree))
3000 }) else {
3001 return;
3002 };
3003
3004 let mut paths_to_replace = Vec::new();
3005 for path in &paths {
3006 if let Some(name) = path.file_name() {
3007 let mut target_path = target_directory.clone();
3008 target_path.push(name);
3009 if target_path.exists() {
3010 paths_to_replace.push((name.to_string_lossy().to_string(), path.clone()));
3011 }
3012 }
3013 }
3014
3015 cx.spawn_in(window, async move |this, cx| {
3016 async move {
3017 for (filename, original_path) in &paths_to_replace {
3018 let answer = cx.update(|window, cx| {
3019 window
3020 .prompt(
3021 PromptLevel::Info,
3022 format!("A file or folder with name {filename} already exists in the destination folder. Do you want to replace it?").as_str(),
3023 None,
3024 &["Replace", "Cancel"],
3025 cx,
3026 )
3027 })?.await?;
3028
3029 if answer == 1 {
3030 if let Some(item_idx) = paths.iter().position(|p| p == original_path) {
3031 paths.remove(item_idx);
3032 }
3033 }
3034 }
3035
3036 if paths.is_empty() {
3037 return Ok(());
3038 }
3039
3040 let task = worktree.update( cx, |worktree, cx| {
3041 worktree.copy_external_entries(target_directory, paths, true, cx)
3042 })?;
3043
3044 let opened_entries = task.await?;
3045 this.update(cx, |this, cx| {
3046 if open_file_after_drop && !opened_entries.is_empty() {
3047 this.open_entry(opened_entries[0], true, false, cx);
3048 }
3049 })
3050 }
3051 .log_err().await
3052 })
3053 .detach();
3054 }
3055
3056 fn drag_onto(
3057 &mut self,
3058 selections: &DraggedSelection,
3059 target_entry_id: ProjectEntryId,
3060 is_file: bool,
3061 window: &mut Window,
3062 cx: &mut Context<Self>,
3063 ) {
3064 let should_copy = window.modifiers().alt;
3065 if should_copy {
3066 let _ = maybe!({
3067 let project = self.project.read(cx);
3068 let target_worktree = project.worktree_for_entry(target_entry_id, cx)?;
3069 let worktree_id = target_worktree.read(cx).id();
3070 let target_entry = target_worktree
3071 .read(cx)
3072 .entry_for_id(target_entry_id)?
3073 .clone();
3074
3075 let mut copy_tasks = Vec::new();
3076 let mut disambiguation_range = None;
3077 for selection in selections.items() {
3078 let (new_path, new_disambiguation_range) = self.create_paste_path(
3079 selection,
3080 (target_worktree.clone(), &target_entry),
3081 cx,
3082 )?;
3083
3084 let task = self.project.update(cx, |project, cx| {
3085 project.copy_entry(selection.entry_id, None, new_path, cx)
3086 });
3087 copy_tasks.push(task);
3088 disambiguation_range = new_disambiguation_range.or(disambiguation_range);
3089 }
3090
3091 let item_count = copy_tasks.len();
3092
3093 cx.spawn_in(window, async move |project_panel, cx| {
3094 let mut last_succeed = None;
3095 for task in copy_tasks.into_iter() {
3096 if let Some(Some(entry)) = task.await.log_err() {
3097 last_succeed = Some(entry.id);
3098 }
3099 }
3100 // update selection
3101 if let Some(entry_id) = last_succeed {
3102 project_panel
3103 .update_in(cx, |project_panel, window, cx| {
3104 project_panel.selection = Some(SelectedEntry {
3105 worktree_id,
3106 entry_id,
3107 });
3108
3109 // if only one entry was dragged and it was disambiguated, open the rename editor
3110 if item_count == 1 && disambiguation_range.is_some() {
3111 project_panel.rename_impl(disambiguation_range, window, cx);
3112 }
3113 })
3114 .ok();
3115 }
3116 })
3117 .detach();
3118 Some(())
3119 });
3120 } else {
3121 for selection in selections.items() {
3122 self.move_entry(selection.entry_id, target_entry_id, is_file, cx);
3123 }
3124 }
3125 }
3126
3127 fn index_for_entry(
3128 &self,
3129 entry_id: ProjectEntryId,
3130 worktree_id: WorktreeId,
3131 ) -> Option<(usize, usize, usize)> {
3132 let mut worktree_ix = 0;
3133 let mut total_ix = 0;
3134 for (current_worktree_id, visible_worktree_entries, _) in &self.visible_entries {
3135 if worktree_id != *current_worktree_id {
3136 total_ix += visible_worktree_entries.len();
3137 worktree_ix += 1;
3138 continue;
3139 }
3140
3141 return visible_worktree_entries
3142 .iter()
3143 .enumerate()
3144 .find(|(_, entry)| entry.id == entry_id)
3145 .map(|(ix, _)| (worktree_ix, ix, total_ix + ix));
3146 }
3147 None
3148 }
3149
3150 fn entry_at_index(&self, index: usize) -> Option<(WorktreeId, GitEntryRef)> {
3151 let mut offset = 0;
3152 for (worktree_id, visible_worktree_entries, _) in &self.visible_entries {
3153 if visible_worktree_entries.len() > offset + index {
3154 return visible_worktree_entries
3155 .get(index)
3156 .map(|entry| (*worktree_id, entry.to_ref()));
3157 }
3158 offset += visible_worktree_entries.len();
3159 }
3160 None
3161 }
3162
3163 fn iter_visible_entries(
3164 &self,
3165 range: Range<usize>,
3166 window: &mut Window,
3167 cx: &mut Context<ProjectPanel>,
3168 mut callback: impl FnMut(&Entry, &HashSet<Arc<Path>>, &mut Window, &mut Context<ProjectPanel>),
3169 ) {
3170 let mut ix = 0;
3171 for (_, visible_worktree_entries, entries_paths) in &self.visible_entries {
3172 if ix >= range.end {
3173 return;
3174 }
3175
3176 if ix + visible_worktree_entries.len() <= range.start {
3177 ix += visible_worktree_entries.len();
3178 continue;
3179 }
3180
3181 let end_ix = range.end.min(ix + visible_worktree_entries.len());
3182 let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
3183 let entries = entries_paths.get_or_init(|| {
3184 visible_worktree_entries
3185 .iter()
3186 .map(|e| (e.path.clone()))
3187 .collect()
3188 });
3189 for entry in visible_worktree_entries[entry_range].iter() {
3190 callback(&entry, entries, window, cx);
3191 }
3192 ix = end_ix;
3193 }
3194 }
3195
3196 fn for_each_visible_entry(
3197 &self,
3198 range: Range<usize>,
3199 window: &mut Window,
3200 cx: &mut Context<ProjectPanel>,
3201 mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut Window, &mut Context<ProjectPanel>),
3202 ) {
3203 let mut ix = 0;
3204 for (worktree_id, visible_worktree_entries, entries_paths) in &self.visible_entries {
3205 if ix >= range.end {
3206 return;
3207 }
3208
3209 if ix + visible_worktree_entries.len() <= range.start {
3210 ix += visible_worktree_entries.len();
3211 continue;
3212 }
3213
3214 let end_ix = range.end.min(ix + visible_worktree_entries.len());
3215 let (git_status_setting, show_file_icons, show_folder_icons) = {
3216 let settings = ProjectPanelSettings::get_global(cx);
3217 (
3218 settings.git_status,
3219 settings.file_icons,
3220 settings.folder_icons,
3221 )
3222 };
3223 if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
3224 let snapshot = worktree.read(cx).snapshot();
3225 let root_name = OsStr::new(snapshot.root_name());
3226 let expanded_entry_ids = self
3227 .expanded_dir_ids
3228 .get(&snapshot.id())
3229 .map(Vec::as_slice)
3230 .unwrap_or(&[]);
3231
3232 let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
3233 let entries = entries_paths.get_or_init(|| {
3234 visible_worktree_entries
3235 .iter()
3236 .map(|e| (e.path.clone()))
3237 .collect()
3238 });
3239 for entry in visible_worktree_entries[entry_range].iter() {
3240 let status = git_status_setting
3241 .then_some(entry.git_summary)
3242 .unwrap_or_default();
3243 let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok();
3244 let icon = match entry.kind {
3245 EntryKind::File => {
3246 if show_file_icons {
3247 FileIcons::get_icon(&entry.path, cx)
3248 } else {
3249 None
3250 }
3251 }
3252 _ => {
3253 if show_folder_icons {
3254 FileIcons::get_folder_icon(is_expanded, cx)
3255 } else {
3256 FileIcons::get_chevron_icon(is_expanded, cx)
3257 }
3258 }
3259 };
3260
3261 let (depth, difference) =
3262 ProjectPanel::calculate_depth_and_difference(&entry, entries);
3263
3264 let filename = match difference {
3265 diff if diff > 1 => entry
3266 .path
3267 .iter()
3268 .skip(entry.path.components().count() - diff)
3269 .collect::<PathBuf>()
3270 .to_str()
3271 .unwrap_or_default()
3272 .to_string(),
3273 _ => entry
3274 .path
3275 .file_name()
3276 .map(|name| name.to_string_lossy().into_owned())
3277 .unwrap_or_else(|| root_name.to_string_lossy().to_string()),
3278 };
3279 let selection = SelectedEntry {
3280 worktree_id: snapshot.id(),
3281 entry_id: entry.id,
3282 };
3283
3284 let is_marked = self.marked_entries.contains(&selection);
3285
3286 let diagnostic_severity = self
3287 .diagnostics
3288 .get(&(*worktree_id, entry.path.to_path_buf()))
3289 .cloned();
3290
3291 let filename_text_color =
3292 entry_git_aware_label_color(status, entry.is_ignored, is_marked);
3293
3294 let mut details = EntryDetails {
3295 filename,
3296 icon,
3297 path: entry.path.clone(),
3298 depth,
3299 kind: entry.kind,
3300 is_ignored: entry.is_ignored,
3301 is_expanded,
3302 is_selected: self.selection == Some(selection),
3303 is_marked,
3304 is_editing: false,
3305 is_processing: false,
3306 is_cut: self
3307 .clipboard
3308 .as_ref()
3309 .map_or(false, |e| e.is_cut() && e.items().contains(&selection)),
3310 filename_text_color,
3311 diagnostic_severity,
3312 git_status: status,
3313 is_private: entry.is_private,
3314 worktree_id: *worktree_id,
3315 canonical_path: entry.canonical_path.clone(),
3316 };
3317
3318 if let Some(edit_state) = &self.edit_state {
3319 let is_edited_entry = if edit_state.is_new_entry() {
3320 entry.id == NEW_ENTRY_ID
3321 } else {
3322 entry.id == edit_state.entry_id
3323 || self
3324 .ancestors
3325 .get(&entry.id)
3326 .is_some_and(|auto_folded_dirs| {
3327 auto_folded_dirs
3328 .ancestors
3329 .iter()
3330 .any(|entry_id| *entry_id == edit_state.entry_id)
3331 })
3332 };
3333
3334 if is_edited_entry {
3335 if let Some(processing_filename) = &edit_state.processing_filename {
3336 details.is_processing = true;
3337 if let Some(ancestors) = edit_state
3338 .leaf_entry_id
3339 .and_then(|entry| self.ancestors.get(&entry))
3340 {
3341 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;
3342 let all_components = ancestors.ancestors.len();
3343
3344 let prefix_components = all_components - position;
3345 let suffix_components = position.checked_sub(1);
3346 let mut previous_components =
3347 Path::new(&details.filename).components();
3348 let mut new_path = previous_components
3349 .by_ref()
3350 .take(prefix_components)
3351 .collect::<PathBuf>();
3352 if let Some(last_component) =
3353 Path::new(processing_filename).components().next_back()
3354 {
3355 new_path.push(last_component);
3356 previous_components.next();
3357 }
3358
3359 if let Some(_) = suffix_components {
3360 new_path.push(previous_components);
3361 }
3362 if let Some(str) = new_path.to_str() {
3363 details.filename.clear();
3364 details.filename.push_str(str);
3365 }
3366 } else {
3367 details.filename.clear();
3368 details.filename.push_str(processing_filename);
3369 }
3370 } else {
3371 if edit_state.is_new_entry() {
3372 details.filename.clear();
3373 }
3374 details.is_editing = true;
3375 }
3376 }
3377 }
3378
3379 callback(entry.id, details, window, cx);
3380 }
3381 }
3382 ix = end_ix;
3383 }
3384 }
3385
3386 fn find_entry_in_worktree(
3387 &self,
3388 worktree_id: WorktreeId,
3389 reverse_search: bool,
3390 only_visible_entries: bool,
3391 predicate: impl Fn(GitEntryRef, WorktreeId) -> bool,
3392 cx: &mut Context<Self>,
3393 ) -> Option<GitEntry> {
3394 if only_visible_entries {
3395 let entries = self
3396 .visible_entries
3397 .iter()
3398 .find_map(|(tree_id, entries, _)| {
3399 if worktree_id == *tree_id {
3400 Some(entries)
3401 } else {
3402 None
3403 }
3404 })?
3405 .clone();
3406
3407 return utils::ReversibleIterable::new(entries.iter(), reverse_search)
3408 .find(|ele| predicate(ele.to_ref(), worktree_id))
3409 .cloned();
3410 }
3411
3412 let repo_snapshots = self
3413 .project
3414 .read(cx)
3415 .git_store()
3416 .read(cx)
3417 .repo_snapshots(cx);
3418 let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
3419 worktree.update(cx, |tree, _| {
3420 utils::ReversibleIterable::new(
3421 GitTraversal::new(&repo_snapshots, tree.entries(true, 0usize)),
3422 reverse_search,
3423 )
3424 .find_single_ended(|ele| predicate(*ele, worktree_id))
3425 .map(|ele| ele.to_owned())
3426 })
3427 }
3428
3429 fn find_entry(
3430 &self,
3431 start: Option<&SelectedEntry>,
3432 reverse_search: bool,
3433 predicate: impl Fn(GitEntryRef, WorktreeId) -> bool,
3434 cx: &mut Context<Self>,
3435 ) -> Option<SelectedEntry> {
3436 let mut worktree_ids: Vec<_> = self
3437 .visible_entries
3438 .iter()
3439 .map(|(worktree_id, _, _)| *worktree_id)
3440 .collect();
3441 let repo_snapshots = self
3442 .project
3443 .read(cx)
3444 .git_store()
3445 .read(cx)
3446 .repo_snapshots(cx);
3447
3448 let mut last_found: Option<SelectedEntry> = None;
3449
3450 if let Some(start) = start {
3451 let worktree = self
3452 .project
3453 .read(cx)
3454 .worktree_for_id(start.worktree_id, cx)?;
3455
3456 let search = worktree.update(cx, |tree, _| {
3457 let entry = tree.entry_for_id(start.entry_id)?;
3458 let root_entry = tree.root_entry()?;
3459 let tree_id = tree.id();
3460
3461 let mut first_iter = GitTraversal::new(
3462 &repo_snapshots,
3463 tree.traverse_from_path(true, true, true, entry.path.as_ref()),
3464 );
3465
3466 if reverse_search {
3467 first_iter.next();
3468 }
3469
3470 let first = first_iter
3471 .enumerate()
3472 .take_until(|(count, entry)| entry.entry == root_entry && *count != 0usize)
3473 .map(|(_, entry)| entry)
3474 .find(|ele| predicate(*ele, tree_id))
3475 .map(|ele| ele.to_owned());
3476
3477 let second_iter = GitTraversal::new(&repo_snapshots, tree.entries(true, 0usize));
3478
3479 let second = if reverse_search {
3480 second_iter
3481 .take_until(|ele| ele.id == start.entry_id)
3482 .filter(|ele| predicate(*ele, tree_id))
3483 .last()
3484 .map(|ele| ele.to_owned())
3485 } else {
3486 second_iter
3487 .take_while(|ele| ele.id != start.entry_id)
3488 .filter(|ele| predicate(*ele, tree_id))
3489 .last()
3490 .map(|ele| ele.to_owned())
3491 };
3492
3493 if reverse_search {
3494 Some((second, first))
3495 } else {
3496 Some((first, second))
3497 }
3498 });
3499
3500 if let Some((first, second)) = search {
3501 let first = first.map(|entry| SelectedEntry {
3502 worktree_id: start.worktree_id,
3503 entry_id: entry.id,
3504 });
3505
3506 let second = second.map(|entry| SelectedEntry {
3507 worktree_id: start.worktree_id,
3508 entry_id: entry.id,
3509 });
3510
3511 if first.is_some() {
3512 return first;
3513 }
3514 last_found = second;
3515
3516 let idx = worktree_ids
3517 .iter()
3518 .enumerate()
3519 .find(|(_, ele)| **ele == start.worktree_id)
3520 .map(|(idx, _)| idx);
3521
3522 if let Some(idx) = idx {
3523 worktree_ids.rotate_left(idx + 1usize);
3524 worktree_ids.pop();
3525 }
3526 }
3527 }
3528
3529 for tree_id in worktree_ids.into_iter() {
3530 if let Some(found) =
3531 self.find_entry_in_worktree(tree_id, reverse_search, false, &predicate, cx)
3532 {
3533 return Some(SelectedEntry {
3534 worktree_id: tree_id,
3535 entry_id: found.id,
3536 });
3537 }
3538 }
3539
3540 last_found
3541 }
3542
3543 fn find_visible_entry(
3544 &self,
3545 start: Option<&SelectedEntry>,
3546 reverse_search: bool,
3547 predicate: impl Fn(GitEntryRef, WorktreeId) -> bool,
3548 cx: &mut Context<Self>,
3549 ) -> Option<SelectedEntry> {
3550 let mut worktree_ids: Vec<_> = self
3551 .visible_entries
3552 .iter()
3553 .map(|(worktree_id, _, _)| *worktree_id)
3554 .collect();
3555
3556 let mut last_found: Option<SelectedEntry> = None;
3557
3558 if let Some(start) = start {
3559 let entries = self
3560 .visible_entries
3561 .iter()
3562 .find(|(worktree_id, _, _)| *worktree_id == start.worktree_id)
3563 .map(|(_, entries, _)| entries)?;
3564
3565 let mut start_idx = entries
3566 .iter()
3567 .enumerate()
3568 .find(|(_, ele)| ele.id == start.entry_id)
3569 .map(|(idx, _)| idx)?;
3570
3571 if reverse_search {
3572 start_idx = start_idx.saturating_add(1usize);
3573 }
3574
3575 let (left, right) = entries.split_at_checked(start_idx)?;
3576
3577 let (first_iter, second_iter) = if reverse_search {
3578 (
3579 utils::ReversibleIterable::new(left.iter(), reverse_search),
3580 utils::ReversibleIterable::new(right.iter(), reverse_search),
3581 )
3582 } else {
3583 (
3584 utils::ReversibleIterable::new(right.iter(), reverse_search),
3585 utils::ReversibleIterable::new(left.iter(), reverse_search),
3586 )
3587 };
3588
3589 let first_search = first_iter.find(|ele| predicate(ele.to_ref(), start.worktree_id));
3590 let second_search = second_iter.find(|ele| predicate(ele.to_ref(), start.worktree_id));
3591
3592 if first_search.is_some() {
3593 return first_search.map(|entry| SelectedEntry {
3594 worktree_id: start.worktree_id,
3595 entry_id: entry.id,
3596 });
3597 }
3598
3599 last_found = second_search.map(|entry| SelectedEntry {
3600 worktree_id: start.worktree_id,
3601 entry_id: entry.id,
3602 });
3603
3604 let idx = worktree_ids
3605 .iter()
3606 .enumerate()
3607 .find(|(_, ele)| **ele == start.worktree_id)
3608 .map(|(idx, _)| idx);
3609
3610 if let Some(idx) = idx {
3611 worktree_ids.rotate_left(idx + 1usize);
3612 worktree_ids.pop();
3613 }
3614 }
3615
3616 for tree_id in worktree_ids.into_iter() {
3617 if let Some(found) =
3618 self.find_entry_in_worktree(tree_id, reverse_search, true, &predicate, cx)
3619 {
3620 return Some(SelectedEntry {
3621 worktree_id: tree_id,
3622 entry_id: found.id,
3623 });
3624 }
3625 }
3626
3627 last_found
3628 }
3629
3630 fn calculate_depth_and_difference(
3631 entry: &Entry,
3632 visible_worktree_entries: &HashSet<Arc<Path>>,
3633 ) -> (usize, usize) {
3634 let (depth, difference) = entry
3635 .path
3636 .ancestors()
3637 .skip(1) // Skip the entry itself
3638 .find_map(|ancestor| {
3639 if let Some(parent_entry) = visible_worktree_entries.get(ancestor) {
3640 let entry_path_components_count = entry.path.components().count();
3641 let parent_path_components_count = parent_entry.components().count();
3642 let difference = entry_path_components_count - parent_path_components_count;
3643 let depth = parent_entry
3644 .ancestors()
3645 .skip(1)
3646 .filter(|ancestor| visible_worktree_entries.contains(*ancestor))
3647 .count();
3648 Some((depth + 1, difference))
3649 } else {
3650 None
3651 }
3652 })
3653 .unwrap_or((0, 0));
3654
3655 (depth, difference)
3656 }
3657
3658 fn render_entry(
3659 &self,
3660 entry_id: ProjectEntryId,
3661 details: EntryDetails,
3662 window: &mut Window,
3663 cx: &mut Context<Self>,
3664 ) -> Stateful<Div> {
3665 const GROUP_NAME: &str = "project_entry";
3666
3667 let kind = details.kind;
3668 let settings = ProjectPanelSettings::get_global(cx);
3669 let show_editor = details.is_editing && !details.is_processing;
3670
3671 let selection = SelectedEntry {
3672 worktree_id: details.worktree_id,
3673 entry_id,
3674 };
3675
3676 let is_marked = self.marked_entries.contains(&selection);
3677 let is_active = self
3678 .selection
3679 .map_or(false, |selection| selection.entry_id == entry_id);
3680
3681 let file_name = details.filename.clone();
3682
3683 let mut icon = details.icon.clone();
3684 if settings.file_icons && show_editor && details.kind.is_file() {
3685 let filename = self.filename_editor.read(cx).text(cx);
3686 if filename.len() > 2 {
3687 icon = FileIcons::get_icon(Path::new(&filename), cx);
3688 }
3689 }
3690
3691 let filename_text_color = details.filename_text_color;
3692 let diagnostic_severity = details.diagnostic_severity;
3693 let item_colors = get_item_color(cx);
3694
3695 let canonical_path = details
3696 .canonical_path
3697 .as_ref()
3698 .map(|f| f.to_string_lossy().to_string());
3699 let path = details.path.clone();
3700
3701 let depth = details.depth;
3702 let worktree_id = details.worktree_id;
3703 let selections = Arc::new(self.marked_entries.clone());
3704 let is_local = self.project.read(cx).is_local();
3705
3706 let dragged_selection = DraggedSelection {
3707 active_selection: selection,
3708 marked_selections: selections,
3709 };
3710
3711 let bg_color = if is_marked {
3712 item_colors.marked
3713 } else {
3714 item_colors.default
3715 };
3716
3717 let bg_hover_color = if is_marked {
3718 item_colors.marked
3719 } else {
3720 item_colors.hover
3721 };
3722
3723 let validation_color_and_message = if show_editor {
3724 match self
3725 .edit_state
3726 .as_ref()
3727 .map_or(ValidationState::None, |e| e.validation_state.clone())
3728 {
3729 ValidationState::Error(msg) => Some((Color::Error.color(cx), msg.clone())),
3730 ValidationState::Warning(msg) => Some((Color::Warning.color(cx), msg.clone())),
3731 ValidationState::None => None,
3732 }
3733 } else {
3734 None
3735 };
3736
3737 let border_color =
3738 if !self.mouse_down && is_active && self.focus_handle.contains_focused(window, cx) {
3739 match validation_color_and_message {
3740 Some((color, _)) => color,
3741 None => item_colors.focused,
3742 }
3743 } else {
3744 bg_color
3745 };
3746
3747 let border_hover_color =
3748 if !self.mouse_down && is_active && self.focus_handle.contains_focused(window, cx) {
3749 match validation_color_and_message {
3750 Some((color, _)) => color,
3751 None => item_colors.focused,
3752 }
3753 } else {
3754 bg_hover_color
3755 };
3756
3757 let folded_directory_drag_target = self.folded_directory_drag_target;
3758
3759 div()
3760 .id(entry_id.to_proto() as usize)
3761 .group(GROUP_NAME)
3762 .cursor_pointer()
3763 .rounded_none()
3764 .bg(bg_color)
3765 .border_1()
3766 .border_r_2()
3767 .border_color(border_color)
3768 .hover(|style| style.bg(bg_hover_color).border_color(border_hover_color))
3769 .when(is_local, |div| {
3770 div.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 abs_path = worktree.absolutize(&path).log_err()?;
3786 let path = if abs_path.is_dir() {
3787 path.as_ref()
3788 } else {
3789 path.parent()?
3790 };
3791 let entry = worktree.entry_for_path(path)?;
3792 Some((worktree, path, entry))
3793 }) else {
3794 return;
3795 };
3796
3797 this.marked_entries.insert(SelectedEntry {
3798 entry_id: entry.id,
3799 worktree_id: worktree.id(),
3800 });
3801
3802 for entry in worktree.child_entries(path) {
3803 this.marked_entries.insert(SelectedEntry {
3804 entry_id: entry.id,
3805 worktree_id: worktree.id(),
3806 });
3807 }
3808
3809 cx.notify();
3810 }
3811 },
3812 ))
3813 .on_drop(cx.listener(
3814 move |this, external_paths: &ExternalPaths, window, cx| {
3815 this.hover_scroll_task.take();
3816 this.last_external_paths_drag_over_entry = None;
3817 this.marked_entries.clear();
3818 this.drop_external_files(external_paths.paths(), entry_id, window, cx);
3819 cx.stop_propagation();
3820 },
3821 ))
3822 })
3823 .on_drag_move::<DraggedSelection>(cx.listener(
3824 move |this, event: &DragMoveEvent<DraggedSelection>, window, cx| {
3825 if event.bounds.contains(&event.event.position) {
3826 if this.last_selection_drag_over_entry == Some(entry_id) {
3827 return;
3828 }
3829 this.last_selection_drag_over_entry = Some(entry_id);
3830 this.hover_expand_task.take();
3831
3832 if !kind.is_dir()
3833 || this
3834 .expanded_dir_ids
3835 .get(&details.worktree_id)
3836 .map_or(false, |ids| ids.binary_search(&entry_id).is_ok())
3837 {
3838 return;
3839 }
3840
3841 let bounds = event.bounds;
3842 this.hover_expand_task =
3843 Some(cx.spawn_in(window, async move |this, cx| {
3844 cx.background_executor()
3845 .timer(Duration::from_millis(500))
3846 .await;
3847 this.update_in(cx, |this, window, cx| {
3848 this.hover_expand_task.take();
3849 if this.last_selection_drag_over_entry == Some(entry_id)
3850 && bounds.contains(&window.mouse_position())
3851 {
3852 this.expand_entry(worktree_id, entry_id, cx);
3853 this.update_visible_entries(
3854 Some((worktree_id, entry_id)),
3855 cx,
3856 );
3857 cx.notify();
3858 }
3859 })
3860 .ok();
3861 }));
3862 }
3863 },
3864 ))
3865 .on_drag(
3866 dragged_selection,
3867 move |selection, click_offset, _window, cx| {
3868 cx.new(|_| DraggedProjectEntryView {
3869 details: details.clone(),
3870 click_offset,
3871 selection: selection.active_selection,
3872 selections: selection.marked_selections.clone(),
3873 })
3874 },
3875 )
3876 .drag_over::<DraggedSelection>(move |style, _, _, _| {
3877 if folded_directory_drag_target.is_some() {
3878 return style;
3879 }
3880 style.bg(item_colors.drag_over)
3881 })
3882 .on_drop(
3883 cx.listener(move |this, selections: &DraggedSelection, window, cx| {
3884 this.hover_scroll_task.take();
3885 this.hover_expand_task.take();
3886 if folded_directory_drag_target.is_some() {
3887 return;
3888 }
3889 this.drag_onto(selections, entry_id, kind.is_file(), window, cx);
3890 }),
3891 )
3892 .on_mouse_down(
3893 MouseButton::Left,
3894 cx.listener(move |this, _, _, cx| {
3895 this.mouse_down = true;
3896 cx.propagate();
3897 }),
3898 )
3899 .on_click(
3900 cx.listener(move |this, event: &gpui::ClickEvent, window, cx| {
3901 if event.down.button == MouseButton::Right
3902 || event.down.first_mouse
3903 || show_editor
3904 {
3905 return;
3906 }
3907 if event.down.button == MouseButton::Left {
3908 this.mouse_down = false;
3909 }
3910 cx.stop_propagation();
3911
3912 if let Some(selection) = this.selection.filter(|_| event.modifiers().shift) {
3913 let current_selection = this.index_for_selection(selection);
3914 let clicked_entry = SelectedEntry {
3915 entry_id,
3916 worktree_id,
3917 };
3918 let target_selection = this.index_for_selection(clicked_entry);
3919 if let Some(((_, _, source_index), (_, _, target_index))) =
3920 current_selection.zip(target_selection)
3921 {
3922 let range_start = source_index.min(target_index);
3923 let range_end = source_index.max(target_index) + 1; // Make the range inclusive.
3924 let mut new_selections = BTreeSet::new();
3925 this.for_each_visible_entry(
3926 range_start..range_end,
3927 window,
3928 cx,
3929 |entry_id, details, _, _| {
3930 new_selections.insert(SelectedEntry {
3931 entry_id,
3932 worktree_id: details.worktree_id,
3933 });
3934 },
3935 );
3936
3937 this.marked_entries = this
3938 .marked_entries
3939 .union(&new_selections)
3940 .cloned()
3941 .collect();
3942
3943 this.selection = Some(clicked_entry);
3944 this.marked_entries.insert(clicked_entry);
3945 }
3946 } else if event.modifiers().secondary() {
3947 if event.down.click_count > 1 {
3948 this.split_entry(entry_id, cx);
3949 } else {
3950 this.selection = Some(selection);
3951 if !this.marked_entries.insert(selection) {
3952 this.marked_entries.remove(&selection);
3953 }
3954 }
3955 } else if kind.is_dir() {
3956 this.marked_entries.clear();
3957 if event.modifiers().alt {
3958 this.toggle_expand_all(entry_id, window, cx);
3959 } else {
3960 this.toggle_expanded(entry_id, window, cx);
3961 }
3962 } else {
3963 let preview_tabs_enabled = PreviewTabsSettings::get_global(cx).enabled;
3964 let click_count = event.up.click_count;
3965 let focus_opened_item = !preview_tabs_enabled || click_count > 1;
3966 let allow_preview = preview_tabs_enabled && click_count == 1;
3967 this.open_entry(entry_id, focus_opened_item, allow_preview, cx);
3968 }
3969 }),
3970 )
3971 .child(
3972 ListItem::new(entry_id.to_proto() as usize)
3973 .indent_level(depth)
3974 .indent_step_size(px(settings.indent_size))
3975 .spacing(match settings.entry_spacing {
3976 project_panel_settings::EntrySpacing::Comfortable => ListItemSpacing::Dense,
3977 project_panel_settings::EntrySpacing::Standard => {
3978 ListItemSpacing::ExtraDense
3979 }
3980 })
3981 .selectable(false)
3982 .when_some(canonical_path, |this, path| {
3983 this.end_slot::<AnyElement>(
3984 div()
3985 .id("symlink_icon")
3986 .pr_3()
3987 .tooltip(move |window, cx| {
3988 Tooltip::with_meta(
3989 path.to_string(),
3990 None,
3991 "Symbolic Link",
3992 window,
3993 cx,
3994 )
3995 })
3996 .child(
3997 Icon::new(IconName::ArrowUpRight)
3998 .size(IconSize::Indicator)
3999 .color(filename_text_color),
4000 )
4001 .into_any_element(),
4002 )
4003 })
4004 .child(if let Some(icon) = &icon {
4005 if let Some((_, decoration_color)) =
4006 entry_diagnostic_aware_icon_decoration_and_color(diagnostic_severity)
4007 {
4008 let is_warning = diagnostic_severity
4009 .map(|severity| matches!(severity, DiagnosticSeverity::WARNING))
4010 .unwrap_or(false);
4011 div().child(
4012 DecoratedIcon::new(
4013 Icon::from_path(icon.clone()).color(Color::Muted),
4014 Some(
4015 IconDecoration::new(
4016 if kind.is_file() {
4017 if is_warning {
4018 IconDecorationKind::Triangle
4019 } else {
4020 IconDecorationKind::X
4021 }
4022 } else {
4023 IconDecorationKind::Dot
4024 },
4025 bg_color,
4026 cx,
4027 )
4028 .group_name(Some(GROUP_NAME.into()))
4029 .knockout_hover_color(bg_hover_color)
4030 .color(decoration_color.color(cx))
4031 .position(Point {
4032 x: px(-2.),
4033 y: px(-2.),
4034 }),
4035 ),
4036 )
4037 .into_any_element(),
4038 )
4039 } else {
4040 h_flex().child(Icon::from_path(icon.to_string()).color(Color::Muted))
4041 }
4042 } else {
4043 if let Some((icon_name, color)) =
4044 entry_diagnostic_aware_icon_name_and_color(diagnostic_severity)
4045 {
4046 h_flex()
4047 .size(IconSize::default().rems())
4048 .child(Icon::new(icon_name).color(color).size(IconSize::Small))
4049 } else {
4050 h_flex()
4051 .size(IconSize::default().rems())
4052 .invisible()
4053 .flex_none()
4054 }
4055 })
4056 .child(
4057 if let (Some(editor), true) = (Some(&self.filename_editor), show_editor) {
4058 h_flex().h_6().w_full().child(editor.clone())
4059 } else {
4060 h_flex().h_6().map(|mut this| {
4061 if let Some(folded_ancestors) = self.ancestors.get(&entry_id) {
4062 let components = Path::new(&file_name)
4063 .components()
4064 .map(|comp| {
4065 let comp_str =
4066 comp.as_os_str().to_string_lossy().into_owned();
4067 comp_str
4068 })
4069 .collect::<Vec<_>>();
4070
4071 let components_len = components.len();
4072 let active_index = components_len
4073 - 1
4074 - folded_ancestors.current_ancestor_depth;
4075 const DELIMITER: SharedString =
4076 SharedString::new_static(std::path::MAIN_SEPARATOR_STR);
4077 for (index, component) in components.into_iter().enumerate() {
4078 if index != 0 {
4079 let delimiter_target_index = index - 1;
4080 let target_entry_id = folded_ancestors.ancestors.get(components_len - 1 - delimiter_target_index).cloned();
4081 this = this.child(
4082 div()
4083 .on_drop(cx.listener(move |this, selections: &DraggedSelection, window, cx| {
4084 this.hover_scroll_task.take();
4085 this.folded_directory_drag_target = None;
4086 if let Some(target_entry_id) = target_entry_id {
4087 this.drag_onto(selections, target_entry_id, kind.is_file(), window, cx);
4088 }
4089 }))
4090 .on_drag_move(cx.listener(
4091 move |this, event: &DragMoveEvent<DraggedSelection>, _, _| {
4092 if event.bounds.contains(&event.event.position) {
4093 this.folded_directory_drag_target = Some(
4094 FoldedDirectoryDragTarget {
4095 entry_id,
4096 index: delimiter_target_index,
4097 is_delimiter_target: true,
4098 }
4099 );
4100 } else {
4101 let is_current_target = this.folded_directory_drag_target
4102 .map_or(false, |target|
4103 target.entry_id == entry_id &&
4104 target.index == delimiter_target_index &&
4105 target.is_delimiter_target
4106 );
4107 if is_current_target {
4108 this.folded_directory_drag_target = None;
4109 }
4110 }
4111
4112 },
4113 ))
4114 .child(
4115 Label::new(DELIMITER.clone())
4116 .single_line()
4117 .color(filename_text_color)
4118 )
4119 );
4120 }
4121 let id = SharedString::from(format!(
4122 "project_panel_path_component_{}_{index}",
4123 entry_id.to_usize()
4124 ));
4125 let label = div()
4126 .id(id)
4127 .on_click(cx.listener(move |this, _, _, cx| {
4128 if index != active_index {
4129 if let Some(folds) =
4130 this.ancestors.get_mut(&entry_id)
4131 {
4132 folds.current_ancestor_depth =
4133 components_len - 1 - index;
4134 cx.notify();
4135 }
4136 }
4137 }))
4138 .when(index != components_len - 1, |div|{
4139 let target_entry_id = folded_ancestors.ancestors.get(components_len - 1 - index).cloned();
4140 div
4141 .on_drag_move(cx.listener(
4142 move |this, event: &DragMoveEvent<DraggedSelection>, _, _| {
4143 if event.bounds.contains(&event.event.position) {
4144 this.folded_directory_drag_target = Some(
4145 FoldedDirectoryDragTarget {
4146 entry_id,
4147 index,
4148 is_delimiter_target: false,
4149 }
4150 );
4151 } else {
4152 let is_current_target = this.folded_directory_drag_target
4153 .as_ref()
4154 .map_or(false, |target|
4155 target.entry_id == entry_id &&
4156 target.index == index &&
4157 !target.is_delimiter_target
4158 );
4159 if is_current_target {
4160 this.folded_directory_drag_target = None;
4161 }
4162 }
4163 },
4164 ))
4165 .on_drop(cx.listener(move |this, selections: &DraggedSelection, window,cx| {
4166 this.hover_scroll_task.take();
4167 this.folded_directory_drag_target = None;
4168 if let Some(target_entry_id) = target_entry_id {
4169 this.drag_onto(selections, target_entry_id, kind.is_file(), window, cx);
4170 }
4171 }))
4172 .when(folded_directory_drag_target.map_or(false, |target|
4173 target.entry_id == entry_id &&
4174 target.index == index
4175 ), |this| {
4176 this.bg(item_colors.drag_over)
4177 })
4178 })
4179 .child(
4180 Label::new(component)
4181 .single_line()
4182 .color(filename_text_color)
4183 .when(
4184 index == active_index
4185 && (is_active || is_marked),
4186 |this| this.underline(),
4187 ),
4188 );
4189
4190 this = this.child(label);
4191 }
4192
4193 this
4194 } else {
4195 this.child(
4196 Label::new(file_name)
4197 .single_line()
4198 .color(filename_text_color),
4199 )
4200 }
4201 })
4202 }
4203 .ml_1(),
4204 )
4205 .on_secondary_mouse_down(cx.listener(
4206 move |this, event: &MouseDownEvent, window, cx| {
4207 // Stop propagation to prevent the catch-all context menu for the project
4208 // panel from being deployed.
4209 cx.stop_propagation();
4210 // Some context menu actions apply to all marked entries. If the user
4211 // right-clicks on an entry that is not marked, they may not realize the
4212 // action applies to multiple entries. To avoid inadvertent changes, all
4213 // entries are unmarked.
4214 if !this.marked_entries.contains(&selection) {
4215 this.marked_entries.clear();
4216 }
4217 this.deploy_context_menu(event.position, entry_id, window, cx);
4218 },
4219 ))
4220 .overflow_x(),
4221 )
4222 .when_some(
4223 validation_color_and_message,
4224 |this, (color, message)| {
4225 this
4226 .relative()
4227 .child(
4228 deferred(
4229 div()
4230 .occlude()
4231 .absolute()
4232 .top_full()
4233 .left(px(-1.)) // Used px over rem so that it doesn't change with font size
4234 .right(px(-0.5))
4235 .py_1()
4236 .px_2()
4237 .border_1()
4238 .border_color(color)
4239 .bg(cx.theme().colors().background)
4240 .child(
4241 Label::new(message)
4242 .color(Color::from(color))
4243 .size(LabelSize::Small)
4244 )
4245 )
4246 )
4247 }
4248 )
4249 }
4250
4251 fn render_vertical_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
4252 if !Self::should_show_scrollbar(cx)
4253 || !(self.show_scrollbar || self.vertical_scrollbar_state.is_dragging())
4254 {
4255 return None;
4256 }
4257 Some(
4258 div()
4259 .occlude()
4260 .id("project-panel-vertical-scroll")
4261 .on_mouse_move(cx.listener(|_, _, _, cx| {
4262 cx.notify();
4263 cx.stop_propagation()
4264 }))
4265 .on_hover(|_, _, cx| {
4266 cx.stop_propagation();
4267 })
4268 .on_any_mouse_down(|_, _, cx| {
4269 cx.stop_propagation();
4270 })
4271 .on_mouse_up(
4272 MouseButton::Left,
4273 cx.listener(|this, _, window, cx| {
4274 if !this.vertical_scrollbar_state.is_dragging()
4275 && !this.focus_handle.contains_focused(window, cx)
4276 {
4277 this.hide_scrollbar(window, cx);
4278 cx.notify();
4279 }
4280
4281 cx.stop_propagation();
4282 }),
4283 )
4284 .on_scroll_wheel(cx.listener(|_, _, _, cx| {
4285 cx.notify();
4286 }))
4287 .h_full()
4288 .absolute()
4289 .right_1()
4290 .top_1()
4291 .bottom_1()
4292 .w(px(12.))
4293 .cursor_default()
4294 .children(Scrollbar::vertical(
4295 // percentage as f32..end_offset as f32,
4296 self.vertical_scrollbar_state.clone(),
4297 )),
4298 )
4299 }
4300
4301 fn render_horizontal_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
4302 if !Self::should_show_scrollbar(cx)
4303 || !(self.show_scrollbar || self.horizontal_scrollbar_state.is_dragging())
4304 {
4305 return None;
4306 }
4307
4308 let scroll_handle = self.scroll_handle.0.borrow();
4309 let longest_item_width = scroll_handle
4310 .last_item_size
4311 .filter(|size| size.contents.width > size.item.width)?
4312 .contents
4313 .width
4314 .0 as f64;
4315 if longest_item_width < scroll_handle.base_handle.bounds().size.width.0 as f64 {
4316 return None;
4317 }
4318
4319 Some(
4320 div()
4321 .occlude()
4322 .id("project-panel-horizontal-scroll")
4323 .on_mouse_move(cx.listener(|_, _, _, cx| {
4324 cx.notify();
4325 cx.stop_propagation()
4326 }))
4327 .on_hover(|_, _, cx| {
4328 cx.stop_propagation();
4329 })
4330 .on_any_mouse_down(|_, _, cx| {
4331 cx.stop_propagation();
4332 })
4333 .on_mouse_up(
4334 MouseButton::Left,
4335 cx.listener(|this, _, window, cx| {
4336 if !this.horizontal_scrollbar_state.is_dragging()
4337 && !this.focus_handle.contains_focused(window, cx)
4338 {
4339 this.hide_scrollbar(window, cx);
4340 cx.notify();
4341 }
4342
4343 cx.stop_propagation();
4344 }),
4345 )
4346 .on_scroll_wheel(cx.listener(|_, _, _, cx| {
4347 cx.notify();
4348 }))
4349 .w_full()
4350 .absolute()
4351 .right_1()
4352 .left_1()
4353 .bottom_1()
4354 .h(px(12.))
4355 .cursor_default()
4356 .when(self.width.is_some(), |this| {
4357 this.children(Scrollbar::horizontal(
4358 self.horizontal_scrollbar_state.clone(),
4359 ))
4360 }),
4361 )
4362 }
4363
4364 fn dispatch_context(&self, window: &Window, cx: &Context<Self>) -> KeyContext {
4365 let mut dispatch_context = KeyContext::new_with_defaults();
4366 dispatch_context.add("ProjectPanel");
4367 dispatch_context.add("menu");
4368
4369 let identifier = if self.filename_editor.focus_handle(cx).is_focused(window) {
4370 "editing"
4371 } else {
4372 "not_editing"
4373 };
4374
4375 dispatch_context.add(identifier);
4376 dispatch_context
4377 }
4378
4379 fn should_show_scrollbar(cx: &App) -> bool {
4380 let show = ProjectPanelSettings::get_global(cx)
4381 .scrollbar
4382 .show
4383 .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show);
4384 match show {
4385 ShowScrollbar::Auto => true,
4386 ShowScrollbar::System => true,
4387 ShowScrollbar::Always => true,
4388 ShowScrollbar::Never => false,
4389 }
4390 }
4391
4392 fn should_autohide_scrollbar(cx: &App) -> bool {
4393 let show = ProjectPanelSettings::get_global(cx)
4394 .scrollbar
4395 .show
4396 .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show);
4397 match show {
4398 ShowScrollbar::Auto => true,
4399 ShowScrollbar::System => cx
4400 .try_global::<ScrollbarAutoHide>()
4401 .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0),
4402 ShowScrollbar::Always => false,
4403 ShowScrollbar::Never => true,
4404 }
4405 }
4406
4407 fn hide_scrollbar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
4408 const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
4409 if !Self::should_autohide_scrollbar(cx) {
4410 return;
4411 }
4412 self.hide_scrollbar_task = Some(cx.spawn_in(window, async move |panel, cx| {
4413 cx.background_executor()
4414 .timer(SCROLLBAR_SHOW_INTERVAL)
4415 .await;
4416 panel
4417 .update(cx, |panel, cx| {
4418 panel.show_scrollbar = false;
4419 cx.notify();
4420 })
4421 .log_err();
4422 }))
4423 }
4424
4425 fn reveal_entry(
4426 &mut self,
4427 project: Entity<Project>,
4428 entry_id: ProjectEntryId,
4429 skip_ignored: bool,
4430 cx: &mut Context<Self>,
4431 ) -> Result<()> {
4432 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
4433 let worktree = worktree.read(cx);
4434 if skip_ignored
4435 && worktree
4436 .entry_for_id(entry_id)
4437 .map_or(true, |entry| entry.is_ignored && !entry.is_always_included)
4438 {
4439 return Err(anyhow!(
4440 "can't reveal an ignored entry in the project panel"
4441 ));
4442 }
4443
4444 let worktree_id = worktree.id();
4445 self.expand_entry(worktree_id, entry_id, cx);
4446 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
4447 self.marked_entries.clear();
4448 self.marked_entries.insert(SelectedEntry {
4449 worktree_id,
4450 entry_id,
4451 });
4452 self.autoscroll(cx);
4453 cx.notify();
4454 Ok(())
4455 } else {
4456 Err(anyhow!(
4457 "can't reveal a non-existent entry in the project panel"
4458 ))
4459 }
4460 }
4461
4462 fn find_active_indent_guide(
4463 &self,
4464 indent_guides: &[IndentGuideLayout],
4465 cx: &App,
4466 ) -> Option<usize> {
4467 let (worktree, entry) = self.selected_entry(cx)?;
4468
4469 // Find the parent entry of the indent guide, this will either be the
4470 // expanded folder we have selected, or the parent of the currently
4471 // selected file/collapsed directory
4472 let mut entry = entry;
4473 loop {
4474 let is_expanded_dir = entry.is_dir()
4475 && self
4476 .expanded_dir_ids
4477 .get(&worktree.id())
4478 .map(|ids| ids.binary_search(&entry.id).is_ok())
4479 .unwrap_or(false);
4480 if is_expanded_dir {
4481 break;
4482 }
4483 entry = worktree.entry_for_path(&entry.path.parent()?)?;
4484 }
4485
4486 let (active_indent_range, depth) = {
4487 let (worktree_ix, child_offset, ix) = self.index_for_entry(entry.id, worktree.id())?;
4488 let child_paths = &self.visible_entries[worktree_ix].1;
4489 let mut child_count = 0;
4490 let depth = entry.path.ancestors().count();
4491 while let Some(entry) = child_paths.get(child_offset + child_count + 1) {
4492 if entry.path.ancestors().count() <= depth {
4493 break;
4494 }
4495 child_count += 1;
4496 }
4497
4498 let start = ix + 1;
4499 let end = start + child_count;
4500
4501 let (_, entries, paths) = &self.visible_entries[worktree_ix];
4502 let visible_worktree_entries =
4503 paths.get_or_init(|| entries.iter().map(|e| (e.path.clone())).collect());
4504
4505 // Calculate the actual depth of the entry, taking into account that directories can be auto-folded.
4506 let (depth, _) = Self::calculate_depth_and_difference(entry, visible_worktree_entries);
4507 (start..end, depth)
4508 };
4509
4510 let candidates = indent_guides
4511 .iter()
4512 .enumerate()
4513 .filter(|(_, indent_guide)| indent_guide.offset.x == depth);
4514
4515 for (i, indent) in candidates {
4516 // Find matches that are either an exact match, partially on screen, or inside the enclosing indent
4517 if active_indent_range.start <= indent.offset.y + indent.length
4518 && indent.offset.y <= active_indent_range.end
4519 {
4520 return Some(i);
4521 }
4522 }
4523 None
4524 }
4525}
4526
4527fn item_width_estimate(depth: usize, item_text_chars: usize, is_symlink: bool) -> usize {
4528 const ICON_SIZE_FACTOR: usize = 2;
4529 let mut item_width = depth * ICON_SIZE_FACTOR + item_text_chars;
4530 if is_symlink {
4531 item_width += ICON_SIZE_FACTOR;
4532 }
4533 item_width
4534}
4535
4536impl Render for ProjectPanel {
4537 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
4538 let has_worktree = !self.visible_entries.is_empty();
4539 let project = self.project.read(cx);
4540 let indent_size = ProjectPanelSettings::get_global(cx).indent_size;
4541 let show_indent_guides =
4542 ProjectPanelSettings::get_global(cx).indent_guides.show == ShowIndentGuides::Always;
4543 let is_local = project.is_local();
4544
4545 if has_worktree {
4546 let item_count = self
4547 .visible_entries
4548 .iter()
4549 .map(|(_, worktree_entries, _)| worktree_entries.len())
4550 .sum();
4551
4552 fn handle_drag_move_scroll<T: 'static>(
4553 this: &mut ProjectPanel,
4554 e: &DragMoveEvent<T>,
4555 window: &mut Window,
4556 cx: &mut Context<ProjectPanel>,
4557 ) {
4558 if !e.bounds.contains(&e.event.position) {
4559 return;
4560 }
4561 this.hover_scroll_task.take();
4562 let panel_height = e.bounds.size.height;
4563 if panel_height <= px(0.) {
4564 return;
4565 }
4566
4567 let event_offset = e.event.position.y - e.bounds.origin.y;
4568 // How far along in the project panel is our cursor? (0. is the top of a list, 1. is the bottom)
4569 let hovered_region_offset = event_offset / panel_height;
4570
4571 // We want the scrolling to be a bit faster when the cursor is closer to the edge of a list.
4572 // These pixels offsets were picked arbitrarily.
4573 let vertical_scroll_offset = if hovered_region_offset <= 0.05 {
4574 8.
4575 } else if hovered_region_offset <= 0.15 {
4576 5.
4577 } else if hovered_region_offset >= 0.95 {
4578 -8.
4579 } else if hovered_region_offset >= 0.85 {
4580 -5.
4581 } else {
4582 return;
4583 };
4584 let adjustment = point(px(0.), px(vertical_scroll_offset));
4585 this.hover_scroll_task = Some(cx.spawn_in(window, async move |this, cx| {
4586 loop {
4587 let should_stop_scrolling = this
4588 .update(cx, |this, cx| {
4589 this.hover_scroll_task.as_ref()?;
4590 let handle = this.scroll_handle.0.borrow_mut();
4591 let offset = handle.base_handle.offset();
4592
4593 handle.base_handle.set_offset(offset + adjustment);
4594 cx.notify();
4595 Some(())
4596 })
4597 .ok()
4598 .flatten()
4599 .is_some();
4600 if should_stop_scrolling {
4601 return;
4602 }
4603 cx.background_executor()
4604 .timer(Duration::from_millis(16))
4605 .await;
4606 }
4607 }));
4608 }
4609 h_flex()
4610 .id("project-panel")
4611 .group("project-panel")
4612 .on_drag_move(cx.listener(handle_drag_move_scroll::<ExternalPaths>))
4613 .on_drag_move(cx.listener(handle_drag_move_scroll::<DraggedSelection>))
4614 .size_full()
4615 .relative()
4616 .on_hover(cx.listener(|this, hovered, window, cx| {
4617 if *hovered {
4618 this.show_scrollbar = true;
4619 this.hide_scrollbar_task.take();
4620 cx.notify();
4621 } else if !this.focus_handle.contains_focused(window, cx) {
4622 this.hide_scrollbar(window, cx);
4623 }
4624 }))
4625 .on_click(cx.listener(|this, _event, _, cx| {
4626 cx.stop_propagation();
4627 this.selection = None;
4628 this.marked_entries.clear();
4629 }))
4630 .key_context(self.dispatch_context(window, cx))
4631 .on_action(cx.listener(Self::select_next))
4632 .on_action(cx.listener(Self::select_previous))
4633 .on_action(cx.listener(Self::select_first))
4634 .on_action(cx.listener(Self::select_last))
4635 .on_action(cx.listener(Self::select_parent))
4636 .on_action(cx.listener(Self::select_next_git_entry))
4637 .on_action(cx.listener(Self::select_prev_git_entry))
4638 .on_action(cx.listener(Self::select_next_diagnostic))
4639 .on_action(cx.listener(Self::select_prev_diagnostic))
4640 .on_action(cx.listener(Self::select_next_directory))
4641 .on_action(cx.listener(Self::select_prev_directory))
4642 .on_action(cx.listener(Self::expand_selected_entry))
4643 .on_action(cx.listener(Self::collapse_selected_entry))
4644 .on_action(cx.listener(Self::collapse_all_entries))
4645 .on_action(cx.listener(Self::open))
4646 .on_action(cx.listener(Self::open_permanent))
4647 .on_action(cx.listener(Self::confirm))
4648 .on_action(cx.listener(Self::cancel))
4649 .on_action(cx.listener(Self::copy_path))
4650 .on_action(cx.listener(Self::copy_relative_path))
4651 .on_action(cx.listener(Self::new_search_in_directory))
4652 .on_action(cx.listener(Self::unfold_directory))
4653 .on_action(cx.listener(Self::fold_directory))
4654 .on_action(cx.listener(Self::remove_from_project))
4655 .when(!project.is_read_only(cx), |el| {
4656 el.on_action(cx.listener(Self::new_file))
4657 .on_action(cx.listener(Self::new_directory))
4658 .on_action(cx.listener(Self::rename))
4659 .on_action(cx.listener(Self::delete))
4660 .on_action(cx.listener(Self::trash))
4661 .on_action(cx.listener(Self::cut))
4662 .on_action(cx.listener(Self::copy))
4663 .on_action(cx.listener(Self::paste))
4664 .on_action(cx.listener(Self::duplicate))
4665 .on_click(cx.listener(|this, event: &gpui::ClickEvent, window, cx| {
4666 if event.up.click_count > 1 {
4667 if let Some(entry_id) = this.last_worktree_root_id {
4668 let project = this.project.read(cx);
4669
4670 let worktree_id = if let Some(worktree) =
4671 project.worktree_for_entry(entry_id, cx)
4672 {
4673 worktree.read(cx).id()
4674 } else {
4675 return;
4676 };
4677
4678 this.selection = Some(SelectedEntry {
4679 worktree_id,
4680 entry_id,
4681 });
4682
4683 this.new_file(&NewFile, window, cx);
4684 }
4685 }
4686 }))
4687 })
4688 .when(project.is_local(), |el| {
4689 el.on_action(cx.listener(Self::reveal_in_finder))
4690 .on_action(cx.listener(Self::open_system))
4691 .on_action(cx.listener(Self::open_in_terminal))
4692 })
4693 .when(project.is_via_ssh(), |el| {
4694 el.on_action(cx.listener(Self::open_in_terminal))
4695 })
4696 .on_mouse_down(
4697 MouseButton::Right,
4698 cx.listener(move |this, event: &MouseDownEvent, window, cx| {
4699 // When deploying the context menu anywhere below the last project entry,
4700 // act as if the user clicked the root of the last worktree.
4701 if let Some(entry_id) = this.last_worktree_root_id {
4702 this.deploy_context_menu(event.position, entry_id, window, cx);
4703 }
4704 }),
4705 )
4706 .track_focus(&self.focus_handle(cx))
4707 .child(
4708 uniform_list(cx.entity().clone(), "entries", item_count, {
4709 |this, range, window, cx| {
4710 let mut items = Vec::with_capacity(range.end - range.start);
4711 this.for_each_visible_entry(
4712 range,
4713 window,
4714 cx,
4715 |id, details, window, cx| {
4716 items.push(this.render_entry(id, details, window, cx));
4717 },
4718 );
4719 items
4720 }
4721 })
4722 .when(show_indent_guides, |list| {
4723 list.with_decoration(
4724 ui::indent_guides(
4725 cx.entity().clone(),
4726 px(indent_size),
4727 IndentGuideColors::panel(cx),
4728 |this, range, window, cx| {
4729 let mut items =
4730 SmallVec::with_capacity(range.end - range.start);
4731 this.iter_visible_entries(
4732 range,
4733 window,
4734 cx,
4735 |entry, entries, _, _| {
4736 let (depth, _) = Self::calculate_depth_and_difference(
4737 entry, entries,
4738 );
4739 items.push(depth);
4740 },
4741 );
4742 items
4743 },
4744 )
4745 .on_click(cx.listener(
4746 |this, active_indent_guide: &IndentGuideLayout, window, cx| {
4747 if window.modifiers().secondary() {
4748 let ix = active_indent_guide.offset.y;
4749 let Some((target_entry, worktree)) = maybe!({
4750 let (worktree_id, entry) = this.entry_at_index(ix)?;
4751 let worktree = this
4752 .project
4753 .read(cx)
4754 .worktree_for_id(worktree_id, cx)?;
4755 let target_entry = worktree
4756 .read(cx)
4757 .entry_for_path(&entry.path.parent()?)?;
4758 Some((target_entry, worktree))
4759 }) else {
4760 return;
4761 };
4762
4763 this.collapse_entry(target_entry.clone(), worktree, cx);
4764 }
4765 },
4766 ))
4767 .with_render_fn(
4768 cx.entity().clone(),
4769 move |this, params, _, cx| {
4770 const LEFT_OFFSET: Pixels = px(14.);
4771 const PADDING_Y: Pixels = px(4.);
4772 const HITBOX_OVERDRAW: Pixels = px(3.);
4773
4774 let active_indent_guide_index =
4775 this.find_active_indent_guide(¶ms.indent_guides, cx);
4776
4777 let indent_size = params.indent_size;
4778 let item_height = params.item_height;
4779
4780 params
4781 .indent_guides
4782 .into_iter()
4783 .enumerate()
4784 .map(|(idx, layout)| {
4785 let offset = if layout.continues_offscreen {
4786 px(0.)
4787 } else {
4788 PADDING_Y
4789 };
4790 let bounds = Bounds::new(
4791 point(
4792 layout.offset.x * indent_size + LEFT_OFFSET,
4793 layout.offset.y * item_height + offset,
4794 ),
4795 size(
4796 px(1.),
4797 layout.length * item_height - offset * 2.,
4798 ),
4799 );
4800 ui::RenderedIndentGuide {
4801 bounds,
4802 layout,
4803 is_active: Some(idx) == active_indent_guide_index,
4804 hitbox: Some(Bounds::new(
4805 point(
4806 bounds.origin.x - HITBOX_OVERDRAW,
4807 bounds.origin.y,
4808 ),
4809 size(
4810 bounds.size.width + HITBOX_OVERDRAW * 2.,
4811 bounds.size.height,
4812 ),
4813 )),
4814 }
4815 })
4816 .collect()
4817 },
4818 ),
4819 )
4820 })
4821 .size_full()
4822 .with_sizing_behavior(ListSizingBehavior::Infer)
4823 .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
4824 .with_width_from_item(self.max_width_item_index)
4825 .track_scroll(self.scroll_handle.clone()),
4826 )
4827 .children(self.render_vertical_scrollbar(cx))
4828 .when_some(self.render_horizontal_scrollbar(cx), |this, scrollbar| {
4829 this.pb_4().child(scrollbar)
4830 })
4831 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
4832 deferred(
4833 anchored()
4834 .position(*position)
4835 .anchor(gpui::Corner::TopLeft)
4836 .child(menu.clone()),
4837 )
4838 .with_priority(1)
4839 }))
4840 } else {
4841 v_flex()
4842 .id("empty-project_panel")
4843 .size_full()
4844 .p_4()
4845 .track_focus(&self.focus_handle(cx))
4846 .child(
4847 Button::new("open_project", "Open a project")
4848 .full_width()
4849 .key_binding(KeyBinding::for_action(&workspace::Open, window, cx))
4850 .on_click(cx.listener(|this, _, window, cx| {
4851 this.workspace
4852 .update(cx, |_, cx| {
4853 window.dispatch_action(Box::new(workspace::Open), cx)
4854 })
4855 .log_err();
4856 })),
4857 )
4858 .when(is_local, |div| {
4859 div.drag_over::<ExternalPaths>(|style, _, _, cx| {
4860 style.bg(cx.theme().colors().drop_target_background)
4861 })
4862 .on_drop(cx.listener(
4863 move |this, external_paths: &ExternalPaths, window, cx| {
4864 this.last_external_paths_drag_over_entry = None;
4865 this.marked_entries.clear();
4866 this.hover_scroll_task.take();
4867 if let Some(task) = this
4868 .workspace
4869 .update(cx, |workspace, cx| {
4870 workspace.open_workspace_for_paths(
4871 true,
4872 external_paths.paths().to_owned(),
4873 window,
4874 cx,
4875 )
4876 })
4877 .log_err()
4878 {
4879 task.detach_and_log_err(cx);
4880 }
4881 cx.stop_propagation();
4882 },
4883 ))
4884 })
4885 }
4886 }
4887}
4888
4889impl Render for DraggedProjectEntryView {
4890 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
4891 let ui_font = ThemeSettings::get_global(cx).ui_font.clone();
4892 h_flex()
4893 .font(ui_font)
4894 .pl(self.click_offset.x + px(12.))
4895 .pt(self.click_offset.y + px(12.))
4896 .child(
4897 div()
4898 .flex()
4899 .gap_1()
4900 .items_center()
4901 .py_1()
4902 .px_2()
4903 .rounded_lg()
4904 .bg(cx.theme().colors().background)
4905 .map(|this| {
4906 if self.selections.len() > 1 && self.selections.contains(&self.selection) {
4907 this.child(Label::new(format!("{} entries", self.selections.len())))
4908 } else {
4909 this.child(if let Some(icon) = &self.details.icon {
4910 div().child(Icon::from_path(icon.clone()))
4911 } else {
4912 div()
4913 })
4914 .child(Label::new(self.details.filename.clone()))
4915 }
4916 }),
4917 )
4918 }
4919}
4920
4921impl EventEmitter<Event> for ProjectPanel {}
4922
4923impl EventEmitter<PanelEvent> for ProjectPanel {}
4924
4925impl Panel for ProjectPanel {
4926 fn position(&self, _: &Window, cx: &App) -> DockPosition {
4927 match ProjectPanelSettings::get_global(cx).dock {
4928 ProjectPanelDockPosition::Left => DockPosition::Left,
4929 ProjectPanelDockPosition::Right => DockPosition::Right,
4930 }
4931 }
4932
4933 fn position_is_valid(&self, position: DockPosition) -> bool {
4934 matches!(position, DockPosition::Left | DockPosition::Right)
4935 }
4936
4937 fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
4938 settings::update_settings_file::<ProjectPanelSettings>(
4939 self.fs.clone(),
4940 cx,
4941 move |settings, _| {
4942 let dock = match position {
4943 DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
4944 DockPosition::Right => ProjectPanelDockPosition::Right,
4945 };
4946 settings.dock = Some(dock);
4947 },
4948 );
4949 }
4950
4951 fn size(&self, _: &Window, cx: &App) -> Pixels {
4952 self.width
4953 .unwrap_or_else(|| ProjectPanelSettings::get_global(cx).default_width)
4954 }
4955
4956 fn set_size(&mut self, size: Option<Pixels>, _: &mut Window, cx: &mut Context<Self>) {
4957 self.width = size;
4958 self.serialize(cx);
4959 cx.notify();
4960 }
4961
4962 fn icon(&self, _: &Window, cx: &App) -> Option<IconName> {
4963 ProjectPanelSettings::get_global(cx)
4964 .button
4965 .then_some(IconName::FileTree)
4966 }
4967
4968 fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
4969 Some("Project Panel")
4970 }
4971
4972 fn toggle_action(&self) -> Box<dyn Action> {
4973 Box::new(ToggleFocus)
4974 }
4975
4976 fn persistent_name() -> &'static str {
4977 "Project Panel"
4978 }
4979
4980 fn starts_open(&self, _: &Window, cx: &App) -> bool {
4981 let project = &self.project.read(cx);
4982 project.visible_worktrees(cx).any(|tree| {
4983 tree.read(cx)
4984 .root_entry()
4985 .map_or(false, |entry| entry.is_dir())
4986 })
4987 }
4988
4989 fn activation_priority(&self) -> u32 {
4990 0
4991 }
4992}
4993
4994impl Focusable for ProjectPanel {
4995 fn focus_handle(&self, _cx: &App) -> FocusHandle {
4996 self.focus_handle.clone()
4997 }
4998}
4999
5000impl ClipboardEntry {
5001 fn is_cut(&self) -> bool {
5002 matches!(self, Self::Cut { .. })
5003 }
5004
5005 fn items(&self) -> &BTreeSet<SelectedEntry> {
5006 match self {
5007 ClipboardEntry::Copied(entries) | ClipboardEntry::Cut(entries) => entries,
5008 }
5009 }
5010}
5011
5012#[cfg(test)]
5013mod project_panel_tests;