1pub mod project_panel_settings;
2mod undo;
3mod utils;
4
5use anyhow::{Context as _, Result};
6use client::{ErrorCode, ErrorExt};
7use collections::{BTreeSet, HashMap, hash_map};
8use command_palette_hooks::CommandPaletteFilter;
9use editor::{
10 Editor, EditorEvent, MultiBufferOffset,
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};
16use feature_flags::{FeatureFlagAppExt, ProjectPanelUndoRedoFeatureFlag};
17use file_icons::FileIcons;
18use git;
19use git::status::GitSummary;
20use git_ui;
21use git_ui::file_diff_view::FileDiffView;
22use gpui::{
23 Action, AnyElement, App, AsyncWindowContext, Bounds, ClipboardEntry as GpuiClipboardEntry,
24 ClipboardItem, Context, CursorStyle, DismissEvent, Div, DragMoveEvent, Entity, EventEmitter,
25 ExternalPaths, FocusHandle, Focusable, FontWeight, Hsla, InteractiveElement, KeyContext,
26 ListHorizontalSizingBehavior, ListSizingBehavior, Modifiers, ModifiersChangedEvent,
27 MouseButton, MouseDownEvent, ParentElement, PathPromptOptions, Pixels, Point, PromptLevel,
28 Render, ScrollStrategy, Stateful, Styled, Subscription, Task, UniformListScrollHandle,
29 WeakEntity, Window, actions, anchored, deferred, div, hsla, linear_color_stop, linear_gradient,
30 point, px, size, transparent_white, uniform_list,
31};
32use language::DiagnosticSeverity;
33use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
34use notifications::status_toast::{StatusToast, ToastIcon};
35use project::{
36 Entry, EntryKind, Fs, GitEntry, GitEntryRef, GitTraversal, Project, ProjectEntryId,
37 ProjectPath, Worktree, WorktreeId,
38 git_store::{GitStoreEvent, RepositoryEvent, git_traversal::ChildEntriesGitIter},
39 project_settings::GoToDiagnosticSeverityFilter,
40};
41use project_panel_settings::ProjectPanelSettings;
42use rayon::slice::ParallelSliceMut;
43use schemars::JsonSchema;
44use serde::Deserialize;
45use settings::{
46 DockSide, ProjectPanelEntrySpacing, Settings, SettingsStore, ShowDiagnostics, ShowIndentGuides,
47 update_settings_file,
48};
49use smallvec::SmallVec;
50use std::ops::Neg;
51use std::{any::TypeId, time::Instant};
52use std::{
53 cell::OnceCell,
54 cmp,
55 collections::HashSet,
56 ops::Range,
57 path::{Path, PathBuf},
58 sync::Arc,
59 time::Duration,
60};
61use theme_settings::ThemeSettings;
62use ui::{
63 Color, ContextMenu, ContextMenuEntry, DecoratedIcon, Divider, Icon, IconDecoration,
64 IconDecorationKind, IndentGuideColors, IndentGuideLayout, Indicator, KeyBinding, Label,
65 LabelSize, ListItem, ListItemSpacing, ScrollAxes, ScrollableHandle, Scrollbars,
66 StickyCandidate, Tooltip, WithScrollbar, prelude::*, v_flex,
67};
68use util::{
69 ResultExt, TakeUntilExt, TryFutureExt, maybe,
70 paths::{PathStyle, compare_paths},
71 rel_path::{RelPath, RelPathBuf},
72};
73use workspace::{
74 DraggedSelection, OpenInTerminal, OpenMode, OpenOptions, OpenVisible, PreviewTabsSettings,
75 SelectedEntry, SplitDirection, Workspace,
76 dock::{DockPosition, Panel, PanelEvent},
77 notifications::{DetachAndPromptErr, NotifyResultExt, NotifyTaskExt},
78};
79use worktree::CreatedEntry;
80use zed_actions::{
81 project_panel::{Toggle, ToggleFocus},
82 workspace::OpenWithSystem,
83};
84
85use crate::{
86 project_panel_settings::ProjectPanelScrollbarProxy,
87 undo::{ProjectPanelOperation, UndoManager},
88};
89
90const PROJECT_PANEL_KEY: &str = "ProjectPanel";
91const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
92
93struct VisibleEntriesForWorktree {
94 worktree_id: WorktreeId,
95 entries: Vec<GitEntry>,
96 index: OnceCell<HashSet<Arc<RelPath>>>,
97}
98
99struct State {
100 last_worktree_root_id: Option<ProjectEntryId>,
101 /// Maps from leaf project entry ID to the currently selected ancestor.
102 /// Relevant only for auto-fold dirs, where a single project panel entry may actually consist of several
103 /// project entries (and all non-leaf nodes are guaranteed to be directories).
104 ancestors: HashMap<ProjectEntryId, FoldedAncestors>,
105 visible_entries: Vec<VisibleEntriesForWorktree>,
106 max_width_item_index: Option<usize>,
107 edit_state: Option<EditState>,
108 temporarily_unfolded_pending_state: Option<TemporaryUnfoldedPendingState>,
109 unfolded_dir_ids: HashSet<ProjectEntryId>,
110 expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
111}
112
113impl State {
114 fn is_unfolded(&self, entry_id: &ProjectEntryId) -> bool {
115 self.unfolded_dir_ids.contains(entry_id)
116 || self.edit_state.as_ref().map_or(false, |edit_state| {
117 edit_state.temporarily_unfolded == Some(*entry_id)
118 })
119 }
120
121 fn derive(old: &Self) -> Self {
122 Self {
123 last_worktree_root_id: None,
124 ancestors: Default::default(),
125 visible_entries: Default::default(),
126 max_width_item_index: None,
127 edit_state: old.edit_state.clone(),
128 temporarily_unfolded_pending_state: None,
129 unfolded_dir_ids: old.unfolded_dir_ids.clone(),
130 expanded_dir_ids: old.expanded_dir_ids.clone(),
131 }
132 }
133}
134
135pub struct ProjectPanel {
136 project: Entity<Project>,
137 fs: Arc<dyn Fs>,
138 focus_handle: FocusHandle,
139 scroll_handle: UniformListScrollHandle,
140 // An update loop that keeps incrementing/decrementing scroll offset while there is a dragged entry that's
141 // hovered over the start/end of a list.
142 hover_scroll_task: Option<Task<()>>,
143 rendered_entries_len: usize,
144 folded_directory_drag_target: Option<FoldedDirectoryDragTarget>,
145 drag_target_entry: Option<DragTarget>,
146 marked_entries: Vec<SelectedEntry>,
147 selection: Option<SelectedEntry>,
148 context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
149 filename_editor: Entity<Editor>,
150 clipboard: Option<ClipboardEntry>,
151 _dragged_entry_destination: Option<Arc<Path>>,
152 workspace: WeakEntity<Workspace>,
153 diagnostics: HashMap<(WorktreeId, Arc<RelPath>), DiagnosticSeverity>,
154 diagnostic_counts: HashMap<(WorktreeId, Arc<RelPath>), DiagnosticCount>,
155 diagnostic_summary_update: Task<()>,
156 // We keep track of the mouse down state on entries so we don't flash the UI
157 // in case a user clicks to open a file.
158 mouse_down: bool,
159 hover_expand_task: Option<Task<()>>,
160 previous_drag_position: Option<Point<Pixels>>,
161 sticky_items_count: usize,
162 last_reported_update: Instant,
163 update_visible_entries_task: UpdateVisibleEntriesTask,
164 undo_manager: UndoManager,
165 state: State,
166}
167
168struct UpdateVisibleEntriesTask {
169 _visible_entries_task: Task<()>,
170 focus_filename_editor: bool,
171 autoscroll: bool,
172}
173
174#[derive(Debug)]
175struct TemporaryUnfoldedPendingState {
176 previously_focused_leaf_entry: SelectedEntry,
177 temporarily_unfolded_active_entry_id: ProjectEntryId,
178}
179
180impl Default for UpdateVisibleEntriesTask {
181 fn default() -> Self {
182 UpdateVisibleEntriesTask {
183 _visible_entries_task: Task::ready(()),
184 focus_filename_editor: Default::default(),
185 autoscroll: Default::default(),
186 }
187 }
188}
189
190enum DragTarget {
191 /// Dragging on an entry
192 Entry {
193 /// The entry currently under the mouse cursor during a drag operation
194 entry_id: ProjectEntryId,
195 /// Highlight this entry along with all of its children
196 highlight_entry_id: ProjectEntryId,
197 },
198 /// Dragging on background
199 Background,
200}
201
202#[derive(Copy, Clone, Debug)]
203struct FoldedDirectoryDragTarget {
204 entry_id: ProjectEntryId,
205 index: usize,
206 /// Whether we are dragging over the delimiter rather than the component itself.
207 is_delimiter_target: bool,
208}
209
210#[derive(Clone, Debug)]
211enum ValidationState {
212 None,
213 Warning(String),
214 Error(String),
215}
216
217#[derive(Clone, Debug)]
218struct EditState {
219 worktree_id: WorktreeId,
220 entry_id: ProjectEntryId,
221 leaf_entry_id: Option<ProjectEntryId>,
222 is_dir: bool,
223 depth: usize,
224 processing_filename: Option<Arc<RelPath>>,
225 previously_focused: Option<SelectedEntry>,
226 validation_state: ValidationState,
227 temporarily_unfolded: Option<ProjectEntryId>,
228}
229
230impl EditState {
231 fn is_new_entry(&self) -> bool {
232 self.leaf_entry_id.is_none()
233 }
234}
235
236#[derive(Clone, Debug)]
237enum ClipboardEntry {
238 Copied(BTreeSet<SelectedEntry>),
239 Cut(BTreeSet<SelectedEntry>),
240}
241
242#[derive(Debug, Default, PartialEq, Eq, Clone, Copy)]
243struct DiagnosticCount {
244 error_count: usize,
245 warning_count: usize,
246}
247
248impl DiagnosticCount {
249 fn capped_error_count(&self) -> String {
250 Self::capped_count(self.error_count)
251 }
252
253 fn capped_warning_count(&self) -> String {
254 Self::capped_count(self.warning_count)
255 }
256
257 fn capped_count(count: usize) -> String {
258 if count > 99 {
259 "99+".to_string()
260 } else {
261 count.to_string()
262 }
263 }
264}
265
266#[derive(Debug, PartialEq, Eq, Clone)]
267struct EntryDetails {
268 filename: String,
269 icon: Option<SharedString>,
270 path: Arc<RelPath>,
271 depth: usize,
272 kind: EntryKind,
273 is_ignored: bool,
274 is_expanded: bool,
275 is_selected: bool,
276 is_marked: bool,
277 is_editing: bool,
278 is_processing: bool,
279 is_cut: bool,
280 sticky: Option<StickyDetails>,
281 filename_text_color: Color,
282 diagnostic_severity: Option<DiagnosticSeverity>,
283 diagnostic_count: Option<DiagnosticCount>,
284 git_status: GitSummary,
285 is_private: bool,
286 worktree_id: WorktreeId,
287 canonical_path: Option<Arc<Path>>,
288}
289
290#[derive(Debug, PartialEq, Eq, Clone)]
291struct StickyDetails {
292 sticky_index: usize,
293}
294
295/// Permanently deletes the selected file or directory.
296#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema, Action)]
297#[action(namespace = project_panel)]
298#[serde(deny_unknown_fields)]
299struct Delete {
300 #[serde(default)]
301 pub skip_prompt: bool,
302}
303
304/// Moves the selected file or directory to the system trash.
305#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema, Action)]
306#[action(namespace = project_panel)]
307#[serde(deny_unknown_fields)]
308struct Trash {
309 #[serde(default)]
310 pub skip_prompt: bool,
311}
312
313/// Selects the next entry with diagnostics.
314#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema, Action)]
315#[action(namespace = project_panel)]
316#[serde(deny_unknown_fields)]
317struct SelectNextDiagnostic {
318 #[serde(default)]
319 pub severity: GoToDiagnosticSeverityFilter,
320}
321
322/// Selects the previous entry with diagnostics.
323#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema, Action)]
324#[action(namespace = project_panel)]
325#[serde(deny_unknown_fields)]
326struct SelectPrevDiagnostic {
327 #[serde(default)]
328 pub severity: GoToDiagnosticSeverityFilter,
329}
330
331actions!(
332 project_panel,
333 [
334 /// Expands the selected entry in the project tree.
335 ExpandSelectedEntry,
336 /// Collapses the selected entry in the project tree.
337 CollapseSelectedEntry,
338 /// Collapses the selected entry and its children in the project tree.
339 CollapseSelectedEntryAndChildren,
340 /// Collapses all entries in the project tree.
341 CollapseAllEntries,
342 /// Creates a new directory.
343 NewDirectory,
344 /// Creates a new file.
345 NewFile,
346 /// Copies the selected file or directory.
347 Copy,
348 /// Duplicates the selected file or directory.
349 Duplicate,
350 /// Reveals the selected item in the system file manager.
351 RevealInFileManager,
352 /// Removes the selected folder from the project.
353 RemoveFromProject,
354 /// Cuts the selected file or directory.
355 Cut,
356 /// Pastes the previously cut or copied item.
357 Paste,
358 /// Downloads the selected remote file
359 DownloadFromRemote,
360 /// Renames the selected file or directory.
361 Rename,
362 /// Opens the selected file in the editor.
363 Open,
364 /// Opens the selected file in a permanent tab.
365 OpenPermanent,
366 /// Opens the selected file in a vertical split.
367 OpenSplitVertical,
368 /// Opens the selected file in a horizontal split.
369 OpenSplitHorizontal,
370 /// Toggles visibility of git-ignored files.
371 ToggleHideGitIgnore,
372 /// Toggles visibility of hidden files.
373 ToggleHideHidden,
374 /// Starts a new search in the selected directory.
375 NewSearchInDirectory,
376 /// Unfolds the selected directory.
377 UnfoldDirectory,
378 /// Folds the selected directory.
379 FoldDirectory,
380 /// Scroll half a page upwards
381 ScrollUp,
382 /// Scroll half a page downwards
383 ScrollDown,
384 /// Scroll until the cursor displays at the center
385 ScrollCursorCenter,
386 /// Scroll until the cursor displays at the top
387 ScrollCursorTop,
388 /// Scroll until the cursor displays at the bottom
389 ScrollCursorBottom,
390 /// Selects the parent directory.
391 SelectParent,
392 /// Selects the next entry with git changes.
393 SelectNextGitEntry,
394 /// Selects the previous entry with git changes.
395 SelectPrevGitEntry,
396 /// Selects the next directory.
397 SelectNextDirectory,
398 /// Selects the previous directory.
399 SelectPrevDirectory,
400 /// Opens a diff view to compare two marked files.
401 CompareMarkedFiles,
402 /// Undoes the last file operation.
403 Undo,
404 ]
405);
406
407#[derive(Clone, Debug, Default)]
408struct FoldedAncestors {
409 current_ancestor_depth: usize,
410 ancestors: Vec<ProjectEntryId>,
411}
412
413impl FoldedAncestors {
414 fn max_ancestor_depth(&self) -> usize {
415 self.ancestors.len()
416 }
417
418 /// Note: This returns None for last item in ancestors list
419 fn active_ancestor(&self) -> Option<ProjectEntryId> {
420 if self.current_ancestor_depth == 0 {
421 return None;
422 }
423 self.ancestors.get(self.current_ancestor_depth).copied()
424 }
425
426 fn active_index(&self) -> usize {
427 self.max_ancestor_depth()
428 .saturating_sub(1)
429 .saturating_sub(self.current_ancestor_depth)
430 }
431
432 fn set_active_index(&mut self, index: usize) -> bool {
433 let new_depth = self
434 .max_ancestor_depth()
435 .saturating_sub(1)
436 .saturating_sub(index);
437 if self.current_ancestor_depth != new_depth {
438 self.current_ancestor_depth = new_depth;
439 true
440 } else {
441 false
442 }
443 }
444
445 fn active_component(&self, file_name: &str) -> Option<String> {
446 Path::new(file_name)
447 .components()
448 .nth(self.active_index())
449 .map(|comp| comp.as_os_str().to_string_lossy().into_owned())
450 }
451}
452
453pub fn init(cx: &mut App) {
454 cx.observe_new(|workspace: &mut Workspace, _, _| {
455 workspace.register_action(|workspace, _: &ToggleFocus, window, cx| {
456 workspace.toggle_panel_focus::<ProjectPanel>(window, cx);
457 });
458 workspace.register_action(|workspace, _: &Toggle, window, cx| {
459 if !workspace.toggle_panel_focus::<ProjectPanel>(window, cx) {
460 workspace.close_panel::<ProjectPanel>(window, cx);
461 }
462 });
463
464 workspace.register_action(|workspace, _: &ToggleHideGitIgnore, _, cx| {
465 let fs = workspace.app_state().fs.clone();
466 update_settings_file(fs, cx, move |setting, _| {
467 setting.project_panel.get_or_insert_default().hide_gitignore = Some(
468 !setting
469 .project_panel
470 .get_or_insert_default()
471 .hide_gitignore
472 .unwrap_or(false),
473 );
474 })
475 });
476
477 workspace.register_action(|workspace, _: &ToggleHideHidden, _, cx| {
478 let fs = workspace.app_state().fs.clone();
479 update_settings_file(fs, cx, move |setting, _| {
480 setting.project_panel.get_or_insert_default().hide_hidden = Some(
481 !setting
482 .project_panel
483 .get_or_insert_default()
484 .hide_hidden
485 .unwrap_or(false),
486 );
487 })
488 });
489
490 workspace.register_action(|workspace, action: &CollapseAllEntries, window, cx| {
491 if let Some(panel) = workspace.panel::<ProjectPanel>(cx) {
492 panel.update(cx, |panel, cx| {
493 panel.collapse_all_entries(action, window, cx);
494 });
495 }
496 });
497
498 workspace.register_action(|workspace, action: &Rename, window, cx| {
499 workspace.open_panel::<ProjectPanel>(window, cx);
500 if let Some(panel) = workspace.panel::<ProjectPanel>(cx) {
501 panel.update(cx, |panel, cx| {
502 if let Some(first_marked) = panel.marked_entries.first() {
503 let first_marked = *first_marked;
504 panel.marked_entries.clear();
505 panel.selection = Some(first_marked);
506 }
507 panel.rename(action, window, cx);
508 });
509 }
510 });
511
512 workspace.register_action(|workspace, action: &Duplicate, window, cx| {
513 workspace.open_panel::<ProjectPanel>(window, cx);
514 if let Some(panel) = workspace.panel::<ProjectPanel>(cx) {
515 panel.update(cx, |panel, cx| {
516 panel.duplicate(action, window, cx);
517 });
518 }
519 });
520
521 workspace.register_action(|workspace, action: &Delete, window, cx| {
522 if let Some(panel) = workspace.panel::<ProjectPanel>(cx) {
523 panel.update(cx, |panel, cx| panel.delete(action, window, cx));
524 }
525 });
526
527 workspace.register_action(|workspace, _: &git::FileHistory, window, cx| {
528 // First try to get from project panel if it's focused
529 if let Some(panel) = workspace.panel::<ProjectPanel>(cx) {
530 let maybe_project_path = panel.read(cx).selection.and_then(|selection| {
531 let project = workspace.project().read(cx);
532 let worktree = project.worktree_for_id(selection.worktree_id, cx)?;
533 let entry = worktree.read(cx).entry_for_id(selection.entry_id)?;
534 if entry.is_file() {
535 Some(ProjectPath {
536 worktree_id: selection.worktree_id,
537 path: entry.path.clone(),
538 })
539 } else {
540 None
541 }
542 });
543
544 if let Some(project_path) = maybe_project_path {
545 let project = workspace.project();
546 let git_store = project.read(cx).git_store();
547 if let Some((repo, repo_path)) = git_store
548 .read(cx)
549 .repository_and_path_for_project_path(&project_path, cx)
550 {
551 git_ui::file_history_view::FileHistoryView::open(
552 repo_path,
553 git_store.downgrade(),
554 repo.downgrade(),
555 workspace.weak_handle(),
556 window,
557 cx,
558 );
559 return;
560 }
561 }
562 }
563
564 // Fallback: try to get from active editor
565 if let Some(active_item) = workspace.active_item(cx)
566 && let Some(editor) = active_item.downcast::<Editor>()
567 && let Some(buffer) = editor.read(cx).buffer().read(cx).as_singleton()
568 && let Some(file) = buffer.read(cx).file()
569 {
570 let worktree_id = file.worktree_id(cx);
571 let project_path = ProjectPath {
572 worktree_id,
573 path: file.path().clone(),
574 };
575 let project = workspace.project();
576 let git_store = project.read(cx).git_store();
577 if let Some((repo, repo_path)) = git_store
578 .read(cx)
579 .repository_and_path_for_project_path(&project_path, cx)
580 {
581 git_ui::file_history_view::FileHistoryView::open(
582 repo_path,
583 git_store.downgrade(),
584 repo.downgrade(),
585 workspace.weak_handle(),
586 window,
587 cx,
588 );
589 }
590 }
591 });
592 })
593 .detach();
594}
595
596#[derive(Debug)]
597pub enum Event {
598 OpenedEntry {
599 entry_id: ProjectEntryId,
600 focus_opened_item: bool,
601 allow_preview: bool,
602 },
603 SplitEntry {
604 entry_id: ProjectEntryId,
605 allow_preview: bool,
606 split_direction: Option<SplitDirection>,
607 },
608 Focus,
609}
610
611struct DraggedProjectEntryView {
612 selection: SelectedEntry,
613 icon: Option<SharedString>,
614 filename: String,
615 click_offset: Point<Pixels>,
616 selections: Arc<[SelectedEntry]>,
617}
618
619struct ItemColors {
620 default: Hsla,
621 hover: Hsla,
622 drag_over: Hsla,
623 marked: Hsla,
624 focused: Hsla,
625}
626
627fn get_item_color(is_sticky: bool, cx: &App) -> ItemColors {
628 let colors = cx.theme().colors();
629
630 ItemColors {
631 default: if is_sticky {
632 colors.panel_overlay_background
633 } else {
634 colors.panel_background
635 },
636 hover: if is_sticky {
637 colors.panel_overlay_hover
638 } else {
639 colors.element_hover
640 },
641 marked: colors.element_selected,
642 focused: colors.panel_focused_border,
643 drag_over: colors.drop_target_background,
644 }
645}
646
647impl ProjectPanel {
648 fn new(
649 workspace: &mut Workspace,
650 window: &mut Window,
651 cx: &mut Context<Workspace>,
652 ) -> Entity<Self> {
653 let project = workspace.project().clone();
654 let git_store = project.read(cx).git_store().clone();
655 let path_style = project.read(cx).path_style(cx);
656 let project_panel = cx.new(|cx| {
657 let focus_handle = cx.focus_handle();
658 cx.on_focus(&focus_handle, window, Self::focus_in).detach();
659
660 cx.subscribe_in(
661 &git_store,
662 window,
663 |this, _, event, window, cx| match event {
664 GitStoreEvent::RepositoryUpdated(_, RepositoryEvent::StatusesChanged, _)
665 | GitStoreEvent::RepositoryAdded
666 | GitStoreEvent::RepositoryRemoved(_) => {
667 this.update_visible_entries(None, false, false, window, cx);
668 cx.notify();
669 }
670 _ => {}
671 },
672 )
673 .detach();
674
675 cx.subscribe_in(
676 &project,
677 window,
678 |this, project, event, window, cx| match event {
679 project::Event::ActiveEntryChanged(Some(entry_id)) => {
680 if ProjectPanelSettings::get_global(cx).auto_reveal_entries {
681 this.reveal_entry(project.clone(), *entry_id, true, window, cx)
682 .ok();
683 }
684 }
685 project::Event::ActiveEntryChanged(None) => {
686 let is_active_item_file_diff_view = this
687 .workspace
688 .upgrade()
689 .and_then(|ws| ws.read(cx).active_item(cx))
690 .map(|item| {
691 item.act_as_type(TypeId::of::<FileDiffView>(), cx).is_some()
692 })
693 .unwrap_or(false);
694 if !is_active_item_file_diff_view {
695 this.marked_entries.clear();
696 }
697 }
698 project::Event::RevealInProjectPanel(entry_id) => {
699 if let Some(()) = this
700 .reveal_entry(project.clone(), *entry_id, false, window, cx)
701 .log_err()
702 {
703 cx.emit(PanelEvent::Activate);
704 }
705 }
706 project::Event::ActivateProjectPanel => {
707 cx.emit(PanelEvent::Activate);
708 }
709 project::Event::DiskBasedDiagnosticsFinished { .. }
710 | project::Event::DiagnosticsUpdated { .. } => {
711 if ProjectPanelSettings::get_global(cx).show_diagnostics
712 != ShowDiagnostics::Off
713 {
714 this.diagnostic_summary_update = cx.spawn(async move |this, cx| {
715 cx.background_executor()
716 .timer(Duration::from_millis(30))
717 .await;
718 this.update(cx, |this, cx| {
719 this.update_diagnostics(cx);
720 cx.notify();
721 })
722 .log_err();
723 });
724 }
725 }
726 project::Event::WorktreeRemoved(id) => {
727 this.state.expanded_dir_ids.remove(id);
728 this.update_visible_entries(None, false, false, window, cx);
729 cx.notify();
730 }
731 project::Event::WorktreeUpdatedEntries(_, _)
732 | project::Event::WorktreeAdded(_)
733 | project::Event::WorktreeOrderChanged => {
734 this.update_visible_entries(None, false, false, window, cx);
735 cx.notify();
736 }
737 project::Event::ExpandedAllForEntry(worktree_id, entry_id) => {
738 if let Some((worktree, expanded_dir_ids)) = project
739 .read(cx)
740 .worktree_for_id(*worktree_id, cx)
741 .zip(this.state.expanded_dir_ids.get_mut(worktree_id))
742 {
743 let worktree = worktree.read(cx);
744
745 let Some(entry) = worktree.entry_for_id(*entry_id) else {
746 return;
747 };
748 let include_ignored_dirs = !entry.is_ignored;
749
750 let mut dirs_to_expand = vec![*entry_id];
751 while let Some(current_id) = dirs_to_expand.pop() {
752 let Some(current_entry) = worktree.entry_for_id(current_id) else {
753 continue;
754 };
755 for child in worktree.child_entries(¤t_entry.path) {
756 if !child.is_dir() || (include_ignored_dirs && child.is_ignored)
757 {
758 continue;
759 }
760
761 dirs_to_expand.push(child.id);
762
763 if let Err(ix) = expanded_dir_ids.binary_search(&child.id) {
764 expanded_dir_ids.insert(ix, child.id);
765 }
766 this.state.unfolded_dir_ids.insert(child.id);
767 }
768 }
769 this.update_visible_entries(None, false, false, window, cx);
770 cx.notify();
771 }
772 }
773 _ => {}
774 },
775 )
776 .detach();
777
778 let trash_action = [TypeId::of::<Trash>()];
779 let is_remote = project.read(cx).is_remote();
780
781 // Make sure the trash option is never displayed anywhere on remote
782 // hosts since they may not support trashing. May want to dynamically
783 // detect this in the future.
784 if is_remote {
785 CommandPaletteFilter::update_global(cx, |filter, _cx| {
786 filter.hide_action_types(&trash_action);
787 });
788 }
789
790 let filename_editor = cx.new(|cx| Editor::single_line(window, cx));
791
792 cx.subscribe_in(
793 &filename_editor,
794 window,
795 |project_panel, _, editor_event, window, cx| match editor_event {
796 EditorEvent::BufferEdited => {
797 project_panel.populate_validation_error(cx);
798 project_panel.autoscroll(cx);
799 }
800 EditorEvent::SelectionsChanged { .. } => {
801 project_panel.autoscroll(cx);
802 }
803 EditorEvent::Blurred => {
804 if project_panel
805 .state
806 .edit_state
807 .as_ref()
808 .is_some_and(|state| state.processing_filename.is_none())
809 {
810 match project_panel.confirm_edit(false, window, cx) {
811 Some(task) => {
812 task.detach_and_notify_err(
813 project_panel.workspace.clone(),
814 window,
815 cx,
816 );
817 }
818 None => {
819 project_panel.discard_edit_state(window, cx);
820 }
821 }
822 }
823 }
824 _ => {}
825 },
826 )
827 .detach();
828
829 cx.observe_global::<FileIcons>(|_, cx| {
830 cx.notify();
831 })
832 .detach();
833
834 let mut project_panel_settings = *ProjectPanelSettings::get_global(cx);
835 cx.observe_global_in::<SettingsStore>(window, move |this, window, cx| {
836 let new_settings = *ProjectPanelSettings::get_global(cx);
837 if project_panel_settings != new_settings {
838 if project_panel_settings.hide_gitignore != new_settings.hide_gitignore {
839 this.update_visible_entries(None, false, false, window, cx);
840 }
841 if project_panel_settings.hide_root != new_settings.hide_root {
842 this.update_visible_entries(None, false, false, window, cx);
843 }
844 if project_panel_settings.hide_hidden != new_settings.hide_hidden {
845 this.update_visible_entries(None, false, false, window, cx);
846 }
847 if project_panel_settings.sort_mode != new_settings.sort_mode {
848 this.update_visible_entries(None, false, false, window, cx);
849 }
850 if project_panel_settings.sort_order != new_settings.sort_order {
851 this.update_visible_entries(None, false, false, window, cx);
852 }
853 if project_panel_settings.sticky_scroll && !new_settings.sticky_scroll {
854 this.sticky_items_count = 0;
855 }
856 project_panel_settings = new_settings;
857 this.update_diagnostics(cx);
858 cx.notify();
859 }
860 })
861 .detach();
862
863 let scroll_handle = UniformListScrollHandle::new();
864 let mut this = Self {
865 project: project.clone(),
866 hover_scroll_task: None,
867 fs: workspace.app_state().fs.clone(),
868 focus_handle,
869 rendered_entries_len: 0,
870 folded_directory_drag_target: None,
871 drag_target_entry: None,
872 marked_entries: Default::default(),
873 selection: None,
874 context_menu: None,
875 filename_editor,
876 clipboard: None,
877 _dragged_entry_destination: None,
878 workspace: workspace.weak_handle(),
879 diagnostics: Default::default(),
880 diagnostic_counts: Default::default(),
881 diagnostic_summary_update: Task::ready(()),
882 scroll_handle,
883 mouse_down: false,
884 hover_expand_task: None,
885 previous_drag_position: None,
886 sticky_items_count: 0,
887 last_reported_update: Instant::now(),
888 state: State {
889 max_width_item_index: None,
890 edit_state: None,
891 temporarily_unfolded_pending_state: None,
892 last_worktree_root_id: Default::default(),
893 visible_entries: Default::default(),
894 ancestors: Default::default(),
895 expanded_dir_ids: Default::default(),
896 unfolded_dir_ids: Default::default(),
897 },
898 update_visible_entries_task: Default::default(),
899 undo_manager: UndoManager::new(workspace.weak_handle()),
900 };
901 this.update_visible_entries(None, false, false, window, cx);
902
903 this
904 });
905
906 cx.subscribe_in(&project_panel, window, {
907 let project_panel = project_panel.downgrade();
908 move |workspace, _, event, window, cx| match event {
909 &Event::OpenedEntry {
910 entry_id,
911 focus_opened_item,
912 allow_preview,
913 } => {
914 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx)
915 && let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
916 let file_path = entry.path.clone();
917 let worktree_id = worktree.read(cx).id();
918 let entry_id = entry.id;
919 let is_via_ssh = project.read(cx).is_via_remote_server();
920
921 workspace
922 .open_path_preview(
923 ProjectPath {
924 worktree_id,
925 path: file_path.clone(),
926 },
927 None,
928 focus_opened_item,
929 allow_preview,
930 true,
931 window, cx,
932 )
933 .detach_and_prompt_err("Failed to open file", window, cx, move |e, _, _| {
934 match e.error_code() {
935 ErrorCode::Disconnected => if is_via_ssh {
936 Some("Disconnected from SSH host".to_string())
937 } else {
938 Some("Disconnected from remote project".to_string())
939 },
940 ErrorCode::UnsharedItem => Some(format!(
941 "{} is not shared by the host. This could be because it has been marked as `private`",
942 file_path.display(path_style)
943 )),
944 // See note in worktree.rs where this error originates. Returning Some in this case prevents
945 // the error popup from saying "Try Again", which is a red herring in this case
946 ErrorCode::Internal if e.to_string().contains("File is too large to load") => Some(e.to_string()),
947 _ => None,
948 }
949 });
950
951 if let Some(project_panel) = project_panel.upgrade() {
952 // Always select and mark the entry, regardless of whether it is opened or not.
953 project_panel.update(cx, |project_panel, _| {
954 let entry = SelectedEntry { worktree_id, entry_id };
955 project_panel.marked_entries.clear();
956 project_panel.marked_entries.push(entry);
957 project_panel.selection = Some(entry);
958 });
959 if !focus_opened_item {
960 let focus_handle = project_panel.read(cx).focus_handle.clone();
961 window.focus(&focus_handle, cx);
962 }
963 }
964 }
965 }
966 &Event::SplitEntry {
967 entry_id,
968 allow_preview,
969 split_direction,
970 } => {
971 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx)
972 && let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
973 workspace
974 .split_path_preview(
975 ProjectPath {
976 worktree_id: worktree.read(cx).id(),
977 path: entry.path.clone(),
978 },
979 allow_preview,
980 split_direction,
981 window, cx,
982 )
983 .detach_and_log_err(cx);
984 }
985 }
986
987 _ => {}
988 }
989 })
990 .detach();
991
992 project_panel
993 }
994
995 pub async fn load(
996 workspace: WeakEntity<Workspace>,
997 mut cx: AsyncWindowContext,
998 ) -> Result<Entity<Self>> {
999 workspace.update_in(&mut cx, |workspace, window, cx| {
1000 ProjectPanel::new(workspace, window, cx)
1001 })
1002 }
1003
1004 fn update_diagnostics(&mut self, cx: &mut Context<Self>) {
1005 let mut diagnostics: HashMap<(WorktreeId, Arc<RelPath>), DiagnosticSeverity> =
1006 Default::default();
1007 let show_diagnostics_setting = ProjectPanelSettings::get_global(cx).show_diagnostics;
1008
1009 if show_diagnostics_setting != ShowDiagnostics::Off {
1010 self.project
1011 .read(cx)
1012 .diagnostic_summaries(false, cx)
1013 .filter_map(|(path, _, diagnostic_summary)| {
1014 if diagnostic_summary.error_count > 0 {
1015 Some((path, DiagnosticSeverity::ERROR))
1016 } else if show_diagnostics_setting == ShowDiagnostics::All
1017 && diagnostic_summary.warning_count > 0
1018 {
1019 Some((path, DiagnosticSeverity::WARNING))
1020 } else {
1021 None
1022 }
1023 })
1024 .for_each(|(project_path, diagnostic_severity)| {
1025 let ancestors = project_path.path.ancestors().collect::<Vec<_>>();
1026 for path in ancestors.into_iter().rev() {
1027 Self::update_strongest_diagnostic_severity(
1028 &mut diagnostics,
1029 &project_path,
1030 path.into(),
1031 diagnostic_severity,
1032 );
1033 }
1034 });
1035 }
1036 self.diagnostics = diagnostics;
1037
1038 let diagnostic_badges = ProjectPanelSettings::get_global(cx).diagnostic_badges;
1039 self.diagnostic_counts =
1040 if diagnostic_badges && show_diagnostics_setting != ShowDiagnostics::Off {
1041 self.project.read(cx).diagnostic_summaries(false, cx).fold(
1042 HashMap::default(),
1043 |mut counts, (project_path, _, summary)| {
1044 let entry = counts
1045 .entry((project_path.worktree_id, project_path.path))
1046 .or_default();
1047 entry.error_count += summary.error_count;
1048 if show_diagnostics_setting == ShowDiagnostics::All {
1049 entry.warning_count += summary.warning_count;
1050 }
1051 counts
1052 },
1053 )
1054 } else {
1055 Default::default()
1056 };
1057 }
1058
1059 fn update_strongest_diagnostic_severity(
1060 diagnostics: &mut HashMap<(WorktreeId, Arc<RelPath>), DiagnosticSeverity>,
1061 project_path: &ProjectPath,
1062 path_buffer: Arc<RelPath>,
1063 diagnostic_severity: DiagnosticSeverity,
1064 ) {
1065 diagnostics
1066 .entry((project_path.worktree_id, path_buffer))
1067 .and_modify(|strongest_diagnostic_severity| {
1068 *strongest_diagnostic_severity =
1069 cmp::min(*strongest_diagnostic_severity, diagnostic_severity);
1070 })
1071 .or_insert(diagnostic_severity);
1072 }
1073
1074 fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1075 if !self.focus_handle.contains_focused(window, cx) {
1076 cx.emit(Event::Focus);
1077 }
1078 }
1079
1080 fn deploy_context_menu(
1081 &mut self,
1082 position: Point<Pixels>,
1083 entry_id: ProjectEntryId,
1084 window: &mut Window,
1085 cx: &mut Context<Self>,
1086 ) {
1087 let project = self.project.read(cx);
1088
1089 let worktree_id = if let Some(id) = project.worktree_id_for_entry(entry_id, cx) {
1090 id
1091 } else {
1092 return;
1093 };
1094
1095 self.selection = Some(SelectedEntry {
1096 worktree_id,
1097 entry_id,
1098 });
1099
1100 if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
1101 let auto_fold_dirs = ProjectPanelSettings::get_global(cx).auto_fold_dirs;
1102 let worktree = worktree.read(cx);
1103 let is_root = Some(entry) == worktree.root_entry();
1104 let is_dir = entry.is_dir();
1105 let is_foldable = auto_fold_dirs && self.is_foldable(entry, worktree);
1106 let is_unfoldable = auto_fold_dirs && self.is_unfoldable(entry, worktree);
1107 let is_read_only = project.is_read_only(cx);
1108 let is_remote = project.is_remote();
1109 let is_collab = project.is_via_collab();
1110 let is_local = project.is_local() || project.is_via_wsl_with_host_interop(cx);
1111
1112 let settings = ProjectPanelSettings::get_global(cx);
1113 let visible_worktrees_count = project.visible_worktrees(cx).count();
1114 let should_hide_rename = is_root
1115 && (cfg!(target_os = "windows")
1116 || (settings.hide_root && visible_worktrees_count == 1));
1117 let should_show_compare = !is_dir && self.file_abs_paths_to_diff(cx).is_some();
1118
1119 let has_git_repo = !is_dir && {
1120 let project_path = project::ProjectPath {
1121 worktree_id,
1122 path: entry.path.clone(),
1123 };
1124 project
1125 .git_store()
1126 .read(cx)
1127 .repository_and_path_for_project_path(&project_path, cx)
1128 .is_some()
1129 };
1130
1131 let has_pasteable_content = self.has_pasteable_content(cx);
1132 let entity = cx.entity();
1133 let context_menu = ContextMenu::build(window, cx, |menu, _, cx| {
1134 menu.context(self.focus_handle.clone()).map(|menu| {
1135 if is_read_only {
1136 menu.when(is_dir, |menu| {
1137 menu.action("Search Inside", Box::new(NewSearchInDirectory))
1138 })
1139 } else {
1140 menu.action("New File", Box::new(NewFile))
1141 .action("New Folder", Box::new(NewDirectory))
1142 .separator()
1143 .when(is_local, |menu| {
1144 menu.action(
1145 ui::utils::reveal_in_file_manager_label(is_remote),
1146 Box::new(RevealInFileManager),
1147 )
1148 })
1149 .when(is_local, |menu| {
1150 menu.action("Open in Default App", Box::new(OpenWithSystem))
1151 })
1152 .action("Open in Terminal", Box::new(OpenInTerminal))
1153 .when(is_dir, |menu| {
1154 menu.separator()
1155 .action("Find in Folder…", Box::new(NewSearchInDirectory))
1156 })
1157 .when(is_unfoldable, |menu| {
1158 menu.action("Unfold Directory", Box::new(UnfoldDirectory))
1159 })
1160 .when(is_foldable, |menu| {
1161 menu.action("Fold Directory", Box::new(FoldDirectory))
1162 })
1163 .when(should_show_compare, |menu| {
1164 menu.separator()
1165 .action("Compare Marked Files", Box::new(CompareMarkedFiles))
1166 })
1167 .separator()
1168 .action("Cut", Box::new(Cut))
1169 .action("Copy", Box::new(Copy))
1170 .action("Duplicate", Box::new(Duplicate))
1171 // TODO: Paste should always be visible, cbut disabled when clipboard is empty
1172 .action_disabled_when(!has_pasteable_content, "Paste", Box::new(Paste))
1173 .when(cx.has_flag::<ProjectPanelUndoRedoFeatureFlag>(), |menu| {
1174 menu.action_disabled_when(
1175 !self.undo_manager.can_undo(),
1176 "Undo",
1177 Box::new(Undo),
1178 )
1179 })
1180 .when(is_remote, |menu| {
1181 menu.separator()
1182 .action("Download...", Box::new(DownloadFromRemote))
1183 })
1184 .separator()
1185 .action("Copy Path", Box::new(zed_actions::workspace::CopyPath))
1186 .action(
1187 "Copy Relative Path",
1188 Box::new(zed_actions::workspace::CopyRelativePath),
1189 )
1190 .when(!is_dir && self.has_git_changes(entry_id), |menu| {
1191 menu.separator().action(
1192 "Restore File",
1193 Box::new(git::RestoreFile { skip_prompt: false }),
1194 )
1195 })
1196 .when(has_git_repo, |menu| {
1197 menu.separator()
1198 .action("View File History", Box::new(git::FileHistory))
1199 })
1200 .when(!should_hide_rename, |menu| {
1201 menu.separator().action("Rename", Box::new(Rename))
1202 })
1203 .when(!is_root && !is_remote, |menu| {
1204 menu.action("Trash", Box::new(Trash { skip_prompt: false }))
1205 })
1206 .when(!is_root, |menu| {
1207 menu.action("Delete", Box::new(Delete { skip_prompt: false }))
1208 })
1209 .when(!is_collab && is_root, |menu| {
1210 menu.separator()
1211 .action(
1212 "Add Project to Workspace…",
1213 Box::new(workspace::AddFolderToProject),
1214 )
1215 .action("Remove from Workspace", Box::new(RemoveFromProject))
1216 })
1217 .when(is_dir && !is_root, |menu| {
1218 menu.separator().action(
1219 "Collapse All",
1220 Box::new(CollapseSelectedEntryAndChildren),
1221 )
1222 })
1223 .when(is_dir && is_root, |menu| {
1224 let entity = entity.clone();
1225 menu.separator().item(
1226 ContextMenuEntry::new("Collapse All").handler(
1227 move |window, cx| {
1228 entity.update(cx, |this, cx| {
1229 this.collapse_all_for_root(window, cx);
1230 });
1231 },
1232 ),
1233 )
1234 })
1235 }
1236 })
1237 });
1238
1239 window.focus(&context_menu.focus_handle(cx), cx);
1240 let subscription = cx.subscribe(&context_menu, |this, _, _: &DismissEvent, cx| {
1241 this.context_menu.take();
1242 cx.notify();
1243 });
1244 self.context_menu = Some((context_menu, position, subscription));
1245 }
1246
1247 cx.notify();
1248 }
1249
1250 fn has_git_changes(&self, entry_id: ProjectEntryId) -> bool {
1251 for visible in &self.state.visible_entries {
1252 if let Some(git_entry) = visible.entries.iter().find(|e| e.id == entry_id) {
1253 let total_modified =
1254 git_entry.git_summary.index.modified + git_entry.git_summary.worktree.modified;
1255 let total_deleted =
1256 git_entry.git_summary.index.deleted + git_entry.git_summary.worktree.deleted;
1257 return total_modified > 0 || total_deleted > 0;
1258 }
1259 }
1260 false
1261 }
1262
1263 fn is_unfoldable(&self, entry: &Entry, worktree: &Worktree) -> bool {
1264 if !entry.is_dir() || self.state.unfolded_dir_ids.contains(&entry.id) {
1265 return false;
1266 }
1267
1268 if let Some(parent_path) = entry.path.parent() {
1269 let snapshot = worktree.snapshot();
1270 let mut child_entries = snapshot.child_entries(parent_path);
1271 if let Some(child) = child_entries.next()
1272 && child_entries.next().is_none()
1273 {
1274 return child.kind.is_dir();
1275 }
1276 };
1277 false
1278 }
1279
1280 fn is_foldable(&self, entry: &Entry, worktree: &Worktree) -> bool {
1281 if entry.is_dir() {
1282 let snapshot = worktree.snapshot();
1283
1284 let mut child_entries = snapshot.child_entries(&entry.path);
1285 if let Some(child) = child_entries.next()
1286 && child_entries.next().is_none()
1287 {
1288 return child.kind.is_dir();
1289 }
1290 }
1291 false
1292 }
1293
1294 fn expand_selected_entry(
1295 &mut self,
1296 _: &ExpandSelectedEntry,
1297 window: &mut Window,
1298 cx: &mut Context<Self>,
1299 ) {
1300 if let Some((worktree, entry)) = self.selected_entry(cx) {
1301 if let Some(folded_ancestors) = self.state.ancestors.get_mut(&entry.id)
1302 && folded_ancestors.current_ancestor_depth > 0
1303 {
1304 folded_ancestors.current_ancestor_depth -= 1;
1305 cx.notify();
1306 return;
1307 }
1308 if entry.is_dir() {
1309 let worktree_id = worktree.id();
1310 let entry_id = entry.id;
1311 let expanded_dir_ids = if let Some(expanded_dir_ids) =
1312 self.state.expanded_dir_ids.get_mut(&worktree_id)
1313 {
1314 expanded_dir_ids
1315 } else {
1316 return;
1317 };
1318
1319 match expanded_dir_ids.binary_search(&entry_id) {
1320 Ok(_) => self.select_next(&SelectNext, window, cx),
1321 Err(ix) => {
1322 self.project.update(cx, |project, cx| {
1323 project.expand_entry(worktree_id, entry_id, cx);
1324 });
1325
1326 expanded_dir_ids.insert(ix, entry_id);
1327 self.update_visible_entries(None, false, false, window, cx);
1328 cx.notify();
1329 }
1330 }
1331 }
1332 }
1333 }
1334
1335 fn collapse_selected_entry(
1336 &mut self,
1337 _: &CollapseSelectedEntry,
1338 window: &mut Window,
1339 cx: &mut Context<Self>,
1340 ) {
1341 let Some((worktree, entry)) = self.selected_entry_handle(cx) else {
1342 return;
1343 };
1344 self.collapse_entry(entry.clone(), worktree, window, cx)
1345 }
1346
1347 fn collapse_entry(
1348 &mut self,
1349 entry: Entry,
1350 worktree: Entity<Worktree>,
1351 window: &mut Window,
1352 cx: &mut Context<Self>,
1353 ) {
1354 let worktree = worktree.read(cx);
1355 if let Some(folded_ancestors) = self.state.ancestors.get_mut(&entry.id)
1356 && folded_ancestors.current_ancestor_depth + 1 < folded_ancestors.max_ancestor_depth()
1357 {
1358 folded_ancestors.current_ancestor_depth += 1;
1359 cx.notify();
1360 return;
1361 }
1362 let worktree_id = worktree.id();
1363 let expanded_dir_ids =
1364 if let Some(expanded_dir_ids) = self.state.expanded_dir_ids.get_mut(&worktree_id) {
1365 expanded_dir_ids
1366 } else {
1367 return;
1368 };
1369
1370 let mut entry = &entry;
1371 loop {
1372 let entry_id = entry.id;
1373 match expanded_dir_ids.binary_search(&entry_id) {
1374 Ok(ix) => {
1375 expanded_dir_ids.remove(ix);
1376 self.update_visible_entries(
1377 Some((worktree_id, entry_id)),
1378 false,
1379 false,
1380 window,
1381 cx,
1382 );
1383 cx.notify();
1384 break;
1385 }
1386 Err(_) => {
1387 if let Some(parent_entry) =
1388 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
1389 {
1390 entry = parent_entry;
1391 } else {
1392 break;
1393 }
1394 }
1395 }
1396 }
1397 }
1398
1399 fn collapse_selected_entry_and_children(
1400 &mut self,
1401 _: &CollapseSelectedEntryAndChildren,
1402 window: &mut Window,
1403 cx: &mut Context<Self>,
1404 ) {
1405 if let Some((worktree, entry)) = self.selected_entry(cx) {
1406 let worktree_id = worktree.id();
1407 let entry_id = entry.id;
1408
1409 self.collapse_all_for_entry(worktree_id, entry_id, cx);
1410
1411 self.update_visible_entries(Some((worktree_id, entry_id)), false, false, window, cx);
1412 cx.notify();
1413 }
1414 }
1415
1416 /// Handles "Collapse All" from the context menu when a root directory is selected.
1417 /// With a single visible worktree, keeps the root expanded (matching CollapseAllEntries behavior).
1418 /// With multiple visible worktrees, collapses the root and all its children.
1419 fn collapse_all_for_root(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1420 let Some((worktree, entry)) = self.selected_entry(cx) else {
1421 return;
1422 };
1423
1424 let is_root = worktree.root_entry().map(|e| e.id) == Some(entry.id);
1425 if !is_root {
1426 return;
1427 }
1428
1429 let worktree_id = worktree.id();
1430 let root_id = entry.id;
1431
1432 if let Some(expanded_dir_ids) = self.state.expanded_dir_ids.get_mut(&worktree_id) {
1433 if self.project.read(cx).visible_worktrees(cx).count() == 1 {
1434 expanded_dir_ids.retain(|id| id == &root_id);
1435 } else {
1436 expanded_dir_ids.clear();
1437 }
1438 }
1439
1440 self.update_visible_entries(Some((worktree_id, root_id)), false, false, window, cx);
1441 cx.notify();
1442 }
1443
1444 fn collapse_all_entries(
1445 &mut self,
1446 _: &CollapseAllEntries,
1447 window: &mut Window,
1448 cx: &mut Context<Self>,
1449 ) {
1450 // By keeping entries for fully collapsed worktrees, we avoid expanding them within update_visible_entries
1451 // (which is it's default behavior when there's no entry for a worktree in expanded_dir_ids).
1452 let multiple_worktrees = self.project.read(cx).visible_worktrees(cx).count() > 1;
1453 let project = self.project.read(cx);
1454
1455 self.state
1456 .expanded_dir_ids
1457 .iter_mut()
1458 .for_each(|(worktree_id, expanded_entries)| {
1459 if multiple_worktrees {
1460 *expanded_entries = Default::default();
1461 return;
1462 }
1463
1464 let root_entry_id = project
1465 .worktree_for_id(*worktree_id, cx)
1466 .map(|worktree| worktree.read(cx).snapshot())
1467 .and_then(|worktree_snapshot| {
1468 worktree_snapshot.root_entry().map(|entry| entry.id)
1469 });
1470
1471 match root_entry_id {
1472 Some(id) => {
1473 expanded_entries.retain(|entry_id| entry_id == &id);
1474 }
1475 None => *expanded_entries = Default::default(),
1476 };
1477 });
1478
1479 self.update_visible_entries(None, false, false, window, cx);
1480 cx.notify();
1481 }
1482
1483 fn toggle_expanded(
1484 &mut self,
1485 entry_id: ProjectEntryId,
1486 window: &mut Window,
1487 cx: &mut Context<Self>,
1488 ) {
1489 if let Some(worktree_id) = self.project.read(cx).worktree_id_for_entry(entry_id, cx)
1490 && let Some(expanded_dir_ids) = self.state.expanded_dir_ids.get_mut(&worktree_id)
1491 {
1492 self.project.update(cx, |project, cx| {
1493 match expanded_dir_ids.binary_search(&entry_id) {
1494 Ok(ix) => {
1495 expanded_dir_ids.remove(ix);
1496 }
1497 Err(ix) => {
1498 project.expand_entry(worktree_id, entry_id, cx);
1499 expanded_dir_ids.insert(ix, entry_id);
1500 }
1501 }
1502 });
1503 self.update_visible_entries(Some((worktree_id, entry_id)), false, false, window, cx);
1504 window.focus(&self.focus_handle, cx);
1505 cx.notify();
1506 }
1507 }
1508
1509 fn toggle_expand_all(
1510 &mut self,
1511 entry_id: ProjectEntryId,
1512 window: &mut Window,
1513 cx: &mut Context<Self>,
1514 ) {
1515 if let Some(worktree_id) = self.project.read(cx).worktree_id_for_entry(entry_id, cx)
1516 && let Some(expanded_dir_ids) = self.state.expanded_dir_ids.get_mut(&worktree_id)
1517 {
1518 match expanded_dir_ids.binary_search(&entry_id) {
1519 Ok(_ix) => {
1520 self.collapse_all_for_entry(worktree_id, entry_id, cx);
1521 }
1522 Err(_ix) => {
1523 self.expand_all_for_entry(worktree_id, entry_id, cx);
1524 }
1525 }
1526 self.update_visible_entries(Some((worktree_id, entry_id)), false, false, window, cx);
1527 window.focus(&self.focus_handle, cx);
1528 cx.notify();
1529 }
1530 }
1531
1532 fn expand_all_for_entry(
1533 &mut self,
1534 worktree_id: WorktreeId,
1535 entry_id: ProjectEntryId,
1536 cx: &mut Context<Self>,
1537 ) {
1538 self.project.update(cx, |project, cx| {
1539 if let Some((worktree, expanded_dir_ids)) = project
1540 .worktree_for_id(worktree_id, cx)
1541 .zip(self.state.expanded_dir_ids.get_mut(&worktree_id))
1542 {
1543 if let Some(task) = project.expand_all_for_entry(worktree_id, entry_id, cx) {
1544 task.detach();
1545 }
1546
1547 let worktree = worktree.read(cx);
1548
1549 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
1550 loop {
1551 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
1552 expanded_dir_ids.insert(ix, entry.id);
1553 }
1554
1555 if let Some(parent_entry) =
1556 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
1557 {
1558 entry = parent_entry;
1559 } else {
1560 break;
1561 }
1562 }
1563 }
1564 }
1565 });
1566 }
1567
1568 fn collapse_all_for_entry(
1569 &mut self,
1570 worktree_id: WorktreeId,
1571 entry_id: ProjectEntryId,
1572 cx: &mut Context<Self>,
1573 ) {
1574 self.project.update(cx, |project, cx| {
1575 if let Some((worktree, expanded_dir_ids)) = project
1576 .worktree_for_id(worktree_id, cx)
1577 .zip(self.state.expanded_dir_ids.get_mut(&worktree_id))
1578 {
1579 let worktree = worktree.read(cx);
1580 let mut dirs_to_collapse = vec![entry_id];
1581 let auto_fold_enabled = ProjectPanelSettings::get_global(cx).auto_fold_dirs;
1582 while let Some(current_id) = dirs_to_collapse.pop() {
1583 let Some(current_entry) = worktree.entry_for_id(current_id) else {
1584 continue;
1585 };
1586 if let Ok(ix) = expanded_dir_ids.binary_search(¤t_id) {
1587 expanded_dir_ids.remove(ix);
1588 }
1589 if auto_fold_enabled {
1590 self.state.unfolded_dir_ids.remove(¤t_id);
1591 }
1592 for child in worktree.child_entries(¤t_entry.path) {
1593 if child.is_dir() {
1594 dirs_to_collapse.push(child.id);
1595 }
1596 }
1597 }
1598 }
1599 });
1600 }
1601
1602 fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
1603 if let Some(edit_state) = &self.state.edit_state
1604 && edit_state.processing_filename.is_none()
1605 {
1606 self.filename_editor.update(cx, |editor, cx| {
1607 editor.move_to_beginning_of_line(
1608 &editor::actions::MoveToBeginningOfLine {
1609 stop_at_soft_wraps: false,
1610 stop_at_indent: false,
1611 },
1612 window,
1613 cx,
1614 );
1615 });
1616 return;
1617 }
1618 if let Some(selection) = self.selection {
1619 let (mut worktree_ix, mut entry_ix, _) =
1620 self.index_for_selection(selection).unwrap_or_default();
1621 if entry_ix > 0 {
1622 entry_ix -= 1;
1623 } else if worktree_ix > 0 {
1624 worktree_ix -= 1;
1625 entry_ix = self.state.visible_entries[worktree_ix].entries.len() - 1;
1626 } else {
1627 return;
1628 }
1629
1630 let VisibleEntriesForWorktree {
1631 worktree_id,
1632 entries,
1633 ..
1634 } = &self.state.visible_entries[worktree_ix];
1635 let selection = SelectedEntry {
1636 worktree_id: *worktree_id,
1637 entry_id: entries[entry_ix].id,
1638 };
1639 self.selection = Some(selection);
1640 if window.modifiers().shift {
1641 self.marked_entries.push(selection);
1642 }
1643 self.autoscroll(cx);
1644 cx.notify();
1645 } else {
1646 self.select_first(&SelectFirst {}, window, cx);
1647 }
1648 }
1649
1650 fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
1651 if let Some(task) = self.confirm_edit(true, window, cx) {
1652 task.detach_and_notify_err(self.workspace.clone(), window, cx);
1653 }
1654 }
1655
1656 fn open(&mut self, _: &Open, window: &mut Window, cx: &mut Context<Self>) {
1657 let preview_tabs_enabled =
1658 PreviewTabsSettings::get_global(cx).enable_preview_from_project_panel;
1659 self.open_internal(true, !preview_tabs_enabled, None, window, cx);
1660 }
1661
1662 fn open_permanent(&mut self, _: &OpenPermanent, window: &mut Window, cx: &mut Context<Self>) {
1663 self.open_internal(false, true, None, window, cx);
1664 }
1665
1666 fn open_split_vertical(
1667 &mut self,
1668 _: &OpenSplitVertical,
1669 window: &mut Window,
1670 cx: &mut Context<Self>,
1671 ) {
1672 self.open_internal(false, true, Some(SplitDirection::vertical(cx)), window, cx);
1673 }
1674
1675 fn open_split_horizontal(
1676 &mut self,
1677 _: &OpenSplitHorizontal,
1678 window: &mut Window,
1679 cx: &mut Context<Self>,
1680 ) {
1681 self.open_internal(
1682 false,
1683 true,
1684 Some(SplitDirection::horizontal(cx)),
1685 window,
1686 cx,
1687 );
1688 }
1689
1690 fn open_internal(
1691 &mut self,
1692 allow_preview: bool,
1693 focus_opened_item: bool,
1694 split_direction: Option<SplitDirection>,
1695 window: &mut Window,
1696 cx: &mut Context<Self>,
1697 ) {
1698 if let Some((_, entry)) = self.selected_entry(cx) {
1699 if entry.is_file() {
1700 if split_direction.is_some() {
1701 self.split_entry(entry.id, allow_preview, split_direction, cx);
1702 } else {
1703 self.open_entry(entry.id, focus_opened_item, allow_preview, cx);
1704 }
1705 cx.notify();
1706 } else {
1707 self.toggle_expanded(entry.id, window, cx);
1708 }
1709 }
1710 }
1711
1712 fn populate_validation_error(&mut self, cx: &mut Context<Self>) {
1713 let edit_state = match self.state.edit_state.as_mut() {
1714 Some(state) => state,
1715 None => return,
1716 };
1717 let filename = self.filename_editor.read(cx).text(cx);
1718 if !filename.is_empty() {
1719 if filename.is_empty() {
1720 edit_state.validation_state =
1721 ValidationState::Error("File or directory name cannot be empty.".to_string());
1722 cx.notify();
1723 return;
1724 }
1725
1726 let trimmed_filename = filename.trim();
1727 if trimmed_filename != filename {
1728 edit_state.validation_state = ValidationState::Warning(
1729 "File or directory name contains leading or trailing whitespace.".to_string(),
1730 );
1731 cx.notify();
1732 return;
1733 }
1734 let trimmed_filename = trimmed_filename.trim_start_matches('/');
1735
1736 let Ok(filename) = RelPath::unix(trimmed_filename) else {
1737 edit_state.validation_state = ValidationState::Warning(
1738 "File or directory name contains leading or trailing whitespace.".to_string(),
1739 );
1740 cx.notify();
1741 return;
1742 };
1743
1744 if let Some(worktree) = self
1745 .project
1746 .read(cx)
1747 .worktree_for_id(edit_state.worktree_id, cx)
1748 && let Some(entry) = worktree.read(cx).entry_for_id(edit_state.entry_id)
1749 {
1750 let mut already_exists = false;
1751 if edit_state.is_new_entry() {
1752 let new_path = entry.path.join(filename);
1753 if worktree.read(cx).entry_for_path(&new_path).is_some() {
1754 already_exists = true;
1755 }
1756 } else {
1757 let new_path = if let Some(parent) = entry.path.clone().parent() {
1758 parent.join(&filename)
1759 } else {
1760 filename.into()
1761 };
1762 if let Some(existing) = worktree.read(cx).entry_for_path(&new_path)
1763 && existing.id != entry.id
1764 {
1765 already_exists = true;
1766 }
1767 };
1768 if already_exists {
1769 edit_state.validation_state = ValidationState::Error(format!(
1770 "File or directory '{}' already exists at location. Please choose a different name.",
1771 filename.as_unix_str()
1772 ));
1773 cx.notify();
1774 return;
1775 }
1776 }
1777 }
1778 edit_state.validation_state = ValidationState::None;
1779 cx.notify();
1780 }
1781
1782 fn confirm_edit(
1783 &mut self,
1784 refocus: bool,
1785 window: &mut Window,
1786 cx: &mut Context<Self>,
1787 ) -> Option<Task<Result<()>>> {
1788 let edit_state = self.state.edit_state.as_mut()?;
1789 let worktree_id = edit_state.worktree_id;
1790 let is_new_entry = edit_state.is_new_entry();
1791 let mut filename = self.filename_editor.read(cx).text(cx);
1792 let path_style = self.project.read(cx).path_style(cx);
1793 if path_style.is_windows() {
1794 // on windows, trailing dots are ignored in paths
1795 // this can cause project panel to create a new entry with a trailing dot
1796 // while the actual one without the dot gets populated by the file watcher
1797 while let Some(trimmed) = filename.strip_suffix('.') {
1798 filename = trimmed.to_string();
1799 }
1800 }
1801 if filename.trim().is_empty() {
1802 return None;
1803 }
1804
1805 let filename_indicates_dir = if path_style.is_windows() {
1806 filename.ends_with('/') || filename.ends_with('\\')
1807 } else {
1808 filename.ends_with('/')
1809 };
1810 let filename = if path_style.is_windows() {
1811 filename.trim_start_matches(&['/', '\\'])
1812 } else {
1813 filename.trim_start_matches('/')
1814 };
1815 let filename = RelPath::new(filename.as_ref(), path_style).ok()?.into_arc();
1816
1817 edit_state.is_dir =
1818 edit_state.is_dir || (edit_state.is_new_entry() && filename_indicates_dir);
1819 let is_dir = edit_state.is_dir;
1820 let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
1821 let entry = worktree.read(cx).entry_for_id(edit_state.entry_id)?.clone();
1822
1823 let edit_task;
1824 let edited_entry_id;
1825 let edited_entry;
1826 let new_project_path: ProjectPath;
1827 if is_new_entry {
1828 self.selection = Some(SelectedEntry {
1829 worktree_id,
1830 entry_id: NEW_ENTRY_ID,
1831 });
1832 let new_path = entry.path.join(&filename);
1833 if worktree.read(cx).entry_for_path(&new_path).is_some() {
1834 return None;
1835 }
1836
1837 edited_entry = None;
1838 edited_entry_id = NEW_ENTRY_ID;
1839 new_project_path = (worktree_id, new_path).into();
1840 edit_task = self.project.update(cx, |project, cx| {
1841 project.create_entry(new_project_path.clone(), is_dir, cx)
1842 });
1843 } else {
1844 let new_path = if let Some(parent) = entry.path.parent() {
1845 parent.join(&filename)
1846 } else {
1847 filename.clone()
1848 };
1849 if let Some(existing) = worktree.read(cx).entry_for_path(&new_path) {
1850 if existing.id == entry.id && refocus {
1851 window.focus(&self.focus_handle, cx);
1852 }
1853 return None;
1854 }
1855 edited_entry_id = entry.id;
1856 edited_entry = Some(entry);
1857 new_project_path = (worktree_id, new_path).into();
1858 edit_task = self.project.update(cx, |project, cx| {
1859 project.rename_entry(edited_entry_id, new_project_path.clone(), cx)
1860 })
1861 };
1862
1863 if refocus {
1864 window.focus(&self.focus_handle, cx);
1865 }
1866 edit_state.processing_filename = Some(filename);
1867 cx.notify();
1868
1869 Some(cx.spawn_in(window, async move |project_panel, cx| {
1870 let new_entry = edit_task.await;
1871 project_panel.update(cx, |project_panel, cx| {
1872 project_panel.state.edit_state = None;
1873
1874 // Record the operation if the edit was applied
1875 if new_entry.is_ok() {
1876 let operation = if let Some(old_entry) = edited_entry {
1877 ProjectPanelOperation::Rename {
1878 old_path: (worktree_id, old_entry.path).into(),
1879 new_path: new_project_path,
1880 }
1881 } else {
1882 ProjectPanelOperation::Create {
1883 project_path: new_project_path,
1884 }
1885 };
1886 project_panel.undo_manager.record(operation);
1887 }
1888
1889 cx.notify();
1890 })?;
1891
1892 match new_entry {
1893 Err(e) => {
1894 project_panel
1895 .update_in(cx, |project_panel, window, cx| {
1896 project_panel.marked_entries.clear();
1897 project_panel.update_visible_entries(None, false, false, window, cx);
1898 })
1899 .ok();
1900 Err(e)?;
1901 }
1902 Ok(CreatedEntry::Included(new_entry)) => {
1903 project_panel.update_in(cx, |project_panel, window, cx| {
1904 if let Some(selection) = &mut project_panel.selection
1905 && selection.entry_id == edited_entry_id
1906 {
1907 selection.worktree_id = worktree_id;
1908 selection.entry_id = new_entry.id;
1909 project_panel.marked_entries.clear();
1910 project_panel.expand_to_selection(cx);
1911 }
1912 project_panel.update_visible_entries(None, false, false, window, cx);
1913 if is_new_entry && !is_dir {
1914 let settings = ProjectPanelSettings::get_global(cx);
1915 if settings.auto_open.should_open_on_create() {
1916 project_panel.open_entry(new_entry.id, true, false, cx);
1917 }
1918 }
1919 cx.notify();
1920 })?;
1921 }
1922 Ok(CreatedEntry::Excluded { abs_path }) => {
1923 if let Some(open_task) = project_panel
1924 .update_in(cx, |project_panel, window, cx| {
1925 project_panel.marked_entries.clear();
1926 project_panel.update_visible_entries(None, false, false, window, cx);
1927
1928 if is_dir {
1929 project_panel.project.update(cx, |_, cx| {
1930 cx.emit(project::Event::Toast {
1931 notification_id: "excluded-directory".into(),
1932 message: format!(
1933 concat!(
1934 "Created an excluded directory at {:?}.\n",
1935 "Alter `file_scan_exclusions` in the settings ",
1936 "to show it in the panel"
1937 ),
1938 abs_path
1939 ),
1940 link: None,
1941 })
1942 });
1943 None
1944 } else {
1945 project_panel
1946 .workspace
1947 .update(cx, |workspace, cx| {
1948 workspace.open_abs_path(
1949 abs_path,
1950 OpenOptions {
1951 visible: Some(OpenVisible::All),
1952 ..Default::default()
1953 },
1954 window,
1955 cx,
1956 )
1957 })
1958 .ok()
1959 }
1960 })
1961 .ok()
1962 .flatten()
1963 {
1964 let _ = open_task.await?;
1965 }
1966 }
1967 }
1968 Ok(())
1969 }))
1970 }
1971
1972 fn discard_edit_state(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1973 if let Some(edit_state) = self.state.edit_state.take() {
1974 self.state.temporarily_unfolded_pending_state = edit_state
1975 .temporarily_unfolded
1976 .and_then(|temporarily_unfolded_entry_id| {
1977 let previously_focused_leaf_entry = edit_state.previously_focused?;
1978 let folded_ancestors =
1979 self.state.ancestors.get(&temporarily_unfolded_entry_id)?;
1980 Some(TemporaryUnfoldedPendingState {
1981 previously_focused_leaf_entry,
1982 temporarily_unfolded_active_entry_id: folded_ancestors
1983 .active_ancestor()
1984 .unwrap_or(temporarily_unfolded_entry_id),
1985 })
1986 });
1987 let previously_focused = edit_state
1988 .previously_focused
1989 .map(|entry| (entry.worktree_id, entry.entry_id));
1990 self.update_visible_entries(
1991 previously_focused,
1992 false,
1993 previously_focused.is_some(),
1994 window,
1995 cx,
1996 );
1997 }
1998 }
1999
2000 fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context<Self>) {
2001 if cx.stop_active_drag(window) {
2002 self.drag_target_entry.take();
2003 self.hover_expand_task.take();
2004 return;
2005 }
2006 self.marked_entries.clear();
2007 cx.notify();
2008 self.discard_edit_state(window, cx);
2009 window.focus(&self.focus_handle, cx);
2010 }
2011
2012 fn open_entry(
2013 &mut self,
2014 entry_id: ProjectEntryId,
2015 focus_opened_item: bool,
2016 allow_preview: bool,
2017
2018 cx: &mut Context<Self>,
2019 ) {
2020 cx.emit(Event::OpenedEntry {
2021 entry_id,
2022 focus_opened_item,
2023 allow_preview,
2024 });
2025 }
2026
2027 fn split_entry(
2028 &mut self,
2029 entry_id: ProjectEntryId,
2030 allow_preview: bool,
2031 split_direction: Option<SplitDirection>,
2032
2033 cx: &mut Context<Self>,
2034 ) {
2035 cx.emit(Event::SplitEntry {
2036 entry_id,
2037 allow_preview,
2038 split_direction,
2039 });
2040 }
2041
2042 fn new_file(&mut self, _: &NewFile, window: &mut Window, cx: &mut Context<Self>) {
2043 self.add_entry(false, window, cx)
2044 }
2045
2046 fn new_directory(&mut self, _: &NewDirectory, window: &mut Window, cx: &mut Context<Self>) {
2047 self.add_entry(true, window, cx)
2048 }
2049
2050 fn add_entry(&mut self, is_dir: bool, window: &mut Window, cx: &mut Context<Self>) {
2051 let Some((worktree_id, entry_id)) = self
2052 .selection
2053 .map(|entry| (entry.worktree_id, entry.entry_id))
2054 .or_else(|| {
2055 let entry_id = self.state.last_worktree_root_id?;
2056 let worktree_id = self
2057 .project
2058 .read(cx)
2059 .worktree_for_entry(entry_id, cx)?
2060 .read(cx)
2061 .id();
2062
2063 self.selection = Some(SelectedEntry {
2064 worktree_id,
2065 entry_id,
2066 });
2067
2068 Some((worktree_id, entry_id))
2069 })
2070 else {
2071 return;
2072 };
2073
2074 let directory_id;
2075 let new_entry_id = self.resolve_entry(entry_id);
2076 if let Some((worktree, expanded_dir_ids)) = self
2077 .project
2078 .read(cx)
2079 .worktree_for_id(worktree_id, cx)
2080 .zip(self.state.expanded_dir_ids.get_mut(&worktree_id))
2081 {
2082 let worktree = worktree.read(cx);
2083 if let Some(mut entry) = worktree.entry_for_id(new_entry_id) {
2084 loop {
2085 if entry.is_dir() {
2086 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
2087 expanded_dir_ids.insert(ix, entry.id);
2088 }
2089 directory_id = entry.id;
2090 break;
2091 } else {
2092 if let Some(parent_path) = entry.path.parent()
2093 && let Some(parent_entry) = worktree.entry_for_path(parent_path)
2094 {
2095 entry = parent_entry;
2096 continue;
2097 }
2098 return;
2099 }
2100 }
2101 } else {
2102 return;
2103 };
2104 } else {
2105 return;
2106 };
2107
2108 self.marked_entries.clear();
2109 self.state.edit_state = Some(EditState {
2110 worktree_id,
2111 entry_id: directory_id,
2112 leaf_entry_id: None,
2113 is_dir,
2114 processing_filename: None,
2115 previously_focused: self.selection,
2116 depth: 0,
2117 validation_state: ValidationState::None,
2118 temporarily_unfolded: (new_entry_id != entry_id).then_some(new_entry_id),
2119 });
2120 self.filename_editor.update(cx, |editor, cx| {
2121 editor.clear(window, cx);
2122 });
2123 self.update_visible_entries(Some((worktree_id, NEW_ENTRY_ID)), true, true, window, cx);
2124 cx.notify();
2125 }
2126
2127 fn unflatten_entry_id(&self, leaf_entry_id: ProjectEntryId) -> ProjectEntryId {
2128 if let Some(ancestors) = self.state.ancestors.get(&leaf_entry_id) {
2129 ancestors
2130 .ancestors
2131 .get(ancestors.current_ancestor_depth)
2132 .copied()
2133 .unwrap_or(leaf_entry_id)
2134 } else {
2135 leaf_entry_id
2136 }
2137 }
2138
2139 pub fn undo(&mut self, _: &Undo, _window: &mut Window, cx: &mut Context<Self>) {
2140 self.undo_manager.undo(cx);
2141 cx.notify();
2142 }
2143
2144 fn rename_impl(
2145 &mut self,
2146 selection: Option<Range<usize>>,
2147 window: &mut Window,
2148 cx: &mut Context<Self>,
2149 ) {
2150 if let Some(SelectedEntry {
2151 worktree_id,
2152 entry_id,
2153 }) = self.selection
2154 && let Some(worktree) = self.project.read(cx).worktree_for_id(worktree_id, cx)
2155 {
2156 let sub_entry_id = self.unflatten_entry_id(entry_id);
2157 if let Some(entry) = worktree.read(cx).entry_for_id(sub_entry_id) {
2158 #[cfg(target_os = "windows")]
2159 if Some(entry) == worktree.read(cx).root_entry() {
2160 return;
2161 }
2162
2163 if Some(entry) == worktree.read(cx).root_entry() {
2164 let settings = ProjectPanelSettings::get_global(cx);
2165 let visible_worktrees_count =
2166 self.project.read(cx).visible_worktrees(cx).count();
2167 if settings.hide_root && visible_worktrees_count == 1 {
2168 return;
2169 }
2170 }
2171
2172 self.state.edit_state = Some(EditState {
2173 worktree_id,
2174 entry_id: sub_entry_id,
2175 leaf_entry_id: Some(entry_id),
2176 is_dir: entry.is_dir(),
2177 processing_filename: None,
2178 previously_focused: None,
2179 depth: 0,
2180 validation_state: ValidationState::None,
2181 temporarily_unfolded: None,
2182 });
2183 let file_name = entry.path.file_name().unwrap_or_default().to_string();
2184 let selection = selection.unwrap_or_else(|| {
2185 let file_stem = entry.path.file_stem().map(|s| s.to_string());
2186 let selection_end =
2187 file_stem.map_or(file_name.len(), |file_stem| file_stem.len());
2188 0..selection_end
2189 });
2190 self.filename_editor.update(cx, |editor, cx| {
2191 editor.set_text(file_name, window, cx);
2192 editor.change_selections(Default::default(), window, cx, |s| {
2193 s.select_ranges([
2194 MultiBufferOffset(selection.start)..MultiBufferOffset(selection.end)
2195 ])
2196 });
2197 });
2198 self.update_visible_entries(None, true, true, window, cx);
2199 cx.notify();
2200 }
2201 }
2202 }
2203
2204 fn rename(&mut self, _: &Rename, window: &mut Window, cx: &mut Context<Self>) {
2205 self.rename_impl(None, window, cx);
2206 }
2207
2208 fn trash(&mut self, action: &Trash, window: &mut Window, cx: &mut Context<Self>) {
2209 self.remove(true, action.skip_prompt, window, cx);
2210 }
2211
2212 fn delete(&mut self, action: &Delete, window: &mut Window, cx: &mut Context<Self>) {
2213 self.remove(false, action.skip_prompt, window, cx);
2214 }
2215
2216 fn restore_file(
2217 &mut self,
2218 action: &git::RestoreFile,
2219 window: &mut Window,
2220 cx: &mut Context<Self>,
2221 ) {
2222 maybe!({
2223 let selection = self.selection?;
2224 let project = self.project.read(cx);
2225
2226 let (_worktree, entry) = self.selected_sub_entry(cx)?;
2227 if entry.is_dir() {
2228 return None;
2229 }
2230
2231 let project_path = project.path_for_entry(selection.entry_id, cx)?;
2232
2233 let git_store = project.git_store();
2234 let (repository, repo_path) = git_store
2235 .read(cx)
2236 .repository_and_path_for_project_path(&project_path, cx)?;
2237
2238 let snapshot = repository.read(cx).snapshot();
2239 let status = snapshot.status_for_path(&repo_path)?;
2240 if !status.status.is_modified() && !status.status.is_deleted() {
2241 return None;
2242 }
2243
2244 let file_name = entry.path.file_name()?.to_string();
2245
2246 let answer = if !action.skip_prompt {
2247 let prompt = format!("Discard changes to {}?", file_name);
2248 Some(window.prompt(PromptLevel::Info, &prompt, None, &["Restore", "Cancel"], cx))
2249 } else {
2250 None
2251 };
2252
2253 cx.spawn_in(window, async move |panel, cx| {
2254 if let Some(answer) = answer
2255 && answer.await != Ok(0)
2256 {
2257 return anyhow::Ok(());
2258 }
2259
2260 let task = panel.update(cx, |_panel, cx| {
2261 repository.update(cx, |repo, cx| {
2262 repo.checkout_files("HEAD", vec![repo_path], cx)
2263 })
2264 })?;
2265
2266 if let Err(e) = task.await {
2267 panel
2268 .update(cx, |panel, cx| {
2269 let message = format!("Failed to restore {}: {}", file_name, e);
2270 let toast = StatusToast::new(message, cx, |this, _| {
2271 this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error))
2272 .dismiss_button(true)
2273 });
2274 panel
2275 .workspace
2276 .update(cx, |workspace, cx| {
2277 workspace.toggle_status_toast(toast, cx);
2278 })
2279 .ok();
2280 })
2281 .ok();
2282 }
2283
2284 panel
2285 .update(cx, |panel, cx| {
2286 panel.project.update(cx, |project, cx| {
2287 if let Some(buffer_id) = project
2288 .buffer_store()
2289 .read(cx)
2290 .buffer_id_for_project_path(&project_path)
2291 {
2292 if let Some(buffer) = project.buffer_for_id(*buffer_id, cx) {
2293 buffer.update(cx, |buffer, cx| {
2294 let _ = buffer.reload(cx);
2295 });
2296 }
2297 }
2298 })
2299 })
2300 .ok();
2301
2302 anyhow::Ok(())
2303 })
2304 .detach_and_log_err(cx);
2305
2306 Some(())
2307 });
2308 }
2309
2310 fn remove(
2311 &mut self,
2312 trash: bool,
2313 skip_prompt: bool,
2314 window: &mut Window,
2315 cx: &mut Context<ProjectPanel>,
2316 ) {
2317 maybe!({
2318 let items_to_delete = self.disjoint_effective_entries(cx);
2319 if items_to_delete.is_empty() {
2320 return None;
2321 }
2322 let project = self.project.read(cx);
2323
2324 let mut dirty_buffers = 0;
2325 let file_paths = items_to_delete
2326 .iter()
2327 .filter_map(|selection| {
2328 let project_path = project.path_for_entry(selection.entry_id, cx)?;
2329 dirty_buffers +=
2330 project.dirty_buffers(cx).any(|path| path == project_path) as usize;
2331
2332 Some((
2333 selection.entry_id,
2334 project_path.path.file_name()?.to_string(),
2335 ))
2336 })
2337 .collect::<Vec<_>>();
2338 if file_paths.is_empty() {
2339 return None;
2340 }
2341 let answer = if !skip_prompt {
2342 let operation = if trash { "Trash" } else { "Delete" };
2343 let message_start = if trash {
2344 "Do you want to trash"
2345 } else {
2346 "Are you sure you want to permanently delete"
2347 };
2348 let prompt = match file_paths.first() {
2349 Some((_, path)) if file_paths.len() == 1 => {
2350 let unsaved_warning = if dirty_buffers > 0 {
2351 "\n\nIt has unsaved changes, which will be lost."
2352 } else {
2353 ""
2354 };
2355
2356 format!("{message_start} {path}?{unsaved_warning}")
2357 }
2358 _ => {
2359 const CUTOFF_POINT: usize = 10;
2360 let names = if file_paths.len() > CUTOFF_POINT {
2361 let truncated_path_counts = file_paths.len() - CUTOFF_POINT;
2362 let mut paths = file_paths
2363 .iter()
2364 .map(|(_, path)| path.clone())
2365 .take(CUTOFF_POINT)
2366 .collect::<Vec<_>>();
2367 paths.truncate(CUTOFF_POINT);
2368 if truncated_path_counts == 1 {
2369 paths.push(".. 1 file not shown".into());
2370 } else {
2371 paths.push(format!(".. {} files not shown", truncated_path_counts));
2372 }
2373 paths
2374 } else {
2375 file_paths.iter().map(|(_, path)| path.clone()).collect()
2376 };
2377 let unsaved_warning = if dirty_buffers == 0 {
2378 String::new()
2379 } else if dirty_buffers == 1 {
2380 "\n\n1 of these has unsaved changes, which will be lost.".to_string()
2381 } else {
2382 format!(
2383 "\n\n{dirty_buffers} of these have unsaved changes, which will be lost."
2384 )
2385 };
2386
2387 format!(
2388 "{message_start} the following {} files?\n{}{unsaved_warning}",
2389 file_paths.len(),
2390 names.join("\n")
2391 )
2392 }
2393 };
2394 let detail = (!trash).then_some("This cannot be undone.");
2395 Some(window.prompt(
2396 PromptLevel::Info,
2397 &prompt,
2398 detail,
2399 &[operation, "Cancel"],
2400 cx,
2401 ))
2402 } else {
2403 None
2404 };
2405 let next_selection = self.find_next_selection_after_deletion(items_to_delete, cx);
2406 cx.spawn_in(window, async move |panel, cx| {
2407 if let Some(answer) = answer
2408 && answer.await != Ok(0)
2409 {
2410 return anyhow::Ok(());
2411 }
2412 for (entry_id, _) in file_paths {
2413 panel
2414 .update(cx, |panel, cx| {
2415 panel
2416 .project
2417 .update(cx, |project, cx| project.delete_entry(entry_id, trash, cx))
2418 .context("no such entry")
2419 })??
2420 .await?;
2421 }
2422 panel.update_in(cx, |panel, window, cx| {
2423 if let Some(next_selection) = next_selection {
2424 panel.update_visible_entries(
2425 Some((next_selection.worktree_id, next_selection.entry_id)),
2426 false,
2427 true,
2428 window,
2429 cx,
2430 );
2431 } else {
2432 panel.select_last(&SelectLast {}, window, cx);
2433 }
2434 })?;
2435 Ok(())
2436 })
2437 .detach_and_log_err(cx);
2438 Some(())
2439 });
2440 }
2441
2442 fn find_next_selection_after_deletion(
2443 &self,
2444 sanitized_entries: BTreeSet<SelectedEntry>,
2445 cx: &mut Context<Self>,
2446 ) -> Option<SelectedEntry> {
2447 if sanitized_entries.is_empty() {
2448 return None;
2449 }
2450 let project = self.project.read(cx);
2451 let (worktree_id, worktree) = sanitized_entries
2452 .iter()
2453 .map(|entry| entry.worktree_id)
2454 .filter_map(|id| project.worktree_for_id(id, cx).map(|w| (id, w.read(cx))))
2455 .max_by(|(_, a), (_, b)| a.root_name().cmp(b.root_name()))?;
2456 let git_store = project.git_store().read(cx);
2457
2458 let marked_entries_in_worktree = sanitized_entries
2459 .iter()
2460 .filter(|e| e.worktree_id == worktree_id)
2461 .collect::<HashSet<_>>();
2462 let latest_entry = marked_entries_in_worktree
2463 .iter()
2464 .max_by(|a, b| {
2465 match (
2466 worktree.entry_for_id(a.entry_id),
2467 worktree.entry_for_id(b.entry_id),
2468 ) {
2469 (Some(a), Some(b)) => compare_paths(
2470 (a.path.as_std_path(), a.is_file()),
2471 (b.path.as_std_path(), b.is_file()),
2472 ),
2473 _ => cmp::Ordering::Equal,
2474 }
2475 })
2476 .and_then(|e| worktree.entry_for_id(e.entry_id))?;
2477
2478 let parent_path = latest_entry.path.parent()?;
2479 let parent_entry = worktree.entry_for_path(parent_path)?;
2480
2481 // Remove all siblings that are being deleted except the last marked entry
2482 let repo_snapshots = git_store.repo_snapshots(cx);
2483 let worktree_snapshot = worktree.snapshot();
2484 let hide_gitignore = ProjectPanelSettings::get_global(cx).hide_gitignore;
2485 let mut siblings: Vec<_> =
2486 ChildEntriesGitIter::new(&repo_snapshots, &worktree_snapshot, parent_path)
2487 .filter(|sibling| {
2488 (sibling.id == latest_entry.id)
2489 || (!marked_entries_in_worktree.contains(&&SelectedEntry {
2490 worktree_id,
2491 entry_id: sibling.id,
2492 }) && (!hide_gitignore || !sibling.is_ignored))
2493 })
2494 .map(|entry| entry.to_owned())
2495 .collect();
2496
2497 let sort_mode = ProjectPanelSettings::get_global(cx).sort_mode;
2498 let sort_order = ProjectPanelSettings::get_global(cx).sort_order;
2499 sort_worktree_entries(&mut siblings, sort_mode, sort_order);
2500 let sibling_entry_index = siblings
2501 .iter()
2502 .position(|sibling| sibling.id == latest_entry.id)?;
2503
2504 if let Some(next_sibling) = sibling_entry_index
2505 .checked_add(1)
2506 .and_then(|i| siblings.get(i))
2507 {
2508 return Some(SelectedEntry {
2509 worktree_id,
2510 entry_id: next_sibling.id,
2511 });
2512 }
2513 if let Some(prev_sibling) = sibling_entry_index
2514 .checked_sub(1)
2515 .and_then(|i| siblings.get(i))
2516 {
2517 return Some(SelectedEntry {
2518 worktree_id,
2519 entry_id: prev_sibling.id,
2520 });
2521 }
2522 // No neighbour sibling found, fall back to parent
2523 Some(SelectedEntry {
2524 worktree_id,
2525 entry_id: parent_entry.id,
2526 })
2527 }
2528
2529 fn unfold_directory(
2530 &mut self,
2531 _: &UnfoldDirectory,
2532 window: &mut Window,
2533 cx: &mut Context<Self>,
2534 ) {
2535 if let Some((worktree, entry)) = self.selected_entry(cx) {
2536 self.state.unfolded_dir_ids.insert(entry.id);
2537
2538 let snapshot = worktree.snapshot();
2539 let mut parent_path = entry.path.parent();
2540 while let Some(path) = parent_path {
2541 if let Some(parent_entry) = worktree.entry_for_path(path) {
2542 let mut children_iter = snapshot.child_entries(path);
2543
2544 if children_iter.by_ref().take(2).count() > 1 {
2545 break;
2546 }
2547
2548 self.state.unfolded_dir_ids.insert(parent_entry.id);
2549 parent_path = path.parent();
2550 } else {
2551 break;
2552 }
2553 }
2554
2555 self.update_visible_entries(None, false, true, window, cx);
2556 cx.notify();
2557 }
2558 }
2559
2560 fn fold_directory(&mut self, _: &FoldDirectory, window: &mut Window, cx: &mut Context<Self>) {
2561 if let Some((worktree, entry)) = self.selected_entry(cx) {
2562 self.state.unfolded_dir_ids.remove(&entry.id);
2563
2564 let snapshot = worktree.snapshot();
2565 let mut path = &*entry.path;
2566 loop {
2567 let mut child_entries_iter = snapshot.child_entries(path);
2568 if let Some(child) = child_entries_iter.next() {
2569 if child_entries_iter.next().is_none() && child.is_dir() {
2570 self.state.unfolded_dir_ids.remove(&child.id);
2571 path = &*child.path;
2572 } else {
2573 break;
2574 }
2575 } else {
2576 break;
2577 }
2578 }
2579
2580 self.update_visible_entries(None, false, true, window, cx);
2581 cx.notify();
2582 }
2583 }
2584
2585 fn scroll_up(&mut self, _: &ScrollUp, window: &mut Window, cx: &mut Context<Self>) {
2586 for _ in 0..self.rendered_entries_len / 2 {
2587 window.dispatch_action(SelectPrevious.boxed_clone(), cx);
2588 }
2589 }
2590
2591 fn scroll_down(&mut self, _: &ScrollDown, window: &mut Window, cx: &mut Context<Self>) {
2592 for _ in 0..self.rendered_entries_len / 2 {
2593 window.dispatch_action(SelectNext.boxed_clone(), cx);
2594 }
2595 }
2596
2597 fn scroll_cursor_center(
2598 &mut self,
2599 _: &ScrollCursorCenter,
2600 _: &mut Window,
2601 cx: &mut Context<Self>,
2602 ) {
2603 if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) {
2604 self.scroll_handle
2605 .scroll_to_item_strict(index, ScrollStrategy::Center);
2606 cx.notify();
2607 }
2608 }
2609
2610 fn scroll_cursor_top(&mut self, _: &ScrollCursorTop, _: &mut Window, cx: &mut Context<Self>) {
2611 if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) {
2612 self.scroll_handle
2613 .scroll_to_item_strict(index, ScrollStrategy::Top);
2614 cx.notify();
2615 }
2616 }
2617
2618 fn scroll_cursor_bottom(
2619 &mut self,
2620 _: &ScrollCursorBottom,
2621 _: &mut Window,
2622 cx: &mut Context<Self>,
2623 ) {
2624 if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) {
2625 self.scroll_handle
2626 .scroll_to_item_strict(index, ScrollStrategy::Bottom);
2627 cx.notify();
2628 }
2629 }
2630
2631 fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
2632 if let Some(edit_state) = &self.state.edit_state
2633 && edit_state.processing_filename.is_none()
2634 {
2635 self.filename_editor.update(cx, |editor, cx| {
2636 editor.move_to_end_of_line(
2637 &editor::actions::MoveToEndOfLine {
2638 stop_at_soft_wraps: false,
2639 },
2640 window,
2641 cx,
2642 );
2643 });
2644 return;
2645 }
2646 if let Some(selection) = self.selection {
2647 let (mut worktree_ix, mut entry_ix, _) =
2648 self.index_for_selection(selection).unwrap_or_default();
2649 if let Some(worktree_entries) = self
2650 .state
2651 .visible_entries
2652 .get(worktree_ix)
2653 .map(|v| &v.entries)
2654 {
2655 if entry_ix + 1 < worktree_entries.len() {
2656 entry_ix += 1;
2657 } else {
2658 worktree_ix += 1;
2659 entry_ix = 0;
2660 }
2661 }
2662
2663 if let Some(VisibleEntriesForWorktree {
2664 worktree_id,
2665 entries,
2666 ..
2667 }) = self.state.visible_entries.get(worktree_ix)
2668 && let Some(entry) = entries.get(entry_ix)
2669 {
2670 let selection = SelectedEntry {
2671 worktree_id: *worktree_id,
2672 entry_id: entry.id,
2673 };
2674 self.selection = Some(selection);
2675 if window.modifiers().shift {
2676 self.marked_entries.push(selection);
2677 }
2678
2679 self.autoscroll(cx);
2680 cx.notify();
2681 }
2682 } else {
2683 self.select_first(&SelectFirst {}, window, cx);
2684 }
2685 }
2686
2687 fn select_prev_diagnostic(
2688 &mut self,
2689 action: &SelectPrevDiagnostic,
2690 window: &mut Window,
2691 cx: &mut Context<Self>,
2692 ) {
2693 let selection = self.find_entry(
2694 self.selection.as_ref(),
2695 true,
2696 &|entry: GitEntryRef, worktree_id: WorktreeId| {
2697 self.selection.is_none_or(|selection| {
2698 if selection.worktree_id == worktree_id {
2699 selection.entry_id != entry.id
2700 } else {
2701 true
2702 }
2703 }) && entry.is_file()
2704 && self
2705 .diagnostics
2706 .get(&(worktree_id, entry.path.clone()))
2707 .is_some_and(|severity| action.severity.matches(*severity))
2708 },
2709 cx,
2710 );
2711
2712 if let Some(selection) = selection {
2713 self.selection = Some(selection);
2714 self.expand_entry(selection.worktree_id, selection.entry_id, cx);
2715 self.update_visible_entries(
2716 Some((selection.worktree_id, selection.entry_id)),
2717 false,
2718 true,
2719 window,
2720 cx,
2721 );
2722 cx.notify();
2723 }
2724 }
2725
2726 fn select_next_diagnostic(
2727 &mut self,
2728 action: &SelectNextDiagnostic,
2729 window: &mut Window,
2730 cx: &mut Context<Self>,
2731 ) {
2732 let selection = self.find_entry(
2733 self.selection.as_ref(),
2734 false,
2735 &|entry: GitEntryRef, worktree_id: WorktreeId| {
2736 self.selection.is_none_or(|selection| {
2737 if selection.worktree_id == worktree_id {
2738 selection.entry_id != entry.id
2739 } else {
2740 true
2741 }
2742 }) && entry.is_file()
2743 && self
2744 .diagnostics
2745 .get(&(worktree_id, entry.path.clone()))
2746 .is_some_and(|severity| action.severity.matches(*severity))
2747 },
2748 cx,
2749 );
2750
2751 if let Some(selection) = selection {
2752 self.selection = Some(selection);
2753 self.expand_entry(selection.worktree_id, selection.entry_id, cx);
2754 self.update_visible_entries(
2755 Some((selection.worktree_id, selection.entry_id)),
2756 false,
2757 true,
2758 window,
2759 cx,
2760 );
2761 cx.notify();
2762 }
2763 }
2764
2765 fn select_prev_git_entry(
2766 &mut self,
2767 _: &SelectPrevGitEntry,
2768 window: &mut Window,
2769 cx: &mut Context<Self>,
2770 ) {
2771 let selection = self.find_entry(
2772 self.selection.as_ref(),
2773 true,
2774 &|entry: GitEntryRef, worktree_id: WorktreeId| {
2775 (self.selection.is_none()
2776 || self.selection.is_some_and(|selection| {
2777 if selection.worktree_id == worktree_id {
2778 selection.entry_id != entry.id
2779 } else {
2780 true
2781 }
2782 }))
2783 && entry.is_file()
2784 && entry.git_summary.index.modified + entry.git_summary.worktree.modified > 0
2785 },
2786 cx,
2787 );
2788
2789 if let Some(selection) = selection {
2790 self.selection = Some(selection);
2791 self.expand_entry(selection.worktree_id, selection.entry_id, cx);
2792 self.update_visible_entries(
2793 Some((selection.worktree_id, selection.entry_id)),
2794 false,
2795 true,
2796 window,
2797 cx,
2798 );
2799 cx.notify();
2800 }
2801 }
2802
2803 fn select_prev_directory(
2804 &mut self,
2805 _: &SelectPrevDirectory,
2806 _: &mut Window,
2807 cx: &mut Context<Self>,
2808 ) {
2809 let selection = self.find_visible_entry(
2810 self.selection.as_ref(),
2811 true,
2812 &|entry: GitEntryRef, worktree_id: WorktreeId| {
2813 self.selection.is_none_or(|selection| {
2814 if selection.worktree_id == worktree_id {
2815 selection.entry_id != entry.id
2816 } else {
2817 true
2818 }
2819 }) && entry.is_dir()
2820 },
2821 cx,
2822 );
2823
2824 if let Some(selection) = selection {
2825 self.selection = Some(selection);
2826 self.autoscroll(cx);
2827 cx.notify();
2828 }
2829 }
2830
2831 fn select_next_directory(
2832 &mut self,
2833 _: &SelectNextDirectory,
2834 _: &mut Window,
2835 cx: &mut Context<Self>,
2836 ) {
2837 let selection = self.find_visible_entry(
2838 self.selection.as_ref(),
2839 false,
2840 &|entry: GitEntryRef, worktree_id: WorktreeId| {
2841 self.selection.is_none_or(|selection| {
2842 if selection.worktree_id == worktree_id {
2843 selection.entry_id != entry.id
2844 } else {
2845 true
2846 }
2847 }) && entry.is_dir()
2848 },
2849 cx,
2850 );
2851
2852 if let Some(selection) = selection {
2853 self.selection = Some(selection);
2854 self.autoscroll(cx);
2855 cx.notify();
2856 }
2857 }
2858
2859 fn select_next_git_entry(
2860 &mut self,
2861 _: &SelectNextGitEntry,
2862 window: &mut Window,
2863 cx: &mut Context<Self>,
2864 ) {
2865 let selection = self.find_entry(
2866 self.selection.as_ref(),
2867 false,
2868 &|entry: GitEntryRef, worktree_id: WorktreeId| {
2869 self.selection.is_none_or(|selection| {
2870 if selection.worktree_id == worktree_id {
2871 selection.entry_id != entry.id
2872 } else {
2873 true
2874 }
2875 }) && entry.is_file()
2876 && entry.git_summary.index.modified + entry.git_summary.worktree.modified > 0
2877 },
2878 cx,
2879 );
2880
2881 if let Some(selection) = selection {
2882 self.selection = Some(selection);
2883 self.expand_entry(selection.worktree_id, selection.entry_id, cx);
2884 self.update_visible_entries(
2885 Some((selection.worktree_id, selection.entry_id)),
2886 false,
2887 true,
2888 window,
2889 cx,
2890 );
2891 cx.notify();
2892 }
2893 }
2894
2895 fn select_parent(&mut self, _: &SelectParent, window: &mut Window, cx: &mut Context<Self>) {
2896 if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
2897 if let Some(parent) = entry.path.parent() {
2898 let worktree = worktree.read(cx);
2899 if let Some(parent_entry) = worktree.entry_for_path(parent) {
2900 self.selection = Some(SelectedEntry {
2901 worktree_id: worktree.id(),
2902 entry_id: parent_entry.id,
2903 });
2904 self.autoscroll(cx);
2905 cx.notify();
2906 }
2907 }
2908 } else {
2909 self.select_first(&SelectFirst {}, window, cx);
2910 }
2911 }
2912
2913 fn select_first(&mut self, _: &SelectFirst, window: &mut Window, cx: &mut Context<Self>) {
2914 if let Some(VisibleEntriesForWorktree {
2915 worktree_id,
2916 entries,
2917 ..
2918 }) = self.state.visible_entries.first()
2919 && let Some(entry) = entries.first()
2920 {
2921 let selection = SelectedEntry {
2922 worktree_id: *worktree_id,
2923 entry_id: entry.id,
2924 };
2925 self.selection = Some(selection);
2926 if window.modifiers().shift {
2927 self.marked_entries.push(selection);
2928 }
2929 self.autoscroll(cx);
2930 cx.notify();
2931 }
2932 }
2933
2934 fn select_last(&mut self, _: &SelectLast, _: &mut Window, cx: &mut Context<Self>) {
2935 if let Some(VisibleEntriesForWorktree {
2936 worktree_id,
2937 entries,
2938 ..
2939 }) = self.state.visible_entries.last()
2940 {
2941 let worktree = self.project.read(cx).worktree_for_id(*worktree_id, cx);
2942 if let (Some(worktree), Some(entry)) = (worktree, entries.last()) {
2943 let worktree = worktree.read(cx);
2944 if let Some(entry) = worktree.entry_for_id(entry.id) {
2945 let selection = SelectedEntry {
2946 worktree_id: *worktree_id,
2947 entry_id: entry.id,
2948 };
2949 self.selection = Some(selection);
2950 self.autoscroll(cx);
2951 cx.notify();
2952 }
2953 }
2954 }
2955 }
2956
2957 fn autoscroll(&mut self, cx: &mut Context<Self>) {
2958 if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) {
2959 self.scroll_handle.scroll_to_item_with_offset(
2960 index,
2961 ScrollStrategy::Center,
2962 self.sticky_items_count,
2963 );
2964 cx.notify();
2965 }
2966 }
2967
2968 fn cut(&mut self, _: &Cut, _: &mut Window, cx: &mut Context<Self>) {
2969 let entries = self.disjoint_effective_entries(cx);
2970 if !entries.is_empty() {
2971 self.write_entries_to_system_clipboard(&entries, cx);
2972 self.clipboard = Some(ClipboardEntry::Cut(entries));
2973 cx.notify();
2974 }
2975 }
2976
2977 fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context<Self>) {
2978 let entries = self.disjoint_effective_entries(cx);
2979 if !entries.is_empty() {
2980 self.write_entries_to_system_clipboard(&entries, cx);
2981 self.clipboard = Some(ClipboardEntry::Copied(entries));
2982 cx.notify();
2983 }
2984 }
2985
2986 fn create_paste_path(
2987 &self,
2988 source: &SelectedEntry,
2989 (worktree, target_entry): (Entity<Worktree>, &Entry),
2990 cx: &App,
2991 ) -> Option<(Arc<RelPath>, Option<Range<usize>>)> {
2992 let mut new_path = target_entry.path.to_rel_path_buf();
2993 // If we're pasting into a file, or a directory into itself, go up one level.
2994 if target_entry.is_file() || (target_entry.is_dir() && target_entry.id == source.entry_id) {
2995 new_path.pop();
2996 }
2997
2998 let source_worktree = self
2999 .project
3000 .read(cx)
3001 .worktree_for_entry(source.entry_id, cx)?;
3002 let source_entry = source_worktree.read(cx).entry_for_id(source.entry_id)?;
3003
3004 let clipboard_entry_file_name = source_entry.path.file_name()?.to_string();
3005 new_path.push(RelPath::unix(&clipboard_entry_file_name).unwrap());
3006
3007 let (extension, file_name_without_extension) = if source_entry.is_file() {
3008 (
3009 new_path.extension().map(|s| s.to_string()),
3010 new_path.file_stem()?.to_string(),
3011 )
3012 } else {
3013 (None, clipboard_entry_file_name.clone())
3014 };
3015
3016 let file_name_len = file_name_without_extension.len();
3017 let mut disambiguation_range = None;
3018 let mut ix = 0;
3019 {
3020 let worktree = worktree.read(cx);
3021 while worktree.entry_for_path(&new_path).is_some() {
3022 new_path.pop();
3023
3024 let mut new_file_name = file_name_without_extension.to_string();
3025
3026 let disambiguation = " copy";
3027 let mut disambiguation_len = disambiguation.len();
3028
3029 new_file_name.push_str(disambiguation);
3030
3031 if ix > 0 {
3032 let extra_disambiguation = format!(" {}", ix);
3033 disambiguation_len += extra_disambiguation.len();
3034 new_file_name.push_str(&extra_disambiguation);
3035 }
3036 if let Some(extension) = extension.as_ref() {
3037 new_file_name.push_str(".");
3038 new_file_name.push_str(extension);
3039 }
3040
3041 new_path.push(RelPath::unix(&new_file_name).unwrap());
3042
3043 disambiguation_range = Some(0..(file_name_len + disambiguation_len));
3044 ix += 1;
3045 }
3046 }
3047 Some((new_path.as_rel_path().into(), disambiguation_range))
3048 }
3049
3050 fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
3051 if let Some(external_paths) = self.external_paths_from_system_clipboard(cx) {
3052 let target_entry_id = self
3053 .selection
3054 .map(|s| s.entry_id)
3055 .or(self.state.last_worktree_root_id);
3056 if let Some(entry_id) = target_entry_id {
3057 self.drop_external_files(external_paths.paths(), entry_id, window, cx);
3058 }
3059 return;
3060 }
3061
3062 maybe!({
3063 let (worktree, entry) = self.selected_entry_handle(cx)?;
3064 let entry = entry.clone();
3065 let worktree_id = worktree.read(cx).id();
3066 let clipboard_entries = self
3067 .clipboard
3068 .as_ref()
3069 .filter(|clipboard| !clipboard.items().is_empty())?;
3070
3071 enum PasteTask {
3072 Rename {
3073 task: Task<Result<CreatedEntry>>,
3074 old_path: ProjectPath,
3075 new_path: ProjectPath,
3076 },
3077 Copy {
3078 task: Task<Result<Option<Entry>>>,
3079 destination: ProjectPath,
3080 },
3081 }
3082
3083 let mut paste_tasks = Vec::new();
3084 let mut disambiguation_range = None;
3085 let clip_is_cut = clipboard_entries.is_cut();
3086 for clipboard_entry in clipboard_entries.items() {
3087 let (new_path, new_disambiguation_range) =
3088 self.create_paste_path(clipboard_entry, self.selected_sub_entry(cx)?, cx)?;
3089 let clip_entry_id = clipboard_entry.entry_id;
3090 let destination: ProjectPath = (worktree_id, new_path).into();
3091 let task = if clipboard_entries.is_cut() {
3092 let old_path = self.project.read(cx).path_for_entry(clip_entry_id, cx)?;
3093 let task = self.project.update(cx, |project, cx| {
3094 project.rename_entry(clip_entry_id, destination.clone(), cx)
3095 });
3096 PasteTask::Rename {
3097 task,
3098 old_path,
3099 new_path: destination,
3100 }
3101 } else {
3102 let task = self.project.update(cx, |project, cx| {
3103 project.copy_entry(clip_entry_id, destination.clone(), cx)
3104 });
3105 PasteTask::Copy { task, destination }
3106 };
3107 paste_tasks.push(task);
3108 disambiguation_range = new_disambiguation_range.or(disambiguation_range);
3109 }
3110
3111 let item_count = paste_tasks.len();
3112 let workspace = self.workspace.clone();
3113
3114 cx.spawn_in(window, async move |project_panel, mut cx| {
3115 let mut last_succeed = None;
3116 let mut operations = Vec::new();
3117
3118 for task in paste_tasks {
3119 match task {
3120 PasteTask::Rename {
3121 task,
3122 old_path,
3123 new_path,
3124 } => {
3125 if let Some(CreatedEntry::Included(entry)) = task
3126 .await
3127 .notify_workspace_async_err(workspace.clone(), &mut cx)
3128 {
3129 operations
3130 .push(ProjectPanelOperation::Rename { old_path, new_path });
3131 last_succeed = Some(entry);
3132 }
3133 }
3134 PasteTask::Copy { task, destination } => {
3135 if let Some(Some(entry)) = task
3136 .await
3137 .notify_workspace_async_err(workspace.clone(), &mut cx)
3138 {
3139 operations.push(ProjectPanelOperation::Create {
3140 project_path: destination,
3141 });
3142 last_succeed = Some(entry);
3143 }
3144 }
3145 }
3146 }
3147
3148 project_panel
3149 .update(cx, |this, _| {
3150 this.undo_manager.record_batch(operations);
3151 })
3152 .ok();
3153
3154 // update selection
3155 if let Some(entry) = last_succeed {
3156 project_panel
3157 .update_in(cx, |project_panel, window, cx| {
3158 project_panel.selection = Some(SelectedEntry {
3159 worktree_id,
3160 entry_id: entry.id,
3161 });
3162
3163 if item_count == 1 {
3164 // open entry if not dir, setting is enabled, and only focus if rename is not pending
3165 if !entry.is_dir() {
3166 let settings = ProjectPanelSettings::get_global(cx);
3167 if settings.auto_open.should_open_on_paste() {
3168 project_panel.open_entry(
3169 entry.id,
3170 disambiguation_range.is_none(),
3171 false,
3172 cx,
3173 );
3174 }
3175 }
3176
3177 // if only one entry was pasted and it was disambiguated, open the rename editor
3178 if disambiguation_range.is_some() {
3179 cx.defer_in(window, |this, window, cx| {
3180 this.rename_impl(disambiguation_range, window, cx);
3181 });
3182 }
3183 }
3184 })
3185 .ok();
3186 }
3187
3188 anyhow::Ok(())
3189 })
3190 .detach_and_log_err(cx);
3191
3192 if clip_is_cut {
3193 // Convert the clipboard cut entry to a copy entry after the first paste.
3194 self.clipboard = self.clipboard.take().map(ClipboardEntry::into_copy_entry);
3195 }
3196
3197 self.expand_entry(worktree_id, entry.id, cx);
3198 Some(())
3199 });
3200 }
3201
3202 fn download_from_remote(
3203 &mut self,
3204 _: &DownloadFromRemote,
3205 window: &mut Window,
3206 cx: &mut Context<Self>,
3207 ) {
3208 let entries = self.effective_entries();
3209 if entries.is_empty() {
3210 return;
3211 }
3212
3213 let project = self.project.read(cx);
3214
3215 // Collect file entries with their worktree_id, path, and relative path for destination
3216 // For directories, we collect all files under them recursively
3217 let mut files_to_download: Vec<(WorktreeId, Arc<RelPath>, PathBuf)> = Vec::new();
3218
3219 for selected in entries.iter() {
3220 let Some(worktree) = project.worktree_for_id(selected.worktree_id, cx) else {
3221 continue;
3222 };
3223 let worktree = worktree.read(cx);
3224 let Some(entry) = worktree.entry_for_id(selected.entry_id) else {
3225 continue;
3226 };
3227
3228 if entry.is_file() {
3229 // Single file: use just the filename
3230 let filename = entry
3231 .path
3232 .file_name()
3233 .map(str::to_string)
3234 .unwrap_or_default();
3235 files_to_download.push((
3236 selected.worktree_id,
3237 entry.path.clone(),
3238 PathBuf::from(filename),
3239 ));
3240 } else if entry.is_dir() {
3241 // Directory: collect all files recursively, preserving relative paths
3242 let dir_name = entry
3243 .path
3244 .file_name()
3245 .map(str::to_string)
3246 .unwrap_or_default();
3247 let base_path = entry.path.clone();
3248
3249 // Use traverse_from_path to iterate all entries under this directory
3250 let mut traversal = worktree.traverse_from_path(true, true, true, &entry.path);
3251 while let Some(child_entry) = traversal.entry() {
3252 // Stop when we're no longer under the directory
3253 if !child_entry.path.starts_with(&base_path) {
3254 break;
3255 }
3256
3257 if child_entry.is_file() {
3258 // Calculate relative path from the directory root
3259 let relative_path = child_entry
3260 .path
3261 .strip_prefix(&base_path)
3262 .map(|p| PathBuf::from(dir_name.clone()).join(p.as_unix_str()))
3263 .unwrap_or_else(|_| {
3264 PathBuf::from(
3265 child_entry
3266 .path
3267 .file_name()
3268 .map(str::to_string)
3269 .unwrap_or_default(),
3270 )
3271 });
3272 files_to_download.push((
3273 selected.worktree_id,
3274 child_entry.path.clone(),
3275 relative_path,
3276 ));
3277 }
3278 traversal.advance();
3279 }
3280 }
3281 }
3282
3283 if files_to_download.is_empty() {
3284 return;
3285 }
3286
3287 let total_files = files_to_download.len();
3288 let workspace = self.workspace.clone();
3289
3290 let destination_dir = cx.prompt_for_paths(PathPromptOptions {
3291 files: false,
3292 directories: true,
3293 multiple: false,
3294 prompt: Some("Download".into()),
3295 });
3296
3297 let fs = self.fs.clone();
3298 let notification_id =
3299 workspace::notifications::NotificationId::Named("download-progress".into());
3300 cx.spawn_in(window, async move |this, cx| {
3301 if let Ok(Ok(Some(mut paths))) = destination_dir.await {
3302 if let Some(dest_dir) = paths.pop() {
3303 // Show initial toast
3304 workspace
3305 .update(cx, |workspace, cx| {
3306 workspace.show_toast(
3307 workspace::Toast::new(
3308 notification_id.clone(),
3309 format!("Downloading 0/{} files...", total_files),
3310 ),
3311 cx,
3312 );
3313 })
3314 .ok();
3315
3316 for (index, (worktree_id, entry_path, relative_path)) in
3317 files_to_download.into_iter().enumerate()
3318 {
3319 // Update progress toast
3320 workspace
3321 .update(cx, |workspace, cx| {
3322 workspace.show_toast(
3323 workspace::Toast::new(
3324 notification_id.clone(),
3325 format!(
3326 "Downloading {}/{} files...",
3327 index + 1,
3328 total_files
3329 ),
3330 ),
3331 cx,
3332 );
3333 })
3334 .ok();
3335
3336 let destination_path = dest_dir.join(&relative_path);
3337
3338 // Create parent directories if needed
3339 if let Some(parent) = destination_path.parent() {
3340 if !parent.exists() {
3341 fs.create_dir(parent).await.log_err();
3342 }
3343 }
3344
3345 let download_task = this.update(cx, |this, cx| {
3346 let project = this.project.clone();
3347 project.update(cx, |project, cx| {
3348 project.download_file(worktree_id, entry_path, destination_path, cx)
3349 })
3350 });
3351 if let Ok(task) = download_task {
3352 task.await.log_err();
3353 }
3354 }
3355
3356 // Show completion toast
3357 workspace
3358 .update(cx, |workspace, cx| {
3359 workspace.show_toast(
3360 workspace::Toast::new(
3361 notification_id.clone(),
3362 format!("Downloaded {} files", total_files),
3363 ),
3364 cx,
3365 );
3366 })
3367 .ok();
3368 }
3369 }
3370 })
3371 .detach();
3372 }
3373
3374 fn duplicate(&mut self, _: &Duplicate, window: &mut Window, cx: &mut Context<Self>) {
3375 self.copy(&Copy {}, window, cx);
3376 self.paste(&Paste {}, window, cx);
3377 }
3378
3379 fn copy_path(
3380 &mut self,
3381 _: &zed_actions::workspace::CopyPath,
3382 _: &mut Window,
3383 cx: &mut Context<Self>,
3384 ) {
3385 let abs_file_paths = {
3386 let project = self.project.read(cx);
3387 self.effective_entries()
3388 .into_iter()
3389 .filter_map(|entry| {
3390 let entry_path = project.path_for_entry(entry.entry_id, cx)?.path;
3391 Some(
3392 project
3393 .worktree_for_id(entry.worktree_id, cx)?
3394 .read(cx)
3395 .absolutize(&entry_path)
3396 .to_string_lossy()
3397 .to_string(),
3398 )
3399 })
3400 .collect::<Vec<_>>()
3401 };
3402 if !abs_file_paths.is_empty() {
3403 cx.write_to_clipboard(ClipboardItem::new_string(abs_file_paths.join("\n")));
3404 }
3405 }
3406
3407 fn copy_relative_path(
3408 &mut self,
3409 _: &zed_actions::workspace::CopyRelativePath,
3410 _: &mut Window,
3411 cx: &mut Context<Self>,
3412 ) {
3413 let path_style = self.project.read(cx).path_style(cx);
3414 let file_paths = {
3415 let project = self.project.read(cx);
3416 self.effective_entries()
3417 .into_iter()
3418 .filter_map(|entry| {
3419 Some(
3420 project
3421 .path_for_entry(entry.entry_id, cx)?
3422 .path
3423 .display(path_style)
3424 .into_owned(),
3425 )
3426 })
3427 .collect::<Vec<_>>()
3428 };
3429 if !file_paths.is_empty() {
3430 cx.write_to_clipboard(ClipboardItem::new_string(file_paths.join("\n")));
3431 }
3432 }
3433
3434 fn reveal_in_finder(
3435 &mut self,
3436 _: &RevealInFileManager,
3437 _: &mut Window,
3438 cx: &mut Context<Self>,
3439 ) {
3440 if let Some(path) = self.reveal_in_file_manager_path(cx) {
3441 self.project
3442 .update(cx, |project, cx| project.reveal_path(&path, cx));
3443 }
3444 }
3445
3446 fn remove_from_project(
3447 &mut self,
3448 _: &RemoveFromProject,
3449 _window: &mut Window,
3450 cx: &mut Context<Self>,
3451 ) {
3452 for entry in self.effective_entries().iter() {
3453 let worktree_id = entry.worktree_id;
3454 self.project
3455 .update(cx, |project, cx| project.remove_worktree(worktree_id, cx));
3456 }
3457 }
3458
3459 fn file_abs_paths_to_diff(&self, cx: &Context<Self>) -> Option<(PathBuf, PathBuf)> {
3460 let mut selections_abs_path = self
3461 .marked_entries
3462 .iter()
3463 .filter_map(|entry| {
3464 let project = self.project.read(cx);
3465 let worktree = project.worktree_for_id(entry.worktree_id, cx)?;
3466 let entry = worktree.read(cx).entry_for_id(entry.entry_id)?;
3467 if !entry.is_file() {
3468 return None;
3469 }
3470 Some(worktree.read(cx).absolutize(&entry.path))
3471 })
3472 .rev();
3473
3474 let last_path = selections_abs_path.next()?;
3475 let previous_to_last = selections_abs_path.next()?;
3476 Some((previous_to_last, last_path))
3477 }
3478
3479 fn compare_marked_files(
3480 &mut self,
3481 _: &CompareMarkedFiles,
3482 window: &mut Window,
3483 cx: &mut Context<Self>,
3484 ) {
3485 let selected_files = self.file_abs_paths_to_diff(cx);
3486 if let Some((file_path1, file_path2)) = selected_files {
3487 self.workspace
3488 .update(cx, |workspace, cx| {
3489 FileDiffView::open(file_path1, file_path2, workspace.weak_handle(), window, cx)
3490 .detach_and_log_err(cx);
3491 })
3492 .ok();
3493 }
3494 }
3495
3496 fn open_system(&mut self, _: &OpenWithSystem, _: &mut Window, cx: &mut Context<Self>) {
3497 if let Some((worktree, entry)) = self.selected_entry(cx) {
3498 let abs_path = worktree.absolutize(&entry.path);
3499 cx.open_with_system(&abs_path);
3500 }
3501 }
3502
3503 fn open_in_terminal(
3504 &mut self,
3505 _: &OpenInTerminal,
3506 window: &mut Window,
3507 cx: &mut Context<Self>,
3508 ) {
3509 if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
3510 let abs_path = match &entry.canonical_path {
3511 Some(canonical_path) => canonical_path.to_path_buf(),
3512 None => worktree.read(cx).absolutize(&entry.path),
3513 };
3514
3515 let working_directory = if entry.is_dir() {
3516 Some(abs_path)
3517 } else {
3518 abs_path.parent().map(|path| path.to_path_buf())
3519 };
3520 if let Some(working_directory) = working_directory {
3521 window.dispatch_action(
3522 workspace::OpenTerminal {
3523 working_directory,
3524 local: false,
3525 }
3526 .boxed_clone(),
3527 cx,
3528 )
3529 }
3530 }
3531 }
3532
3533 pub fn new_search_in_directory(
3534 &mut self,
3535 _: &NewSearchInDirectory,
3536 window: &mut Window,
3537 cx: &mut Context<Self>,
3538 ) {
3539 if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
3540 let dir_path = if entry.is_dir() {
3541 entry.path.clone()
3542 } else {
3543 // entry is a file, use its parent directory
3544 match entry.path.parent() {
3545 Some(parent) => Arc::from(parent),
3546 None => {
3547 // File at root, open search with empty filter
3548 self.workspace
3549 .update(cx, |workspace, cx| {
3550 search::ProjectSearchView::new_search_in_directory(
3551 workspace,
3552 RelPath::empty(),
3553 window,
3554 cx,
3555 );
3556 })
3557 .ok();
3558 return;
3559 }
3560 }
3561 };
3562
3563 let include_root = self.project.read(cx).visible_worktrees(cx).count() > 1;
3564 let dir_path = if include_root {
3565 worktree.read(cx).root_name().join(&dir_path)
3566 } else {
3567 dir_path
3568 };
3569
3570 self.workspace
3571 .update(cx, |workspace, cx| {
3572 search::ProjectSearchView::new_search_in_directory(
3573 workspace, &dir_path, window, cx,
3574 );
3575 })
3576 .ok();
3577 }
3578 }
3579
3580 fn move_entry(
3581 &mut self,
3582 entry_to_move: ProjectEntryId,
3583 destination: ProjectEntryId,
3584 destination_is_file: bool,
3585 cx: &mut Context<Self>,
3586 ) -> Option<Task<Result<CreatedEntry>>> {
3587 if self
3588 .project
3589 .read(cx)
3590 .entry_is_worktree_root(entry_to_move, cx)
3591 {
3592 self.move_worktree_root(entry_to_move, destination, cx);
3593 None
3594 } else {
3595 self.move_worktree_entry(entry_to_move, destination, destination_is_file, cx)
3596 }
3597 }
3598
3599 fn move_worktree_root(
3600 &mut self,
3601 entry_to_move: ProjectEntryId,
3602 destination: ProjectEntryId,
3603 cx: &mut Context<Self>,
3604 ) {
3605 self.project.update(cx, |project, cx| {
3606 let Some(worktree_to_move) = project.worktree_for_entry(entry_to_move, cx) else {
3607 return;
3608 };
3609 let Some(destination_worktree) = project.worktree_for_entry(destination, cx) else {
3610 return;
3611 };
3612
3613 let worktree_id = worktree_to_move.read(cx).id();
3614 let destination_id = destination_worktree.read(cx).id();
3615
3616 project
3617 .move_worktree(worktree_id, destination_id, cx)
3618 .log_err();
3619 });
3620 }
3621
3622 fn move_worktree_entry(
3623 &mut self,
3624 entry_to_move: ProjectEntryId,
3625 destination_entry: ProjectEntryId,
3626 destination_is_file: bool,
3627 cx: &mut Context<Self>,
3628 ) -> Option<Task<Result<CreatedEntry>>> {
3629 if entry_to_move == destination_entry {
3630 return None;
3631 }
3632
3633 let (destination_worktree, rename_task) = self.project.update(cx, |project, cx| {
3634 let Some(source_path) = project.path_for_entry(entry_to_move, cx) else {
3635 return (None, None);
3636 };
3637 let Some(destination_path) = project.path_for_entry(destination_entry, cx) else {
3638 return (None, None);
3639 };
3640 let destination_worktree_id = destination_path.worktree_id;
3641
3642 let destination_dir = if destination_is_file {
3643 destination_path.path.parent().unwrap_or(RelPath::empty())
3644 } else {
3645 destination_path.path.as_ref()
3646 };
3647
3648 let Some(source_name) = source_path.path.file_name() else {
3649 return (None, None);
3650 };
3651 let Ok(source_name) = RelPath::unix(source_name) else {
3652 return (None, None);
3653 };
3654
3655 let mut new_path = destination_dir.to_rel_path_buf();
3656 new_path.push(source_name);
3657 let rename_task = (new_path.as_rel_path() != source_path.path.as_ref()).then(|| {
3658 project.rename_entry(
3659 entry_to_move,
3660 (destination_worktree_id, new_path).into(),
3661 cx,
3662 )
3663 });
3664
3665 (
3666 project.worktree_id_for_entry(destination_entry, cx),
3667 rename_task,
3668 )
3669 });
3670
3671 if let Some(destination_worktree) = destination_worktree {
3672 self.expand_entry(destination_worktree, destination_entry, cx);
3673 }
3674 rename_task
3675 }
3676
3677 fn index_for_selection(&self, selection: SelectedEntry) -> Option<(usize, usize, usize)> {
3678 self.index_for_entry(selection.entry_id, selection.worktree_id)
3679 }
3680
3681 fn disjoint_effective_entries(&self, cx: &App) -> BTreeSet<SelectedEntry> {
3682 self.disjoint_entries(self.effective_entries(), cx)
3683 }
3684
3685 fn disjoint_entries(
3686 &self,
3687 entries: BTreeSet<SelectedEntry>,
3688 cx: &App,
3689 ) -> BTreeSet<SelectedEntry> {
3690 let mut sanitized_entries = BTreeSet::new();
3691 if entries.is_empty() {
3692 return sanitized_entries;
3693 }
3694
3695 let project = self.project.read(cx);
3696 let entries_by_worktree: HashMap<WorktreeId, Vec<SelectedEntry>> = entries
3697 .into_iter()
3698 .filter(|entry| !project.entry_is_worktree_root(entry.entry_id, cx))
3699 .fold(HashMap::default(), |mut map, entry| {
3700 map.entry(entry.worktree_id).or_default().push(entry);
3701 map
3702 });
3703
3704 for (worktree_id, worktree_entries) in entries_by_worktree {
3705 if let Some(worktree) = project.worktree_for_id(worktree_id, cx) {
3706 let worktree = worktree.read(cx);
3707 let dir_paths = worktree_entries
3708 .iter()
3709 .filter_map(|entry| {
3710 worktree.entry_for_id(entry.entry_id).and_then(|entry| {
3711 if entry.is_dir() {
3712 Some(entry.path.as_ref())
3713 } else {
3714 None
3715 }
3716 })
3717 })
3718 .collect::<BTreeSet<_>>();
3719
3720 sanitized_entries.extend(worktree_entries.into_iter().filter(|entry| {
3721 let Some(entry_info) = worktree.entry_for_id(entry.entry_id) else {
3722 return false;
3723 };
3724 let entry_path = entry_info.path.as_ref();
3725 let inside_selected_dir = dir_paths.iter().any(|&dir_path| {
3726 entry_path != dir_path && entry_path.starts_with(dir_path)
3727 });
3728 !inside_selected_dir
3729 }));
3730 }
3731 }
3732
3733 sanitized_entries
3734 }
3735
3736 fn effective_entries(&self) -> BTreeSet<SelectedEntry> {
3737 if let Some(selection) = self.selection {
3738 let selection = SelectedEntry {
3739 entry_id: self.resolve_entry(selection.entry_id),
3740 worktree_id: selection.worktree_id,
3741 };
3742
3743 // Default to using just the selected item when nothing is marked.
3744 if self.marked_entries.is_empty() {
3745 return BTreeSet::from([selection]);
3746 }
3747
3748 // Allow operating on the selected item even when something else is marked,
3749 // making it easier to perform one-off actions without clearing a mark.
3750 if self.marked_entries.len() == 1 && !self.marked_entries.contains(&selection) {
3751 return BTreeSet::from([selection]);
3752 }
3753 }
3754
3755 // Return only marked entries since we've already handled special cases where
3756 // only selection should take precedence. At this point, marked entries may or
3757 // may not include the current selection, which is intentional.
3758 self.marked_entries
3759 .iter()
3760 .map(|entry| SelectedEntry {
3761 entry_id: self.resolve_entry(entry.entry_id),
3762 worktree_id: entry.worktree_id,
3763 })
3764 .collect::<BTreeSet<_>>()
3765 }
3766
3767 /// Finds the currently selected subentry for a given leaf entry id. If a given entry
3768 /// has no ancestors, the project entry ID that's passed in is returned as-is.
3769 fn resolve_entry(&self, id: ProjectEntryId) -> ProjectEntryId {
3770 self.state
3771 .ancestors
3772 .get(&id)
3773 .and_then(|ancestors| ancestors.active_ancestor())
3774 .unwrap_or(id)
3775 }
3776
3777 pub fn selected_entry<'a>(&self, cx: &'a App) -> Option<(&'a Worktree, &'a project::Entry)> {
3778 let (worktree, entry) = self.selected_entry_handle(cx)?;
3779 Some((worktree.read(cx), entry))
3780 }
3781
3782 /// Compared to selected_entry, this function resolves to the currently
3783 /// selected subentry if dir auto-folding is enabled.
3784 fn selected_sub_entry<'a>(
3785 &self,
3786 cx: &'a App,
3787 ) -> Option<(Entity<Worktree>, &'a project::Entry)> {
3788 let (worktree, mut entry) = self.selected_entry_handle(cx)?;
3789
3790 let resolved_id = self.resolve_entry(entry.id);
3791 if resolved_id != entry.id {
3792 let worktree = worktree.read(cx);
3793 entry = worktree.entry_for_id(resolved_id)?;
3794 }
3795 Some((worktree, entry))
3796 }
3797
3798 fn reveal_in_file_manager_path(&self, cx: &App) -> Option<PathBuf> {
3799 if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
3800 return Some(worktree.read(cx).absolutize(&entry.path));
3801 }
3802
3803 let root_entry_id = self.state.last_worktree_root_id?;
3804 let project = self.project.read(cx);
3805 let worktree = project.worktree_for_entry(root_entry_id, cx)?;
3806 let worktree = worktree.read(cx);
3807 let root_entry = worktree.entry_for_id(root_entry_id)?;
3808 Some(worktree.absolutize(&root_entry.path))
3809 }
3810
3811 fn write_entries_to_system_clipboard(&self, entries: &BTreeSet<SelectedEntry>, cx: &mut App) {
3812 let project = self.project.read(cx);
3813 let paths: Vec<String> = entries
3814 .iter()
3815 .filter_map(|entry| {
3816 let worktree = project.worktree_for_id(entry.worktree_id, cx)?;
3817 let worktree = worktree.read(cx);
3818 let worktree_entry = worktree.entry_for_id(entry.entry_id)?;
3819 Some(
3820 worktree
3821 .abs_path()
3822 .join(worktree_entry.path.as_std_path())
3823 .to_string_lossy()
3824 .to_string(),
3825 )
3826 })
3827 .collect();
3828 if !paths.is_empty() {
3829 cx.write_to_clipboard(ClipboardItem::new_string(paths.join("\n")));
3830 }
3831 }
3832
3833 fn external_paths_from_system_clipboard(&self, cx: &App) -> Option<ExternalPaths> {
3834 let clipboard_item = cx.read_from_clipboard()?;
3835 for entry in clipboard_item.entries() {
3836 if let GpuiClipboardEntry::ExternalPaths(paths) = entry {
3837 if !paths.paths().is_empty() {
3838 return Some(paths.clone());
3839 }
3840 }
3841 }
3842 None
3843 }
3844
3845 fn has_pasteable_content(&self, cx: &App) -> bool {
3846 if self
3847 .clipboard
3848 .as_ref()
3849 .is_some_and(|c| !c.items().is_empty())
3850 {
3851 return true;
3852 }
3853 self.external_paths_from_system_clipboard(cx).is_some()
3854 }
3855
3856 fn selected_entry_handle<'a>(
3857 &self,
3858 cx: &'a App,
3859 ) -> Option<(Entity<Worktree>, &'a project::Entry)> {
3860 let selection = self.selection?;
3861 let project = self.project.read(cx);
3862 let worktree = project.worktree_for_id(selection.worktree_id, cx)?;
3863 let entry = worktree.read(cx).entry_for_id(selection.entry_id)?;
3864 Some((worktree, entry))
3865 }
3866
3867 fn expand_to_selection(&mut self, cx: &mut Context<Self>) -> Option<()> {
3868 let (worktree, entry) = self.selected_entry(cx)?;
3869 let expanded_dir_ids = self
3870 .state
3871 .expanded_dir_ids
3872 .entry(worktree.id())
3873 .or_default();
3874
3875 for path in entry.path.ancestors() {
3876 let Some(entry) = worktree.entry_for_path(path) else {
3877 continue;
3878 };
3879 if entry.is_dir()
3880 && let Err(idx) = expanded_dir_ids.binary_search(&entry.id)
3881 {
3882 expanded_dir_ids.insert(idx, entry.id);
3883 }
3884 }
3885
3886 Some(())
3887 }
3888
3889 fn create_new_git_entry(
3890 parent_entry: &Entry,
3891 git_summary: GitSummary,
3892 new_entry_kind: EntryKind,
3893 ) -> GitEntry {
3894 GitEntry {
3895 entry: Entry {
3896 id: NEW_ENTRY_ID,
3897 kind: new_entry_kind,
3898 path: parent_entry.path.join(RelPath::unix("\0").unwrap()),
3899 inode: 0,
3900 mtime: parent_entry.mtime,
3901 size: parent_entry.size,
3902 is_ignored: parent_entry.is_ignored,
3903 is_hidden: parent_entry.is_hidden,
3904 is_external: false,
3905 is_private: false,
3906 is_always_included: parent_entry.is_always_included,
3907 canonical_path: parent_entry.canonical_path.clone(),
3908 char_bag: parent_entry.char_bag,
3909 is_fifo: parent_entry.is_fifo,
3910 },
3911 git_summary,
3912 }
3913 }
3914
3915 fn update_visible_entries(
3916 &mut self,
3917 new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
3918 focus_filename_editor: bool,
3919 autoscroll: bool,
3920 window: &mut Window,
3921 cx: &mut Context<Self>,
3922 ) {
3923 let now = Instant::now();
3924 let settings = ProjectPanelSettings::get_global(cx);
3925 let auto_collapse_dirs = settings.auto_fold_dirs;
3926 let hide_gitignore = settings.hide_gitignore;
3927 let sort_mode = settings.sort_mode;
3928 let sort_order = settings.sort_order;
3929 let project = self.project.read(cx);
3930 let repo_snapshots = project.git_store().read(cx).repo_snapshots(cx);
3931
3932 let old_ancestors = self.state.ancestors.clone();
3933 let temporary_unfolded_pending_state = self.state.temporarily_unfolded_pending_state.take();
3934 let mut new_state = State::derive(&self.state);
3935 new_state.last_worktree_root_id = project
3936 .visible_worktrees(cx)
3937 .next_back()
3938 .and_then(|worktree| worktree.read(cx).root_entry())
3939 .map(|entry| entry.id);
3940 let mut max_width_item = None;
3941
3942 let visible_worktrees: Vec<_> = project
3943 .visible_worktrees(cx)
3944 .map(|worktree| worktree.read(cx).snapshot())
3945 .collect();
3946 let hide_root = settings.hide_root && visible_worktrees.len() == 1;
3947 let hide_hidden = settings.hide_hidden;
3948
3949 let visible_entries_task = cx.spawn_in(window, async move |this, cx| {
3950 let new_state = cx
3951 .background_spawn(async move {
3952 for worktree_snapshot in visible_worktrees {
3953 let worktree_id = worktree_snapshot.id();
3954
3955 let mut new_entry_parent_id = None;
3956 let mut new_entry_kind = EntryKind::Dir;
3957 if let Some(edit_state) = &new_state.edit_state
3958 && edit_state.worktree_id == worktree_id
3959 && edit_state.is_new_entry()
3960 {
3961 new_entry_parent_id = Some(edit_state.entry_id);
3962 new_entry_kind = if edit_state.is_dir {
3963 EntryKind::Dir
3964 } else {
3965 EntryKind::File
3966 };
3967 }
3968
3969 let mut visible_worktree_entries = Vec::new();
3970 let mut entry_iter =
3971 GitTraversal::new(&repo_snapshots, worktree_snapshot.entries(true, 0));
3972 let mut auto_folded_ancestors = vec![];
3973 let worktree_abs_path = worktree_snapshot.abs_path();
3974 while let Some(entry) = entry_iter.entry() {
3975 if hide_root && Some(entry.entry) == worktree_snapshot.root_entry() {
3976 if new_entry_parent_id == Some(entry.id) {
3977 visible_worktree_entries.push(Self::create_new_git_entry(
3978 entry.entry,
3979 entry.git_summary,
3980 new_entry_kind,
3981 ));
3982 new_entry_parent_id = None;
3983 }
3984 entry_iter.advance();
3985 continue;
3986 }
3987 if auto_collapse_dirs && entry.kind.is_dir() {
3988 auto_folded_ancestors.push(entry.id);
3989 if !new_state.is_unfolded(&entry.id)
3990 && let Some(root_path) = worktree_snapshot.root_entry()
3991 {
3992 let mut child_entries =
3993 worktree_snapshot.child_entries(&entry.path);
3994 if let Some(child) = child_entries.next()
3995 && entry.path != root_path.path
3996 && child_entries.next().is_none()
3997 && child.kind.is_dir()
3998 {
3999 entry_iter.advance();
4000
4001 continue;
4002 }
4003 }
4004 let depth = temporary_unfolded_pending_state
4005 .as_ref()
4006 .and_then(|state| {
4007 if state.previously_focused_leaf_entry.worktree_id
4008 == worktree_id
4009 && state.previously_focused_leaf_entry.entry_id
4010 == entry.id
4011 {
4012 auto_folded_ancestors.iter().rev().position(|id| {
4013 *id == state.temporarily_unfolded_active_entry_id
4014 })
4015 } else {
4016 None
4017 }
4018 })
4019 .unwrap_or_else(|| {
4020 old_ancestors
4021 .get(&entry.id)
4022 .map(|ancestor| ancestor.current_ancestor_depth)
4023 .unwrap_or_default()
4024 })
4025 .min(auto_folded_ancestors.len());
4026 if let Some(edit_state) = &mut new_state.edit_state
4027 && edit_state.entry_id == entry.id
4028 {
4029 edit_state.depth = depth;
4030 }
4031 let mut ancestors = std::mem::take(&mut auto_folded_ancestors);
4032 if ancestors.len() > 1 {
4033 ancestors.reverse();
4034 new_state.ancestors.insert(
4035 entry.id,
4036 FoldedAncestors {
4037 current_ancestor_depth: depth,
4038 ancestors,
4039 },
4040 );
4041 }
4042 }
4043 auto_folded_ancestors.clear();
4044 if (!hide_gitignore || !entry.is_ignored)
4045 && (!hide_hidden || !entry.is_hidden)
4046 {
4047 visible_worktree_entries.push(entry.to_owned());
4048 }
4049 let precedes_new_entry = if let Some(new_entry_id) = new_entry_parent_id
4050 {
4051 entry.id == new_entry_id || {
4052 new_state.ancestors.get(&entry.id).is_some_and(|entries| {
4053 entries.ancestors.contains(&new_entry_id)
4054 })
4055 }
4056 } else {
4057 false
4058 };
4059 if precedes_new_entry
4060 && (!hide_gitignore || !entry.is_ignored)
4061 && (!hide_hidden || !entry.is_hidden)
4062 {
4063 visible_worktree_entries.push(Self::create_new_git_entry(
4064 entry.entry,
4065 entry.git_summary,
4066 new_entry_kind,
4067 ));
4068 }
4069
4070 let (depth, chars) = if Some(entry.entry)
4071 == worktree_snapshot.root_entry()
4072 {
4073 let Some(path_name) = worktree_abs_path.file_name() else {
4074 entry_iter.advance();
4075 continue;
4076 };
4077 let depth = 0;
4078 (depth, path_name.to_string_lossy().chars().count())
4079 } else if entry.is_file() {
4080 let Some(path_name) = entry
4081 .path
4082 .file_name()
4083 .with_context(|| {
4084 format!("Non-root entry has no file name: {entry:?}")
4085 })
4086 .log_err()
4087 else {
4088 continue;
4089 };
4090 let depth = entry.path.ancestors().count() - 1;
4091 (depth, path_name.chars().count())
4092 } else {
4093 let path = new_state
4094 .ancestors
4095 .get(&entry.id)
4096 .and_then(|ancestors| {
4097 let outermost_ancestor = ancestors.ancestors.last()?;
4098 let root_folded_entry = worktree_snapshot
4099 .entry_for_id(*outermost_ancestor)?
4100 .path
4101 .as_ref();
4102 entry.path.strip_prefix(root_folded_entry).ok().and_then(
4103 |suffix| {
4104 Some(
4105 RelPath::unix(root_folded_entry.file_name()?)
4106 .unwrap()
4107 .join(suffix),
4108 )
4109 },
4110 )
4111 })
4112 .or_else(|| {
4113 entry.path.file_name().map(|file_name| {
4114 RelPath::unix(file_name).unwrap().into()
4115 })
4116 })
4117 .unwrap_or_else(|| entry.path.clone());
4118 let depth = path.components().count();
4119 (depth, path.as_unix_str().chars().count())
4120 };
4121 let width_estimate =
4122 item_width_estimate(depth, chars, entry.canonical_path.is_some());
4123
4124 match max_width_item.as_mut() {
4125 Some((id, worktree_id, width)) => {
4126 if *width < width_estimate {
4127 *id = entry.id;
4128 *worktree_id = worktree_snapshot.id();
4129 *width = width_estimate;
4130 }
4131 }
4132 None => {
4133 max_width_item =
4134 Some((entry.id, worktree_snapshot.id(), width_estimate))
4135 }
4136 }
4137
4138 let expanded_dir_ids =
4139 match new_state.expanded_dir_ids.entry(worktree_id) {
4140 hash_map::Entry::Occupied(e) => e.into_mut(),
4141 hash_map::Entry::Vacant(e) => {
4142 // The first time a worktree's root entry becomes available,
4143 // mark that root entry as expanded.
4144 if let Some(entry) = worktree_snapshot.root_entry() {
4145 e.insert(vec![entry.id]).as_slice()
4146 } else {
4147 &[]
4148 }
4149 }
4150 };
4151
4152 if expanded_dir_ids.binary_search(&entry.id).is_err()
4153 && entry_iter.advance_to_sibling()
4154 {
4155 continue;
4156 }
4157 entry_iter.advance();
4158 }
4159
4160 par_sort_worktree_entries(
4161 &mut visible_worktree_entries,
4162 sort_mode,
4163 sort_order,
4164 );
4165 new_state.visible_entries.push(VisibleEntriesForWorktree {
4166 worktree_id,
4167 entries: visible_worktree_entries,
4168 index: OnceCell::new(),
4169 })
4170 }
4171 if let Some((project_entry_id, worktree_id, _)) = max_width_item {
4172 let mut visited_worktrees_length = 0;
4173 let index = new_state
4174 .visible_entries
4175 .iter()
4176 .find_map(|visible_entries| {
4177 if worktree_id == visible_entries.worktree_id {
4178 visible_entries
4179 .entries
4180 .iter()
4181 .position(|entry| entry.id == project_entry_id)
4182 } else {
4183 visited_worktrees_length += visible_entries.entries.len();
4184 None
4185 }
4186 });
4187 if let Some(index) = index {
4188 new_state.max_width_item_index = Some(visited_worktrees_length + index);
4189 }
4190 }
4191 new_state
4192 })
4193 .await;
4194 this.update_in(cx, |this, window, cx| {
4195 this.state = new_state;
4196 if let Some((worktree_id, entry_id)) = new_selected_entry {
4197 this.selection = Some(SelectedEntry {
4198 worktree_id,
4199 entry_id,
4200 });
4201 }
4202 let elapsed = now.elapsed();
4203 if this.last_reported_update.elapsed() > Duration::from_secs(3600) {
4204 telemetry::event!(
4205 "Project Panel Updated",
4206 elapsed_ms = elapsed.as_millis() as u64,
4207 worktree_entries = this
4208 .state
4209 .visible_entries
4210 .iter()
4211 .map(|worktree| worktree.entries.len())
4212 .sum::<usize>(),
4213 )
4214 }
4215 if this.update_visible_entries_task.focus_filename_editor {
4216 this.update_visible_entries_task.focus_filename_editor = false;
4217 this.filename_editor.update(cx, |editor, cx| {
4218 window.focus(&editor.focus_handle(cx), cx);
4219 });
4220 }
4221 if this.update_visible_entries_task.autoscroll {
4222 this.update_visible_entries_task.autoscroll = false;
4223 this.autoscroll(cx);
4224 }
4225 cx.notify();
4226 })
4227 .ok();
4228 });
4229
4230 self.update_visible_entries_task = UpdateVisibleEntriesTask {
4231 _visible_entries_task: visible_entries_task,
4232 focus_filename_editor: focus_filename_editor
4233 || self.update_visible_entries_task.focus_filename_editor,
4234 autoscroll: autoscroll || self.update_visible_entries_task.autoscroll,
4235 };
4236 }
4237
4238 fn expand_entry(
4239 &mut self,
4240 worktree_id: WorktreeId,
4241 entry_id: ProjectEntryId,
4242 cx: &mut Context<Self>,
4243 ) {
4244 self.project.update(cx, |project, cx| {
4245 if let Some((worktree, expanded_dir_ids)) = project
4246 .worktree_for_id(worktree_id, cx)
4247 .zip(self.state.expanded_dir_ids.get_mut(&worktree_id))
4248 {
4249 project.expand_entry(worktree_id, entry_id, cx);
4250 let worktree = worktree.read(cx);
4251
4252 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
4253 loop {
4254 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
4255 expanded_dir_ids.insert(ix, entry.id);
4256 }
4257
4258 if let Some(parent_entry) =
4259 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
4260 {
4261 entry = parent_entry;
4262 } else {
4263 break;
4264 }
4265 }
4266 }
4267 }
4268 });
4269 }
4270
4271 fn drop_external_files(
4272 &mut self,
4273 paths: &[PathBuf],
4274 entry_id: ProjectEntryId,
4275 window: &mut Window,
4276 cx: &mut Context<Self>,
4277 ) {
4278 let mut paths: Vec<Arc<Path>> = paths.iter().map(|path| Arc::from(path.clone())).collect();
4279
4280 let open_file_after_drop = paths.len() == 1 && paths[0].is_file();
4281
4282 let Some((target_directory, worktree, fs)) = maybe!({
4283 let project = self.project.read(cx);
4284 let fs = project.fs().clone();
4285 let worktree = project.worktree_for_entry(entry_id, cx)?;
4286 let entry = worktree.read(cx).entry_for_id(entry_id)?;
4287 let path = entry.path.clone();
4288 let target_directory = if entry.is_dir() {
4289 path
4290 } else {
4291 path.parent()?.into()
4292 };
4293 Some((target_directory, worktree, fs))
4294 }) else {
4295 return;
4296 };
4297
4298 let mut paths_to_replace = Vec::new();
4299 for path in &paths {
4300 if let Some(name) = path.file_name()
4301 && let Some(name) = name.to_str()
4302 {
4303 let target_path = target_directory.join(RelPath::unix(name).unwrap());
4304 if worktree.read(cx).entry_for_path(&target_path).is_some() {
4305 paths_to_replace.push((name.to_string(), path.clone()));
4306 }
4307 }
4308 }
4309
4310 cx.spawn_in(window, async move |this, cx| {
4311 async move {
4312 for (filename, original_path) in &paths_to_replace {
4313 let prompt_message = format!(
4314 concat!(
4315 "A file or folder with name {} ",
4316 "already exists in the destination folder. ",
4317 "Do you want to replace it?"
4318 ),
4319 filename
4320 );
4321 let answer = cx
4322 .update(|window, cx| {
4323 window.prompt(
4324 PromptLevel::Info,
4325 &prompt_message,
4326 None,
4327 &["Replace", "Cancel"],
4328 cx,
4329 )
4330 })?
4331 .await?;
4332
4333 if answer == 1
4334 && let Some(item_idx) = paths.iter().position(|p| p == original_path)
4335 {
4336 paths.remove(item_idx);
4337 }
4338 }
4339
4340 if paths.is_empty() {
4341 return Ok(());
4342 }
4343
4344 let (worktree_id, task) = worktree.update(cx, |worktree, cx| {
4345 (
4346 worktree.id(),
4347 worktree.copy_external_entries(target_directory, paths, fs, cx),
4348 )
4349 });
4350
4351 let opened_entries: Vec<_> = task
4352 .await
4353 .with_context(|| "failed to copy external paths")?;
4354 this.update_in(cx, |this, window, cx| {
4355 let mut did_open = false;
4356 if open_file_after_drop && !opened_entries.is_empty() {
4357 let settings = ProjectPanelSettings::get_global(cx);
4358 if settings.auto_open.should_open_on_drop() {
4359 this.open_entry(opened_entries[0], true, false, cx);
4360 did_open = true;
4361 }
4362 }
4363
4364 if !did_open {
4365 let new_selection = opened_entries
4366 .last()
4367 .map(|&entry_id| (worktree_id, entry_id));
4368 for &entry_id in &opened_entries {
4369 this.expand_entry(worktree_id, entry_id, cx);
4370 }
4371 this.marked_entries.clear();
4372 this.update_visible_entries(new_selection, false, false, window, cx);
4373 }
4374 })
4375 }
4376 .log_err()
4377 .await
4378 })
4379 .detach();
4380 }
4381
4382 fn refresh_drag_cursor_style(
4383 &self,
4384 modifiers: &Modifiers,
4385 window: &mut Window,
4386 cx: &mut Context<Self>,
4387 ) {
4388 if let Some(existing_cursor) = cx.active_drag_cursor_style() {
4389 let new_cursor = if Self::is_copy_modifier_set(modifiers) {
4390 CursorStyle::DragCopy
4391 } else {
4392 CursorStyle::PointingHand
4393 };
4394 if existing_cursor != new_cursor {
4395 cx.set_active_drag_cursor_style(new_cursor, window);
4396 }
4397 }
4398 }
4399
4400 fn is_copy_modifier_set(modifiers: &Modifiers) -> bool {
4401 cfg!(target_os = "macos") && modifiers.alt
4402 || cfg!(not(target_os = "macos")) && modifiers.control
4403 }
4404
4405 fn drag_onto(
4406 &mut self,
4407 selections: &DraggedSelection,
4408 target_entry_id: ProjectEntryId,
4409 is_file: bool,
4410 window: &mut Window,
4411 cx: &mut Context<Self>,
4412 ) {
4413 let resolved_selections = selections
4414 .items()
4415 .map(|entry| SelectedEntry {
4416 entry_id: self.resolve_entry(entry.entry_id),
4417 worktree_id: entry.worktree_id,
4418 })
4419 .collect::<BTreeSet<SelectedEntry>>();
4420 let entries = self.disjoint_entries(resolved_selections, cx);
4421
4422 if Self::is_copy_modifier_set(&window.modifiers()) {
4423 let _ = maybe!({
4424 let project = self.project.read(cx);
4425 let target_worktree = project.worktree_for_entry(target_entry_id, cx)?;
4426 let worktree_id = target_worktree.read(cx).id();
4427 let target_entry = target_worktree
4428 .read(cx)
4429 .entry_for_id(target_entry_id)?
4430 .clone();
4431
4432 let mut copy_tasks = Vec::new();
4433 let mut disambiguation_range = None;
4434 for selection in &entries {
4435 let (new_path, new_disambiguation_range) = self.create_paste_path(
4436 selection,
4437 (target_worktree.clone(), &target_entry),
4438 cx,
4439 )?;
4440
4441 let task = self.project.update(cx, |project, cx| {
4442 project.copy_entry(selection.entry_id, (worktree_id, new_path).into(), cx)
4443 });
4444 copy_tasks.push(task);
4445 disambiguation_range = new_disambiguation_range.or(disambiguation_range);
4446 }
4447
4448 let item_count = copy_tasks.len();
4449
4450 cx.spawn_in(window, async move |project_panel, cx| {
4451 let mut last_succeed = None;
4452 let mut operations = Vec::new();
4453 for task in copy_tasks.into_iter() {
4454 if let Some(Some(entry)) = task.await.log_err() {
4455 last_succeed = Some(entry.id);
4456 operations.push(ProjectPanelOperation::Create {
4457 project_path: (worktree_id, entry.path).into(),
4458 });
4459 }
4460 }
4461 // update selection
4462 if let Some(entry_id) = last_succeed {
4463 project_panel
4464 .update_in(cx, |project_panel, window, cx| {
4465 project_panel.selection = Some(SelectedEntry {
4466 worktree_id,
4467 entry_id,
4468 });
4469
4470 project_panel.undo_manager.record_batch(operations);
4471
4472 // if only one entry was dragged and it was disambiguated, open the rename editor
4473 if item_count == 1 && disambiguation_range.is_some() {
4474 project_panel.rename_impl(disambiguation_range, window, cx);
4475 }
4476 })
4477 .ok();
4478 }
4479 })
4480 .detach();
4481 Some(())
4482 });
4483 } else {
4484 let update_marks = !self.marked_entries.is_empty();
4485 let active_selection = selections.active_selection;
4486
4487 // For folded selections, track the leaf suffix relative to the resolved
4488 // entry so we can refresh it after the move completes.
4489 let (folded_selection_info, folded_selection_entries): (
4490 Vec<(ProjectEntryId, RelPathBuf)>,
4491 HashSet<SelectedEntry>,
4492 ) = {
4493 let project = self.project.read(cx);
4494 let mut info = Vec::new();
4495 let mut folded_entries = HashSet::default();
4496
4497 for selection in selections.items() {
4498 let resolved_id = self.resolve_entry(selection.entry_id);
4499 if resolved_id == selection.entry_id {
4500 continue;
4501 }
4502 folded_entries.insert(*selection);
4503 let Some(source_path) = project.path_for_entry(resolved_id, cx) else {
4504 continue;
4505 };
4506 let Some(leaf_path) = project.path_for_entry(selection.entry_id, cx) else {
4507 continue;
4508 };
4509 let Ok(suffix) = leaf_path.path.strip_prefix(source_path.path.as_ref()) else {
4510 continue;
4511 };
4512 if suffix.as_unix_str().is_empty() {
4513 continue;
4514 }
4515
4516 info.push((resolved_id, suffix.to_rel_path_buf()));
4517 }
4518 (info, folded_entries)
4519 };
4520
4521 // Capture old paths before moving so we can record undo operations.
4522 let old_paths: HashMap<ProjectEntryId, ProjectPath> = {
4523 let project = self.project.read(cx);
4524 entries
4525 .iter()
4526 .filter_map(|entry| {
4527 let path = project.path_for_entry(entry.entry_id, cx)?;
4528 Some((entry.entry_id, path))
4529 })
4530 .collect()
4531 };
4532 let destination_worktree_id = self
4533 .project
4534 .read(cx)
4535 .worktree_for_entry(target_entry_id, cx)
4536 .map(|wt| wt.read(cx).id());
4537
4538 // Collect move tasks paired with their source entry ID so we can correlate
4539 // results with folded selections that need refreshing.
4540 let mut move_tasks: Vec<(ProjectEntryId, Task<Result<CreatedEntry>>)> = Vec::new();
4541 for entry in entries {
4542 if let Some(task) = self.move_entry(entry.entry_id, target_entry_id, is_file, cx) {
4543 move_tasks.push((entry.entry_id, task));
4544 }
4545 }
4546
4547 if move_tasks.is_empty() {
4548 return;
4549 }
4550
4551 let workspace = self.workspace.clone();
4552 if folded_selection_info.is_empty() {
4553 cx.spawn_in(window, async move |project_panel, mut cx| {
4554 let mut operations = Vec::new();
4555 for (entry_id, task) in move_tasks {
4556 if let Some(CreatedEntry::Included(new_entry)) = task
4557 .await
4558 .notify_workspace_async_err(workspace.clone(), &mut cx)
4559 {
4560 if let (Some(old_path), Some(worktree_id)) =
4561 (old_paths.get(&entry_id), destination_worktree_id)
4562 {
4563 operations.push(ProjectPanelOperation::Rename {
4564 old_path: old_path.clone(),
4565 new_path: (worktree_id, new_entry.path).into(),
4566 });
4567 }
4568 }
4569 }
4570 project_panel
4571 .update(cx, |this, _| {
4572 this.undo_manager.record_batch(operations);
4573 })
4574 .ok();
4575 })
4576 .detach();
4577 } else {
4578 cx.spawn_in(window, async move |project_panel, mut cx| {
4579 // Await all move tasks and collect successful results
4580 let mut move_results: Vec<(ProjectEntryId, Entry)> = Vec::new();
4581 let mut operations = Vec::new();
4582 for (entry_id, task) in move_tasks {
4583 if let Some(CreatedEntry::Included(new_entry)) = task
4584 .await
4585 .notify_workspace_async_err(workspace.clone(), &mut cx)
4586 {
4587 if let (Some(old_path), Some(worktree_id)) =
4588 (old_paths.get(&entry_id), destination_worktree_id)
4589 {
4590 operations.push(ProjectPanelOperation::Rename {
4591 old_path: old_path.clone(),
4592 new_path: (worktree_id, new_entry.path.clone()).into(),
4593 });
4594 }
4595 move_results.push((entry_id, new_entry));
4596 }
4597 }
4598
4599 if move_results.is_empty() {
4600 return;
4601 }
4602
4603 project_panel
4604 .update(cx, |this, _| {
4605 this.undo_manager.record_batch(operations);
4606 })
4607 .ok();
4608
4609 // For folded selections, we need to refresh the leaf paths (with suffixes)
4610 // because they may not be indexed yet after the parent directory was moved.
4611 // First collect the paths to refresh, then refresh them.
4612 let paths_to_refresh: Vec<(Entity<Worktree>, Arc<RelPath>)> = project_panel
4613 .update(cx, |project_panel, cx| {
4614 let project = project_panel.project.read(cx);
4615 folded_selection_info
4616 .iter()
4617 .filter_map(|(resolved_id, suffix)| {
4618 let (_, new_entry) =
4619 move_results.iter().find(|(id, _)| id == resolved_id)?;
4620 let worktree = project.worktree_for_entry(new_entry.id, cx)?;
4621 let leaf_path = new_entry.path.join(suffix);
4622 Some((worktree, leaf_path))
4623 })
4624 .collect()
4625 })
4626 .ok()
4627 .unwrap_or_default();
4628
4629 let refresh_tasks: Vec<_> = paths_to_refresh
4630 .into_iter()
4631 .filter_map(|(worktree, leaf_path)| {
4632 worktree.update(cx, |worktree, cx| {
4633 worktree
4634 .as_local_mut()
4635 .map(|local| local.refresh_entry(leaf_path, None, cx))
4636 })
4637 })
4638 .collect();
4639
4640 for task in refresh_tasks {
4641 task.await.log_err();
4642 }
4643
4644 if update_marks && !folded_selection_entries.is_empty() {
4645 project_panel
4646 .update(cx, |project_panel, cx| {
4647 project_panel.marked_entries.retain(|entry| {
4648 !folded_selection_entries.contains(entry)
4649 || *entry == active_selection
4650 });
4651 cx.notify();
4652 })
4653 .ok();
4654 }
4655 })
4656 .detach();
4657 }
4658 }
4659 }
4660
4661 fn index_for_entry(
4662 &self,
4663 entry_id: ProjectEntryId,
4664 worktree_id: WorktreeId,
4665 ) -> Option<(usize, usize, usize)> {
4666 let mut total_ix = 0;
4667 for (worktree_ix, visible) in self.state.visible_entries.iter().enumerate() {
4668 if worktree_id != visible.worktree_id {
4669 total_ix += visible.entries.len();
4670 continue;
4671 }
4672
4673 return visible
4674 .entries
4675 .iter()
4676 .enumerate()
4677 .find(|(_, entry)| entry.id == entry_id)
4678 .map(|(ix, _)| (worktree_ix, ix, total_ix + ix));
4679 }
4680 None
4681 }
4682
4683 fn entry_at_index(&self, index: usize) -> Option<(WorktreeId, GitEntryRef<'_>)> {
4684 let mut offset = 0;
4685 for worktree in &self.state.visible_entries {
4686 let current_len = worktree.entries.len();
4687 if index < offset + current_len {
4688 return worktree
4689 .entries
4690 .get(index - offset)
4691 .map(|entry| (worktree.worktree_id, entry.to_ref()));
4692 }
4693 offset += current_len;
4694 }
4695 None
4696 }
4697
4698 fn iter_visible_entries(
4699 &self,
4700 range: Range<usize>,
4701 window: &mut Window,
4702 cx: &mut Context<ProjectPanel>,
4703 callback: &mut dyn FnMut(
4704 &Entry,
4705 usize,
4706 &HashSet<Arc<RelPath>>,
4707 &mut Window,
4708 &mut Context<ProjectPanel>,
4709 ),
4710 ) {
4711 let mut ix = 0;
4712 for visible in &self.state.visible_entries {
4713 if ix >= range.end {
4714 return;
4715 }
4716
4717 if ix + visible.entries.len() <= range.start {
4718 ix += visible.entries.len();
4719 continue;
4720 }
4721
4722 let end_ix = range.end.min(ix + visible.entries.len());
4723 let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
4724 let entries = visible
4725 .index
4726 .get_or_init(|| visible.entries.iter().map(|e| e.path.clone()).collect());
4727 let base_index = ix + entry_range.start;
4728 for (i, entry) in visible.entries[entry_range].iter().enumerate() {
4729 let global_index = base_index + i;
4730 callback(entry, global_index, entries, window, cx);
4731 }
4732 ix = end_ix;
4733 }
4734 }
4735
4736 fn for_each_visible_entry(
4737 &self,
4738 range: Range<usize>,
4739 window: &mut Window,
4740 cx: &mut Context<ProjectPanel>,
4741 callback: &mut dyn FnMut(
4742 ProjectEntryId,
4743 EntryDetails,
4744 &mut Window,
4745 &mut Context<ProjectPanel>,
4746 ),
4747 ) {
4748 let mut ix = 0;
4749 for visible in &self.state.visible_entries {
4750 if ix >= range.end {
4751 return;
4752 }
4753
4754 if ix + visible.entries.len() <= range.start {
4755 ix += visible.entries.len();
4756 continue;
4757 }
4758
4759 let end_ix = range.end.min(ix + visible.entries.len());
4760 let git_status_setting = {
4761 let settings = ProjectPanelSettings::get_global(cx);
4762 settings.git_status
4763 };
4764 if let Some(worktree) = self
4765 .project
4766 .read(cx)
4767 .worktree_for_id(visible.worktree_id, cx)
4768 {
4769 let snapshot = worktree.read(cx).snapshot();
4770 let root_name = snapshot.root_name();
4771
4772 let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
4773 let entries = visible
4774 .index
4775 .get_or_init(|| visible.entries.iter().map(|e| e.path.clone()).collect());
4776 for entry in visible.entries[entry_range].iter() {
4777 let status = git_status_setting
4778 .then_some(entry.git_summary)
4779 .unwrap_or_default();
4780
4781 let mut details = self.details_for_entry(
4782 entry,
4783 visible.worktree_id,
4784 root_name,
4785 entries,
4786 status,
4787 None,
4788 window,
4789 cx,
4790 );
4791
4792 if let Some(edit_state) = &self.state.edit_state {
4793 let is_edited_entry = if edit_state.is_new_entry() {
4794 entry.id == NEW_ENTRY_ID
4795 } else {
4796 entry.id == edit_state.entry_id
4797 || self.state.ancestors.get(&entry.id).is_some_and(
4798 |auto_folded_dirs| {
4799 auto_folded_dirs.ancestors.contains(&edit_state.entry_id)
4800 },
4801 )
4802 };
4803
4804 if is_edited_entry {
4805 if let Some(processing_filename) = &edit_state.processing_filename {
4806 details.is_processing = true;
4807 if let Some(ancestors) = edit_state
4808 .leaf_entry_id
4809 .and_then(|entry| self.state.ancestors.get(&entry))
4810 {
4811 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;
4812 let all_components = ancestors.ancestors.len();
4813
4814 let prefix_components = all_components - position;
4815 let suffix_components = position.checked_sub(1);
4816 let mut previous_components =
4817 Path::new(&details.filename).components();
4818 let mut new_path = previous_components
4819 .by_ref()
4820 .take(prefix_components)
4821 .collect::<PathBuf>();
4822 if let Some(last_component) =
4823 processing_filename.components().next_back()
4824 {
4825 new_path.push(last_component);
4826 previous_components.next();
4827 }
4828
4829 if suffix_components.is_some() {
4830 new_path.push(previous_components);
4831 }
4832 if let Some(str) = new_path.to_str() {
4833 details.filename.clear();
4834 details.filename.push_str(str);
4835 }
4836 } else {
4837 details.filename.clear();
4838 details.filename.push_str(processing_filename.as_unix_str());
4839 }
4840 } else {
4841 if edit_state.is_new_entry() {
4842 details.filename.clear();
4843 }
4844 details.is_editing = true;
4845 }
4846 }
4847 }
4848
4849 callback(entry.id, details, window, cx);
4850 }
4851 }
4852 ix = end_ix;
4853 }
4854 }
4855
4856 fn find_entry_in_worktree(
4857 &self,
4858 worktree_id: WorktreeId,
4859 reverse_search: bool,
4860 only_visible_entries: bool,
4861 predicate: &dyn Fn(GitEntryRef, WorktreeId) -> bool,
4862 cx: &mut Context<Self>,
4863 ) -> Option<GitEntry> {
4864 if only_visible_entries {
4865 let entries = self
4866 .state
4867 .visible_entries
4868 .iter()
4869 .find_map(|visible| {
4870 if worktree_id == visible.worktree_id {
4871 Some(&visible.entries)
4872 } else {
4873 None
4874 }
4875 })?
4876 .clone();
4877
4878 return utils::ReversibleIterable::new(entries.iter(), reverse_search)
4879 .find(|ele| predicate(ele.to_ref(), worktree_id))
4880 .cloned();
4881 }
4882
4883 let repo_snapshots = self
4884 .project
4885 .read(cx)
4886 .git_store()
4887 .read(cx)
4888 .repo_snapshots(cx);
4889 let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
4890 worktree.read_with(cx, |tree, _| {
4891 utils::ReversibleIterable::new(
4892 GitTraversal::new(&repo_snapshots, tree.entries(true, 0usize)),
4893 reverse_search,
4894 )
4895 .find_single_ended(|ele| predicate(*ele, worktree_id))
4896 .map(|ele| ele.to_owned())
4897 })
4898 }
4899
4900 fn find_entry(
4901 &self,
4902 start: Option<&SelectedEntry>,
4903 reverse_search: bool,
4904 predicate: &dyn Fn(GitEntryRef, WorktreeId) -> bool,
4905 cx: &mut Context<Self>,
4906 ) -> Option<SelectedEntry> {
4907 let mut worktree_ids: Vec<_> = self
4908 .state
4909 .visible_entries
4910 .iter()
4911 .map(|worktree| worktree.worktree_id)
4912 .collect();
4913 let repo_snapshots = self
4914 .project
4915 .read(cx)
4916 .git_store()
4917 .read(cx)
4918 .repo_snapshots(cx);
4919
4920 let mut last_found: Option<SelectedEntry> = None;
4921
4922 if let Some(start) = start {
4923 let worktree = self
4924 .project
4925 .read(cx)
4926 .worktree_for_id(start.worktree_id, cx)?
4927 .read(cx);
4928
4929 let search = {
4930 let entry = worktree.entry_for_id(start.entry_id)?;
4931 let root_entry = worktree.root_entry()?;
4932 let tree_id = worktree.id();
4933
4934 let mut first_iter = GitTraversal::new(
4935 &repo_snapshots,
4936 worktree.traverse_from_path(true, true, true, entry.path.as_ref()),
4937 );
4938
4939 if reverse_search {
4940 first_iter.next();
4941 }
4942
4943 let first = first_iter
4944 .enumerate()
4945 .take_until(|(count, entry)| entry.entry == root_entry && *count != 0usize)
4946 .map(|(_, entry)| entry)
4947 .find(|ele| predicate(*ele, tree_id))
4948 .map(|ele| ele.to_owned());
4949
4950 let second_iter =
4951 GitTraversal::new(&repo_snapshots, worktree.entries(true, 0usize));
4952
4953 let second = if reverse_search {
4954 second_iter
4955 .take_until(|ele| ele.id == start.entry_id)
4956 .filter(|ele| predicate(*ele, tree_id))
4957 .last()
4958 .map(|ele| ele.to_owned())
4959 } else {
4960 second_iter
4961 .take_while(|ele| ele.id != start.entry_id)
4962 .filter(|ele| predicate(*ele, tree_id))
4963 .last()
4964 .map(|ele| ele.to_owned())
4965 };
4966
4967 if reverse_search {
4968 Some((second, first))
4969 } else {
4970 Some((first, second))
4971 }
4972 };
4973
4974 if let Some((first, second)) = search {
4975 let first = first.map(|entry| SelectedEntry {
4976 worktree_id: start.worktree_id,
4977 entry_id: entry.id,
4978 });
4979
4980 let second = second.map(|entry| SelectedEntry {
4981 worktree_id: start.worktree_id,
4982 entry_id: entry.id,
4983 });
4984
4985 if first.is_some() {
4986 return first;
4987 }
4988 last_found = second;
4989
4990 let idx = worktree_ids
4991 .iter()
4992 .enumerate()
4993 .find(|(_, ele)| **ele == start.worktree_id)
4994 .map(|(idx, _)| idx);
4995
4996 if let Some(idx) = idx {
4997 worktree_ids.rotate_left(idx + 1usize);
4998 worktree_ids.pop();
4999 }
5000 }
5001 }
5002
5003 for tree_id in worktree_ids.into_iter() {
5004 if let Some(found) =
5005 self.find_entry_in_worktree(tree_id, reverse_search, false, &predicate, cx)
5006 {
5007 return Some(SelectedEntry {
5008 worktree_id: tree_id,
5009 entry_id: found.id,
5010 });
5011 }
5012 }
5013
5014 last_found
5015 }
5016
5017 fn find_visible_entry(
5018 &self,
5019 start: Option<&SelectedEntry>,
5020 reverse_search: bool,
5021 predicate: &dyn Fn(GitEntryRef, WorktreeId) -> bool,
5022 cx: &mut Context<Self>,
5023 ) -> Option<SelectedEntry> {
5024 let mut worktree_ids: Vec<_> = self
5025 .state
5026 .visible_entries
5027 .iter()
5028 .map(|worktree| worktree.worktree_id)
5029 .collect();
5030
5031 let mut last_found: Option<SelectedEntry> = None;
5032
5033 if let Some(start) = start {
5034 let entries = self
5035 .state
5036 .visible_entries
5037 .iter()
5038 .find(|worktree| worktree.worktree_id == start.worktree_id)
5039 .map(|worktree| &worktree.entries)?;
5040
5041 let mut start_idx = entries
5042 .iter()
5043 .enumerate()
5044 .find(|(_, ele)| ele.id == start.entry_id)
5045 .map(|(idx, _)| idx)?;
5046
5047 if reverse_search {
5048 start_idx = start_idx.saturating_add(1usize);
5049 }
5050
5051 let (left, right) = entries.split_at_checked(start_idx)?;
5052
5053 let (first_iter, second_iter) = if reverse_search {
5054 (
5055 utils::ReversibleIterable::new(left.iter(), reverse_search),
5056 utils::ReversibleIterable::new(right.iter(), reverse_search),
5057 )
5058 } else {
5059 (
5060 utils::ReversibleIterable::new(right.iter(), reverse_search),
5061 utils::ReversibleIterable::new(left.iter(), reverse_search),
5062 )
5063 };
5064
5065 let first_search = first_iter.find(|ele| predicate(ele.to_ref(), start.worktree_id));
5066 let second_search = second_iter.find(|ele| predicate(ele.to_ref(), start.worktree_id));
5067
5068 if first_search.is_some() {
5069 return first_search.map(|entry| SelectedEntry {
5070 worktree_id: start.worktree_id,
5071 entry_id: entry.id,
5072 });
5073 }
5074
5075 last_found = second_search.map(|entry| SelectedEntry {
5076 worktree_id: start.worktree_id,
5077 entry_id: entry.id,
5078 });
5079
5080 let idx = worktree_ids
5081 .iter()
5082 .enumerate()
5083 .find(|(_, ele)| **ele == start.worktree_id)
5084 .map(|(idx, _)| idx);
5085
5086 if let Some(idx) = idx {
5087 worktree_ids.rotate_left(idx + 1usize);
5088 worktree_ids.pop();
5089 }
5090 }
5091
5092 for tree_id in worktree_ids.into_iter() {
5093 if let Some(found) =
5094 self.find_entry_in_worktree(tree_id, reverse_search, true, &predicate, cx)
5095 {
5096 return Some(SelectedEntry {
5097 worktree_id: tree_id,
5098 entry_id: found.id,
5099 });
5100 }
5101 }
5102
5103 last_found
5104 }
5105
5106 fn calculate_depth_and_difference(
5107 entry: &Entry,
5108 visible_worktree_entries: &HashSet<Arc<RelPath>>,
5109 ) -> (usize, usize) {
5110 let (depth, difference) = entry
5111 .path
5112 .ancestors()
5113 .skip(1) // Skip the entry itself
5114 .find_map(|ancestor| {
5115 if let Some(parent_entry) = visible_worktree_entries.get(ancestor) {
5116 let entry_path_components_count = entry.path.components().count();
5117 let parent_path_components_count = parent_entry.components().count();
5118 let difference = entry_path_components_count - parent_path_components_count;
5119 let depth = parent_entry
5120 .ancestors()
5121 .skip(1)
5122 .filter(|ancestor| visible_worktree_entries.contains(*ancestor))
5123 .count();
5124 Some((depth + 1, difference))
5125 } else {
5126 None
5127 }
5128 })
5129 .unwrap_or_else(|| (0, entry.path.components().count()));
5130
5131 (depth, difference)
5132 }
5133
5134 fn highlight_entry_for_external_drag(
5135 &self,
5136 target_entry: &Entry,
5137 target_worktree: &Worktree,
5138 ) -> Option<ProjectEntryId> {
5139 // Always highlight directory or parent directory if it's file
5140 if target_entry.is_dir() {
5141 Some(target_entry.id)
5142 } else {
5143 target_entry
5144 .path
5145 .parent()
5146 .and_then(|parent_path| target_worktree.entry_for_path(parent_path))
5147 .map(|parent_entry| parent_entry.id)
5148 }
5149 }
5150
5151 fn highlight_entry_for_selection_drag(
5152 &self,
5153 target_entry: &Entry,
5154 target_worktree: &Worktree,
5155 drag_state: &DraggedSelection,
5156 cx: &Context<Self>,
5157 ) -> Option<ProjectEntryId> {
5158 let target_parent_path = target_entry.path.parent();
5159
5160 // In case of single item drag, we do not highlight existing
5161 // directory which item belongs too
5162 if drag_state.items().count() == 1
5163 && drag_state.active_selection.worktree_id == target_worktree.id()
5164 {
5165 let active_entry_path = self
5166 .project
5167 .read(cx)
5168 .path_for_entry(drag_state.active_selection.entry_id, cx)?;
5169
5170 if let Some(active_parent_path) = active_entry_path.path.parent() {
5171 // Do not highlight active entry parent
5172 if active_parent_path == target_entry.path.as_ref() {
5173 return None;
5174 }
5175
5176 // Do not highlight active entry sibling files
5177 if Some(active_parent_path) == target_parent_path && target_entry.is_file() {
5178 return None;
5179 }
5180 }
5181 }
5182
5183 // Always highlight directory or parent directory if it's file
5184 if target_entry.is_dir() {
5185 Some(target_entry.id)
5186 } else {
5187 target_parent_path
5188 .and_then(|parent_path| target_worktree.entry_for_path(parent_path))
5189 .map(|parent_entry| parent_entry.id)
5190 }
5191 }
5192
5193 fn should_highlight_background_for_selection_drag(
5194 &self,
5195 drag_state: &DraggedSelection,
5196 last_root_id: ProjectEntryId,
5197 cx: &App,
5198 ) -> bool {
5199 // Always highlight for multiple entries
5200 if drag_state.items().count() > 1 {
5201 return true;
5202 }
5203
5204 // Since root will always have empty relative path
5205 if let Some(entry_path) = self
5206 .project
5207 .read(cx)
5208 .path_for_entry(drag_state.active_selection.entry_id, cx)
5209 {
5210 if let Some(parent_path) = entry_path.path.parent() {
5211 if !parent_path.is_empty() {
5212 return true;
5213 }
5214 }
5215 }
5216
5217 // If parent is empty, check if different worktree
5218 if let Some(last_root_worktree_id) = self
5219 .project
5220 .read(cx)
5221 .worktree_id_for_entry(last_root_id, cx)
5222 {
5223 if drag_state.active_selection.worktree_id != last_root_worktree_id {
5224 return true;
5225 }
5226 }
5227
5228 false
5229 }
5230
5231 fn render_entry(
5232 &self,
5233 entry_id: ProjectEntryId,
5234 details: EntryDetails,
5235 window: &mut Window,
5236 cx: &mut Context<Self>,
5237 ) -> Stateful<Div> {
5238 const GROUP_NAME: &str = "project_entry";
5239
5240 let kind = details.kind;
5241 let is_sticky = details.sticky.is_some();
5242 let sticky_index = details.sticky.as_ref().map(|this| this.sticky_index);
5243 let settings = ProjectPanelSettings::get_global(cx);
5244 let show_editor = details.is_editing && !details.is_processing;
5245
5246 let selection = SelectedEntry {
5247 worktree_id: details.worktree_id,
5248 entry_id,
5249 };
5250
5251 let is_marked = self.marked_entries.contains(&selection);
5252 let is_active = self
5253 .selection
5254 .is_some_and(|selection| selection.entry_id == entry_id);
5255
5256 let file_name = details.filename.clone();
5257
5258 let mut icon = details.icon.clone();
5259 if settings.file_icons && show_editor && details.kind.is_file() {
5260 let filename = self.filename_editor.read(cx).text(cx);
5261 if filename.len() > 2 {
5262 icon = FileIcons::get_icon(Path::new(&filename), cx);
5263 }
5264 }
5265
5266 let filename_text_color = details.filename_text_color;
5267 let diagnostic_severity = details.diagnostic_severity;
5268 let diagnostic_count = details.diagnostic_count;
5269 let item_colors = get_item_color(is_sticky, cx);
5270
5271 let canonical_path = details
5272 .canonical_path
5273 .as_ref()
5274 .map(|f| f.to_string_lossy().into_owned());
5275 let path_style = self.project.read(cx).path_style(cx);
5276 let path = details.path.clone();
5277 let path_for_external_paths = path.clone();
5278 let path_for_dragged_selection = path.clone();
5279
5280 let depth = details.depth;
5281 let worktree_id = details.worktree_id;
5282 let dragged_selection = DraggedSelection {
5283 active_selection: SelectedEntry {
5284 worktree_id: selection.worktree_id,
5285 entry_id: selection.entry_id,
5286 },
5287 marked_selections: Arc::from(self.marked_entries.clone()),
5288 };
5289
5290 let bg_color = if is_marked {
5291 item_colors.marked
5292 } else {
5293 item_colors.default
5294 };
5295
5296 let bg_hover_color = if is_marked {
5297 item_colors.marked
5298 } else {
5299 item_colors.hover
5300 };
5301
5302 let validation_color_and_message = if show_editor {
5303 match self
5304 .state
5305 .edit_state
5306 .as_ref()
5307 .map_or(ValidationState::None, |e| e.validation_state.clone())
5308 {
5309 ValidationState::Error(msg) => Some((Color::Error.color(cx), msg)),
5310 ValidationState::Warning(msg) => Some((Color::Warning.color(cx), msg)),
5311 ValidationState::None => None,
5312 }
5313 } else {
5314 None
5315 };
5316
5317 let border_color =
5318 if !self.mouse_down && is_active && self.focus_handle.contains_focused(window, cx) {
5319 match validation_color_and_message {
5320 Some((color, _)) => color,
5321 None => item_colors.focused,
5322 }
5323 } else {
5324 bg_color
5325 };
5326
5327 let border_hover_color =
5328 if !self.mouse_down && is_active && self.focus_handle.contains_focused(window, cx) {
5329 match validation_color_and_message {
5330 Some((color, _)) => color,
5331 None => item_colors.focused,
5332 }
5333 } else {
5334 bg_hover_color
5335 };
5336
5337 let folded_directory_drag_target = self.folded_directory_drag_target;
5338 let is_highlighted = {
5339 if let Some(highlight_entry_id) =
5340 self.drag_target_entry
5341 .as_ref()
5342 .and_then(|drag_target| match drag_target {
5343 DragTarget::Entry {
5344 highlight_entry_id, ..
5345 } => Some(*highlight_entry_id),
5346 DragTarget::Background => self.state.last_worktree_root_id,
5347 })
5348 {
5349 // Highlight if same entry or it's children
5350 if entry_id == highlight_entry_id {
5351 true
5352 } else {
5353 maybe!({
5354 let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
5355 let highlight_entry = worktree.read(cx).entry_for_id(highlight_entry_id)?;
5356 Some(path.starts_with(&highlight_entry.path))
5357 })
5358 .unwrap_or(false)
5359 }
5360 } else {
5361 false
5362 }
5363 };
5364 let git_indicator = settings
5365 .git_status_indicator
5366 .then(|| git_status_indicator(details.git_status))
5367 .flatten();
5368
5369 let id: ElementId = if is_sticky {
5370 SharedString::from(format!("project_panel_sticky_item_{}", entry_id.to_usize())).into()
5371 } else {
5372 (entry_id.to_proto() as usize).into()
5373 };
5374
5375 div()
5376 .id(id.clone())
5377 .relative()
5378 .group(GROUP_NAME)
5379 .cursor_pointer()
5380 .rounded_none()
5381 .bg(bg_color)
5382 .border_1()
5383 .border_r_2()
5384 .border_color(border_color)
5385 .hover(|style| style.bg(bg_hover_color).border_color(border_hover_color))
5386 .when(is_sticky, |this| this.block_mouse_except_scroll())
5387 .when(!is_sticky, |this| {
5388 this.when(
5389 is_highlighted && folded_directory_drag_target.is_none(),
5390 |this| {
5391 this.border_color(transparent_white())
5392 .bg(item_colors.drag_over)
5393 },
5394 )
5395 .when(settings.drag_and_drop, |this| {
5396 this.on_drag_move::<ExternalPaths>(cx.listener(
5397 move |this, event: &DragMoveEvent<ExternalPaths>, _, cx| {
5398 let is_current_target =
5399 this.drag_target_entry
5400 .as_ref()
5401 .and_then(|entry| match entry {
5402 DragTarget::Entry {
5403 entry_id: target_id,
5404 ..
5405 } => Some(*target_id),
5406 DragTarget::Background { .. } => None,
5407 })
5408 == Some(entry_id);
5409
5410 if !event.bounds.contains(&event.event.position) {
5411 // Entry responsible for setting drag target is also responsible to
5412 // clear it up after drag is out of bounds
5413 if is_current_target {
5414 this.drag_target_entry = None;
5415 }
5416 return;
5417 }
5418
5419 if is_current_target {
5420 return;
5421 }
5422
5423 this.marked_entries.clear();
5424
5425 let Some((entry_id, highlight_entry_id)) = maybe!({
5426 let target_worktree = this
5427 .project
5428 .read(cx)
5429 .worktree_for_id(selection.worktree_id, cx)?
5430 .read(cx);
5431 let target_entry =
5432 target_worktree.entry_for_path(&path_for_external_paths)?;
5433 let highlight_entry_id = this.highlight_entry_for_external_drag(
5434 target_entry,
5435 target_worktree,
5436 )?;
5437 Some((target_entry.id, highlight_entry_id))
5438 }) else {
5439 return;
5440 };
5441
5442 this.drag_target_entry = Some(DragTarget::Entry {
5443 entry_id,
5444 highlight_entry_id,
5445 });
5446 },
5447 ))
5448 .on_drop(cx.listener(
5449 move |this, external_paths: &ExternalPaths, window, cx| {
5450 this.drag_target_entry = None;
5451 this.hover_scroll_task.take();
5452 this.drop_external_files(external_paths.paths(), entry_id, window, cx);
5453 cx.stop_propagation();
5454 },
5455 ))
5456 .on_drag_move::<DraggedSelection>(cx.listener(
5457 move |this, event: &DragMoveEvent<DraggedSelection>, window, cx| {
5458 let is_current_target =
5459 this.drag_target_entry
5460 .as_ref()
5461 .and_then(|entry| match entry {
5462 DragTarget::Entry {
5463 entry_id: target_id,
5464 ..
5465 } => Some(*target_id),
5466 DragTarget::Background { .. } => None,
5467 })
5468 == Some(entry_id);
5469
5470 if !event.bounds.contains(&event.event.position) {
5471 // Entry responsible for setting drag target is also responsible to
5472 // clear it up after drag is out of bounds
5473 if is_current_target {
5474 this.drag_target_entry = None;
5475 }
5476 return;
5477 }
5478
5479 if is_current_target {
5480 return;
5481 }
5482
5483 let drag_state = event.drag(cx);
5484
5485 if drag_state.items().count() == 1 {
5486 this.marked_entries.clear();
5487 this.marked_entries.push(drag_state.active_selection);
5488 }
5489
5490 let Some((entry_id, highlight_entry_id)) = maybe!({
5491 let target_worktree = this
5492 .project
5493 .read(cx)
5494 .worktree_for_id(selection.worktree_id, cx)?
5495 .read(cx);
5496 let target_entry =
5497 target_worktree.entry_for_path(&path_for_dragged_selection)?;
5498 let highlight_entry_id = this.highlight_entry_for_selection_drag(
5499 target_entry,
5500 target_worktree,
5501 drag_state,
5502 cx,
5503 )?;
5504 Some((target_entry.id, highlight_entry_id))
5505 }) else {
5506 return;
5507 };
5508
5509 this.drag_target_entry = Some(DragTarget::Entry {
5510 entry_id,
5511 highlight_entry_id,
5512 });
5513
5514 this.hover_expand_task.take();
5515
5516 if !kind.is_dir()
5517 || this
5518 .state
5519 .expanded_dir_ids
5520 .get(&details.worktree_id)
5521 .is_some_and(|ids| ids.binary_search(&entry_id).is_ok())
5522 {
5523 return;
5524 }
5525
5526 let bounds = event.bounds;
5527 this.hover_expand_task =
5528 Some(cx.spawn_in(window, async move |this, cx| {
5529 cx.background_executor()
5530 .timer(Duration::from_millis(500))
5531 .await;
5532 this.update_in(cx, |this, window, cx| {
5533 this.hover_expand_task.take();
5534 if this.drag_target_entry.as_ref().and_then(|entry| {
5535 match entry {
5536 DragTarget::Entry {
5537 entry_id: target_id,
5538 ..
5539 } => Some(*target_id),
5540 DragTarget::Background { .. } => None,
5541 }
5542 }) == Some(entry_id)
5543 && bounds.contains(&window.mouse_position())
5544 {
5545 this.expand_entry(worktree_id, entry_id, cx);
5546 this.update_visible_entries(
5547 Some((worktree_id, entry_id)),
5548 false,
5549 false,
5550 window,
5551 cx,
5552 );
5553 cx.notify();
5554 }
5555 })
5556 .ok();
5557 }));
5558 },
5559 ))
5560 .on_drag(dragged_selection, {
5561 let active_component =
5562 self.state.ancestors.get(&entry_id).and_then(|ancestors| {
5563 ancestors.active_component(&details.filename)
5564 });
5565 move |selection, click_offset, _window, cx| {
5566 let filename = active_component
5567 .as_ref()
5568 .unwrap_or_else(|| &details.filename);
5569 cx.new(|_| DraggedProjectEntryView {
5570 icon: details.icon.clone(),
5571 filename: filename.clone(),
5572 click_offset,
5573 selection: selection.active_selection,
5574 selections: selection.marked_selections.clone(),
5575 })
5576 }
5577 })
5578 .on_drop(cx.listener(
5579 move |this, selections: &DraggedSelection, window, cx| {
5580 this.drag_target_entry = None;
5581 this.hover_scroll_task.take();
5582 this.hover_expand_task.take();
5583 if folded_directory_drag_target.is_some() {
5584 return;
5585 }
5586 this.drag_onto(selections, entry_id, kind.is_file(), window, cx);
5587 },
5588 ))
5589 })
5590 })
5591 .on_mouse_down(
5592 MouseButton::Left,
5593 cx.listener(move |this, _, _, cx| {
5594 this.mouse_down = true;
5595 cx.propagate();
5596 }),
5597 )
5598 .on_click(
5599 cx.listener(move |project_panel, event: &gpui::ClickEvent, window, cx| {
5600 if event.is_right_click() || event.first_focus() || show_editor {
5601 return;
5602 }
5603 if event.standard_click() {
5604 project_panel.mouse_down = false;
5605 }
5606 cx.stop_propagation();
5607
5608 if let Some(selection) =
5609 project_panel.selection.filter(|_| event.modifiers().shift)
5610 {
5611 let current_selection = project_panel.index_for_selection(selection);
5612 let clicked_entry = SelectedEntry {
5613 entry_id,
5614 worktree_id,
5615 };
5616 let target_selection = project_panel.index_for_selection(clicked_entry);
5617 if let Some(((_, _, source_index), (_, _, target_index))) =
5618 current_selection.zip(target_selection)
5619 {
5620 let range_start = source_index.min(target_index);
5621 let range_end = source_index.max(target_index) + 1;
5622 let mut new_selections = Vec::new();
5623 project_panel.for_each_visible_entry(
5624 range_start..range_end,
5625 window,
5626 cx,
5627 &mut |entry_id, details, _, _| {
5628 new_selections.push(SelectedEntry {
5629 entry_id,
5630 worktree_id: details.worktree_id,
5631 });
5632 },
5633 );
5634
5635 for selection in &new_selections {
5636 if !project_panel.marked_entries.contains(selection) {
5637 project_panel.marked_entries.push(*selection);
5638 }
5639 }
5640
5641 project_panel.selection = Some(clicked_entry);
5642 if !project_panel.marked_entries.contains(&clicked_entry) {
5643 project_panel.marked_entries.push(clicked_entry);
5644 }
5645 }
5646 } else if event.modifiers().secondary() {
5647 if event.click_count() > 1 {
5648 project_panel.split_entry(entry_id, false, None, cx);
5649 } else {
5650 project_panel.selection = Some(selection);
5651 if let Some(position) = project_panel
5652 .marked_entries
5653 .iter()
5654 .position(|e| *e == selection)
5655 {
5656 project_panel.marked_entries.remove(position);
5657 } else {
5658 project_panel.marked_entries.push(selection);
5659 }
5660 }
5661 } else if kind.is_dir() {
5662 project_panel.marked_entries.clear();
5663 if is_sticky
5664 && let Some((_, _, index)) =
5665 project_panel.index_for_entry(entry_id, worktree_id)
5666 {
5667 project_panel
5668 .scroll_handle
5669 .scroll_to_item_strict_with_offset(
5670 index,
5671 ScrollStrategy::Top,
5672 sticky_index.unwrap_or(0),
5673 );
5674 cx.notify();
5675 // move down by 1px so that clicked item
5676 // don't count as sticky anymore
5677 cx.on_next_frame(window, |_, window, cx| {
5678 cx.on_next_frame(window, |this, _, cx| {
5679 let mut offset = this.scroll_handle.offset();
5680 offset.y += px(1.);
5681 this.scroll_handle.set_offset(offset);
5682 cx.notify();
5683 });
5684 });
5685 return;
5686 }
5687 if event.modifiers().alt {
5688 project_panel.toggle_expand_all(entry_id, window, cx);
5689 } else {
5690 project_panel.toggle_expanded(entry_id, window, cx);
5691 }
5692 } else {
5693 let preview_tabs_enabled =
5694 PreviewTabsSettings::get_global(cx).enable_preview_from_project_panel;
5695 let click_count = event.click_count();
5696 let focus_opened_item = click_count > 1;
5697 let allow_preview = preview_tabs_enabled && click_count == 1;
5698 project_panel.open_entry(entry_id, focus_opened_item, allow_preview, cx);
5699 }
5700 }),
5701 )
5702 .child(
5703 ListItem::new(id)
5704 .indent_level(depth)
5705 .indent_step_size(px(settings.indent_size))
5706 .spacing(match settings.entry_spacing {
5707 ProjectPanelEntrySpacing::Comfortable => ListItemSpacing::Dense,
5708 ProjectPanelEntrySpacing::Standard => ListItemSpacing::ExtraDense,
5709 })
5710 .selectable(false)
5711 .when(
5712 canonical_path.is_some()
5713 || diagnostic_count.is_some()
5714 || git_indicator.is_some(),
5715 |this| {
5716 let symlink_element = canonical_path.map(|path| {
5717 div()
5718 .id("symlink_icon")
5719 .tooltip(move |_window, cx| {
5720 Tooltip::with_meta(
5721 path.to_string(),
5722 None,
5723 "Symbolic Link",
5724 cx,
5725 )
5726 })
5727 .child(
5728 Icon::new(IconName::ArrowUpRight)
5729 .size(IconSize::Indicator)
5730 .color(filename_text_color),
5731 )
5732 });
5733 this.end_slot::<AnyElement>(
5734 h_flex()
5735 .gap_1()
5736 .flex_none()
5737 .pr_3()
5738 .when_some(diagnostic_count, |this, count| {
5739 this.when(count.error_count > 0, |this| {
5740 this.child(
5741 Label::new(count.capped_error_count())
5742 .size(LabelSize::Small)
5743 .color(Color::Error),
5744 )
5745 })
5746 .when(
5747 count.warning_count > 0,
5748 |this| {
5749 this.child(
5750 Label::new(count.capped_warning_count())
5751 .size(LabelSize::Small)
5752 .color(Color::Warning),
5753 )
5754 },
5755 )
5756 })
5757 .when_some(git_indicator, |this, (label, color)| {
5758 let git_indicator = if kind.is_dir() {
5759 Indicator::dot()
5760 .color(Color::Custom(color.color(cx).opacity(0.5)))
5761 .into_any_element()
5762 } else {
5763 Label::new(label)
5764 .size(LabelSize::Small)
5765 .color(color)
5766 .into_any_element()
5767 };
5768
5769 this.child(git_indicator)
5770 })
5771 .when_some(symlink_element, |this, el| this.child(el))
5772 .into_any_element(),
5773 )
5774 },
5775 )
5776 .child(if let Some(icon) = &icon {
5777 if let Some((_, decoration_color)) =
5778 entry_diagnostic_aware_icon_decoration_and_color(diagnostic_severity)
5779 {
5780 let is_warning = diagnostic_severity
5781 .map(|severity| matches!(severity, DiagnosticSeverity::WARNING))
5782 .unwrap_or(false);
5783 div().child(
5784 DecoratedIcon::new(
5785 Icon::from_path(icon.clone()).color(Color::Muted),
5786 Some(
5787 IconDecoration::new(
5788 if kind.is_file() {
5789 if is_warning {
5790 IconDecorationKind::Triangle
5791 } else {
5792 IconDecorationKind::X
5793 }
5794 } else {
5795 IconDecorationKind::Dot
5796 },
5797 bg_color,
5798 cx,
5799 )
5800 .group_name(Some(GROUP_NAME.into()))
5801 .knockout_hover_color(bg_hover_color)
5802 .color(decoration_color.color(cx))
5803 .position(Point {
5804 x: px(-2.),
5805 y: px(-2.),
5806 }),
5807 ),
5808 )
5809 .into_any_element(),
5810 )
5811 } else {
5812 h_flex().child(Icon::from_path(icon.to_string()).color(Color::Muted))
5813 }
5814 } else if let Some((icon_name, color)) =
5815 entry_diagnostic_aware_icon_name_and_color(diagnostic_severity)
5816 {
5817 h_flex()
5818 .size(IconSize::default().rems())
5819 .child(Icon::new(icon_name).color(color).size(IconSize::Small))
5820 } else {
5821 h_flex()
5822 .size(IconSize::default().rems())
5823 .invisible()
5824 .flex_none()
5825 })
5826 .child(if show_editor {
5827 h_flex().h_6().w_full().child(self.filename_editor.clone())
5828 } else {
5829 h_flex()
5830 .h_6()
5831 .map(|this| match self.state.ancestors.get(&entry_id) {
5832 Some(folded_ancestors) => {
5833 this.children(self.render_folder_elements(
5834 folded_ancestors,
5835 entry_id,
5836 file_name,
5837 path_style,
5838 is_sticky,
5839 kind.is_file(),
5840 is_active || is_marked,
5841 settings.drag_and_drop,
5842 settings.bold_folder_labels,
5843 item_colors.drag_over,
5844 folded_directory_drag_target,
5845 filename_text_color,
5846 cx,
5847 ))
5848 }
5849
5850 None => this.child(
5851 Label::new(file_name)
5852 .single_line()
5853 .color(filename_text_color)
5854 .when(
5855 settings.bold_folder_labels && kind.is_dir(),
5856 |this| this.weight(FontWeight::SEMIBOLD),
5857 )
5858 .into_any_element(),
5859 ),
5860 })
5861 })
5862 .on_secondary_mouse_down(cx.listener(
5863 move |this, event: &MouseDownEvent, window, cx| {
5864 // Stop propagation to prevent the catch-all context menu for the project
5865 // panel from being deployed.
5866 cx.stop_propagation();
5867 // Some context menu actions apply to all marked entries. If the user
5868 // right-clicks on an entry that is not marked, they may not realize the
5869 // action applies to multiple entries. To avoid inadvertent changes, all
5870 // entries are unmarked.
5871 if !this.marked_entries.contains(&selection) {
5872 this.marked_entries.clear();
5873 }
5874 this.deploy_context_menu(event.position, entry_id, window, cx);
5875 },
5876 ))
5877 .overflow_x(),
5878 )
5879 .when_some(validation_color_and_message, |this, (color, message)| {
5880 this.relative().child(deferred(
5881 div()
5882 .occlude()
5883 .absolute()
5884 .top_full()
5885 .left(px(-1.)) // Used px over rem so that it doesn't change with font size
5886 .right(px(-0.5))
5887 .py_1()
5888 .px_2()
5889 .border_1()
5890 .border_color(color)
5891 .bg(cx.theme().colors().background)
5892 .child(
5893 Label::new(message)
5894 .color(Color::from(color))
5895 .size(LabelSize::Small),
5896 ),
5897 ))
5898 })
5899 }
5900
5901 fn render_folder_elements(
5902 &self,
5903 folded_ancestors: &FoldedAncestors,
5904 entry_id: ProjectEntryId,
5905 file_name: String,
5906 path_style: PathStyle,
5907 is_sticky: bool,
5908 is_file: bool,
5909 is_active_or_marked: bool,
5910 drag_and_drop_enabled: bool,
5911 bold_folder_labels: bool,
5912 drag_over_color: Hsla,
5913 folded_directory_drag_target: Option<FoldedDirectoryDragTarget>,
5914 filename_text_color: Color,
5915 cx: &Context<Self>,
5916 ) -> impl Iterator<Item = AnyElement> {
5917 let components = Path::new(&file_name)
5918 .components()
5919 .map(|comp| comp.as_os_str().to_string_lossy().into_owned())
5920 .collect::<Vec<_>>();
5921 let active_index = folded_ancestors.active_index();
5922 let components_len = components.len();
5923 let delimiter = SharedString::new(path_style.primary_separator());
5924
5925 let path_component_elements =
5926 components
5927 .into_iter()
5928 .enumerate()
5929 .map(move |(index, component)| {
5930 div()
5931 .id(SharedString::from(format!(
5932 "project_panel_path_component_{}_{index}",
5933 entry_id.to_usize()
5934 )))
5935 .when(index == 0, |this| this.ml_neg_0p5())
5936 .px_0p5()
5937 .rounded_xs()
5938 .hover(|style| style.bg(cx.theme().colors().element_active))
5939 .when(!is_sticky, |div| {
5940 div.when(index != components_len - 1, |div| {
5941 let target_entry_id = folded_ancestors
5942 .ancestors
5943 .get(components_len - 1 - index)
5944 .cloned();
5945 div.when(drag_and_drop_enabled, |div| {
5946 div.on_drag_move(cx.listener(
5947 move |this,
5948 event: &DragMoveEvent<DraggedSelection>,
5949 _,
5950 _| {
5951 if event.bounds.contains(&event.event.position) {
5952 this.folded_directory_drag_target =
5953 Some(FoldedDirectoryDragTarget {
5954 entry_id,
5955 index,
5956 is_delimiter_target: false,
5957 });
5958 } else {
5959 let is_current_target = this
5960 .folded_directory_drag_target
5961 .as_ref()
5962 .is_some_and(|target| {
5963 target.entry_id == entry_id
5964 && target.index == index
5965 && !target.is_delimiter_target
5966 });
5967 if is_current_target {
5968 this.folded_directory_drag_target = None;
5969 }
5970 }
5971 },
5972 ))
5973 .on_drop(cx.listener(
5974 move |this, selections: &DraggedSelection, window, cx| {
5975 this.hover_scroll_task.take();
5976 this.drag_target_entry = None;
5977 this.folded_directory_drag_target = None;
5978 if let Some(target_entry_id) = target_entry_id {
5979 this.drag_onto(
5980 selections,
5981 target_entry_id,
5982 is_file,
5983 window,
5984 cx,
5985 );
5986 }
5987 },
5988 ))
5989 .when(
5990 folded_directory_drag_target.is_some_and(|target| {
5991 target.entry_id == entry_id && target.index == index
5992 }),
5993 |this| this.bg(drag_over_color),
5994 )
5995 })
5996 })
5997 })
5998 .on_mouse_down(
5999 MouseButton::Left,
6000 cx.listener(move |this, _, _, cx| {
6001 if let Some(folds) = this.state.ancestors.get_mut(&entry_id) {
6002 if folds.set_active_index(index) {
6003 cx.notify();
6004 }
6005 }
6006 }),
6007 )
6008 .on_mouse_down(
6009 MouseButton::Right,
6010 cx.listener(move |this, _, _, cx| {
6011 if let Some(folds) = this.state.ancestors.get_mut(&entry_id) {
6012 if folds.set_active_index(index) {
6013 cx.notify();
6014 }
6015 }
6016 }),
6017 )
6018 .child(
6019 Label::new(component)
6020 .single_line()
6021 .color(filename_text_color)
6022 .when(bold_folder_labels && !is_file, |this| {
6023 this.weight(FontWeight::SEMIBOLD)
6024 })
6025 .when(index == active_index && is_active_or_marked, |this| {
6026 this.underline()
6027 }),
6028 )
6029 .into_any()
6030 });
6031
6032 let mut separator_index = 0;
6033 itertools::intersperse_with(path_component_elements, move || {
6034 separator_index += 1;
6035 self.render_entry_path_separator(
6036 entry_id,
6037 separator_index,
6038 components_len,
6039 is_sticky,
6040 is_file,
6041 drag_and_drop_enabled,
6042 filename_text_color,
6043 &delimiter,
6044 folded_ancestors,
6045 cx,
6046 )
6047 .into_any()
6048 })
6049 }
6050
6051 fn render_entry_path_separator(
6052 &self,
6053 entry_id: ProjectEntryId,
6054 index: usize,
6055 components_len: usize,
6056 is_sticky: bool,
6057 is_file: bool,
6058 drag_and_drop_enabled: bool,
6059 filename_text_color: Color,
6060 delimiter: &SharedString,
6061 folded_ancestors: &FoldedAncestors,
6062 cx: &Context<Self>,
6063 ) -> Div {
6064 let delimiter_target_index = index - 1;
6065 let target_entry_id = folded_ancestors
6066 .ancestors
6067 .get(components_len - 1 - delimiter_target_index)
6068 .cloned();
6069 div()
6070 .when(!is_sticky, |div| {
6071 div.when(drag_and_drop_enabled, |div| {
6072 div.on_drop(cx.listener(
6073 move |this, selections: &DraggedSelection, window, cx| {
6074 this.hover_scroll_task.take();
6075 this.drag_target_entry = None;
6076 this.folded_directory_drag_target = None;
6077 if let Some(target_entry_id) = target_entry_id {
6078 this.drag_onto(selections, target_entry_id, is_file, window, cx);
6079 }
6080 },
6081 ))
6082 .on_drag_move(cx.listener(
6083 move |this, event: &DragMoveEvent<DraggedSelection>, _, _| {
6084 if event.bounds.contains(&event.event.position) {
6085 this.folded_directory_drag_target =
6086 Some(FoldedDirectoryDragTarget {
6087 entry_id,
6088 index: delimiter_target_index,
6089 is_delimiter_target: true,
6090 });
6091 } else {
6092 let is_current_target =
6093 this.folded_directory_drag_target.is_some_and(|target| {
6094 target.entry_id == entry_id
6095 && target.index == delimiter_target_index
6096 && target.is_delimiter_target
6097 });
6098 if is_current_target {
6099 this.folded_directory_drag_target = None;
6100 }
6101 }
6102 },
6103 ))
6104 })
6105 })
6106 .child(
6107 Label::new(delimiter.clone())
6108 .single_line()
6109 .color(filename_text_color),
6110 )
6111 }
6112
6113 fn details_for_entry(
6114 &self,
6115 entry: &Entry,
6116 worktree_id: WorktreeId,
6117 root_name: &RelPath,
6118 entries_paths: &HashSet<Arc<RelPath>>,
6119 git_status: GitSummary,
6120 sticky: Option<StickyDetails>,
6121 _window: &mut Window,
6122 cx: &mut Context<Self>,
6123 ) -> EntryDetails {
6124 let (show_file_icons, show_folder_icons) = {
6125 let settings = ProjectPanelSettings::get_global(cx);
6126 (settings.file_icons, settings.folder_icons)
6127 };
6128
6129 let expanded_entry_ids = self
6130 .state
6131 .expanded_dir_ids
6132 .get(&worktree_id)
6133 .map(Vec::as_slice)
6134 .unwrap_or(&[]);
6135 let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok();
6136
6137 let icon = match entry.kind {
6138 EntryKind::File => {
6139 if show_file_icons {
6140 FileIcons::get_icon(entry.path.as_std_path(), cx)
6141 } else {
6142 None
6143 }
6144 }
6145 _ => {
6146 if show_folder_icons {
6147 FileIcons::get_folder_icon(is_expanded, entry.path.as_std_path(), cx)
6148 } else {
6149 FileIcons::get_chevron_icon(is_expanded, cx)
6150 }
6151 }
6152 };
6153
6154 let path_style = self.project.read(cx).path_style(cx);
6155 let (depth, difference) =
6156 ProjectPanel::calculate_depth_and_difference(entry, entries_paths);
6157
6158 let filename = if difference > 1 {
6159 entry
6160 .path
6161 .last_n_components(difference)
6162 .map_or(String::new(), |suffix| {
6163 suffix.display(path_style).to_string()
6164 })
6165 } else {
6166 entry
6167 .path
6168 .file_name()
6169 .map(|name| name.to_string())
6170 .unwrap_or_else(|| root_name.as_unix_str().to_string())
6171 };
6172
6173 let selection = SelectedEntry {
6174 worktree_id,
6175 entry_id: entry.id,
6176 };
6177 let is_marked = self.marked_entries.contains(&selection);
6178 let is_selected = self.selection == Some(selection);
6179
6180 let diagnostic_severity = self
6181 .diagnostics
6182 .get(&(worktree_id, entry.path.clone()))
6183 .cloned();
6184
6185 let diagnostic_count = self
6186 .diagnostic_counts
6187 .get(&(worktree_id, entry.path.clone()))
6188 .copied();
6189
6190 let filename_text_color =
6191 entry_git_aware_label_color(git_status, entry.is_ignored, is_marked);
6192
6193 let is_cut = self
6194 .clipboard
6195 .as_ref()
6196 .is_some_and(|e| e.is_cut() && e.items().contains(&selection));
6197
6198 EntryDetails {
6199 filename,
6200 icon,
6201 path: entry.path.clone(),
6202 depth,
6203 kind: entry.kind,
6204 is_ignored: entry.is_ignored,
6205 is_expanded,
6206 is_selected,
6207 is_marked,
6208 is_editing: false,
6209 is_processing: false,
6210 is_cut,
6211 sticky,
6212 filename_text_color,
6213 diagnostic_severity,
6214 diagnostic_count,
6215 git_status,
6216 is_private: entry.is_private,
6217 worktree_id,
6218 canonical_path: entry.canonical_path.clone(),
6219 }
6220 }
6221
6222 fn dispatch_context(&self, window: &Window, cx: &Context<Self>) -> KeyContext {
6223 let mut dispatch_context = KeyContext::new_with_defaults();
6224 dispatch_context.add("ProjectPanel");
6225 dispatch_context.add("menu");
6226
6227 let identifier = if self.filename_editor.focus_handle(cx).is_focused(window) {
6228 "editing"
6229 } else {
6230 "not_editing"
6231 };
6232
6233 dispatch_context.add(identifier);
6234 dispatch_context
6235 }
6236
6237 fn reveal_entry(
6238 &mut self,
6239 project: Entity<Project>,
6240 entry_id: ProjectEntryId,
6241 skip_ignored: bool,
6242 window: &mut Window,
6243 cx: &mut Context<Self>,
6244 ) -> Result<()> {
6245 let worktree = project
6246 .read(cx)
6247 .worktree_for_entry(entry_id, cx)
6248 .context("can't reveal a non-existent entry in the project panel")?;
6249 let worktree = worktree.read(cx);
6250 let worktree_id = worktree.id();
6251 let is_ignored = worktree
6252 .entry_for_id(entry_id)
6253 .is_none_or(|entry| entry.is_ignored && !entry.is_always_included);
6254 if skip_ignored && is_ignored {
6255 if self.index_for_entry(entry_id, worktree_id).is_none() {
6256 anyhow::bail!("can't reveal an ignored entry in the project panel");
6257 }
6258
6259 self.selection = Some(SelectedEntry {
6260 worktree_id,
6261 entry_id,
6262 });
6263 self.marked_entries.clear();
6264 self.marked_entries.push(SelectedEntry {
6265 worktree_id,
6266 entry_id,
6267 });
6268 self.autoscroll(cx);
6269 cx.notify();
6270 return Ok(());
6271 }
6272 let is_active_item_file_diff_view = self
6273 .workspace
6274 .upgrade()
6275 .and_then(|ws| ws.read(cx).active_item(cx))
6276 .map(|item| item.act_as_type(TypeId::of::<FileDiffView>(), cx).is_some())
6277 .unwrap_or(false);
6278 if is_active_item_file_diff_view {
6279 return Ok(());
6280 }
6281
6282 self.expand_entry(worktree_id, entry_id, cx);
6283 self.update_visible_entries(Some((worktree_id, entry_id)), false, true, window, cx);
6284 self.marked_entries.clear();
6285 self.marked_entries.push(SelectedEntry {
6286 worktree_id,
6287 entry_id,
6288 });
6289 cx.notify();
6290 Ok(())
6291 }
6292
6293 fn find_active_indent_guide(
6294 &self,
6295 indent_guides: &[IndentGuideLayout],
6296 cx: &App,
6297 ) -> Option<usize> {
6298 let (worktree, entry) = self.selected_entry(cx)?;
6299
6300 // Find the parent entry of the indent guide, this will either be the
6301 // expanded folder we have selected, or the parent of the currently
6302 // selected file/collapsed directory
6303 let mut entry = entry;
6304 loop {
6305 let is_expanded_dir = entry.is_dir()
6306 && self
6307 .state
6308 .expanded_dir_ids
6309 .get(&worktree.id())
6310 .map(|ids| ids.binary_search(&entry.id).is_ok())
6311 .unwrap_or(false);
6312 if is_expanded_dir {
6313 break;
6314 }
6315 entry = worktree.entry_for_path(&entry.path.parent()?)?;
6316 }
6317
6318 let (active_indent_range, depth) = {
6319 let (worktree_ix, child_offset, ix) = self.index_for_entry(entry.id, worktree.id())?;
6320 let child_paths = &self.state.visible_entries[worktree_ix].entries;
6321 let mut child_count = 0;
6322 let depth = entry.path.ancestors().count();
6323 while let Some(entry) = child_paths.get(child_offset + child_count + 1) {
6324 if entry.path.ancestors().count() <= depth {
6325 break;
6326 }
6327 child_count += 1;
6328 }
6329
6330 let start = ix + 1;
6331 let end = start + child_count;
6332
6333 let visible_worktree = &self.state.visible_entries[worktree_ix];
6334 let visible_worktree_entries = visible_worktree.index.get_or_init(|| {
6335 visible_worktree
6336 .entries
6337 .iter()
6338 .map(|e| e.path.clone())
6339 .collect()
6340 });
6341
6342 // Calculate the actual depth of the entry, taking into account that directories can be auto-folded.
6343 let (depth, _) = Self::calculate_depth_and_difference(entry, visible_worktree_entries);
6344 (start..end, depth)
6345 };
6346
6347 let candidates = indent_guides
6348 .iter()
6349 .enumerate()
6350 .filter(|(_, indent_guide)| indent_guide.offset.x == depth);
6351
6352 for (i, indent) in candidates {
6353 // Find matches that are either an exact match, partially on screen, or inside the enclosing indent
6354 if active_indent_range.start <= indent.offset.y + indent.length
6355 && indent.offset.y <= active_indent_range.end
6356 {
6357 return Some(i);
6358 }
6359 }
6360 None
6361 }
6362
6363 fn render_sticky_entries(
6364 &self,
6365 child: StickyProjectPanelCandidate,
6366 window: &mut Window,
6367 cx: &mut Context<Self>,
6368 ) -> SmallVec<[AnyElement; 8]> {
6369 let project = self.project.read(cx);
6370
6371 let Some((worktree_id, entry_ref)) = self.entry_at_index(child.index) else {
6372 return SmallVec::new();
6373 };
6374
6375 let Some(visible) = self
6376 .state
6377 .visible_entries
6378 .iter()
6379 .find(|worktree| worktree.worktree_id == worktree_id)
6380 else {
6381 return SmallVec::new();
6382 };
6383
6384 let Some(worktree) = project.worktree_for_id(worktree_id, cx) else {
6385 return SmallVec::new();
6386 };
6387 let worktree = worktree.read(cx).snapshot();
6388
6389 let paths = visible
6390 .index
6391 .get_or_init(|| visible.entries.iter().map(|e| e.path.clone()).collect());
6392
6393 let mut sticky_parents = Vec::new();
6394 let mut current_path = entry_ref.path.clone();
6395
6396 'outer: loop {
6397 if let Some(parent_path) = current_path.parent() {
6398 for ancestor_path in parent_path.ancestors() {
6399 if paths.contains(ancestor_path)
6400 && let Some(parent_entry) = worktree.entry_for_path(ancestor_path)
6401 {
6402 sticky_parents.push(parent_entry.clone());
6403 current_path = parent_entry.path.clone();
6404 continue 'outer;
6405 }
6406 }
6407 }
6408 break 'outer;
6409 }
6410
6411 if sticky_parents.is_empty() {
6412 return SmallVec::new();
6413 }
6414
6415 sticky_parents.reverse();
6416
6417 let panel_settings = ProjectPanelSettings::get_global(cx);
6418 let git_status_enabled = panel_settings.git_status;
6419 let root_name = worktree.root_name();
6420
6421 let git_summaries_by_id = if git_status_enabled {
6422 visible
6423 .entries
6424 .iter()
6425 .map(|e| (e.id, e.git_summary))
6426 .collect::<HashMap<_, _>>()
6427 } else {
6428 Default::default()
6429 };
6430
6431 // already checked if non empty above
6432 let last_item_index = sticky_parents.len() - 1;
6433 sticky_parents
6434 .iter()
6435 .enumerate()
6436 .map(|(index, entry)| {
6437 let git_status = git_summaries_by_id
6438 .get(&entry.id)
6439 .copied()
6440 .unwrap_or_default();
6441 let sticky_details = Some(StickyDetails {
6442 sticky_index: index,
6443 });
6444 let details = self.details_for_entry(
6445 entry,
6446 worktree_id,
6447 root_name,
6448 paths,
6449 git_status,
6450 sticky_details,
6451 window,
6452 cx,
6453 );
6454 self.render_entry(entry.id, details, window, cx)
6455 .when(index == last_item_index, |this| {
6456 let shadow_color_top = hsla(0.0, 0.0, 0.0, 0.1);
6457 let shadow_color_bottom = hsla(0.0, 0.0, 0.0, 0.);
6458 let sticky_shadow = div()
6459 .absolute()
6460 .left_0()
6461 .bottom_neg_1p5()
6462 .h_1p5()
6463 .w_full()
6464 .bg(linear_gradient(
6465 0.,
6466 linear_color_stop(shadow_color_top, 1.),
6467 linear_color_stop(shadow_color_bottom, 0.),
6468 ));
6469 this.child(sticky_shadow)
6470 })
6471 .into_any()
6472 })
6473 .collect()
6474 }
6475}
6476
6477#[derive(Clone)]
6478struct StickyProjectPanelCandidate {
6479 index: usize,
6480 depth: usize,
6481}
6482
6483impl StickyCandidate for StickyProjectPanelCandidate {
6484 fn depth(&self) -> usize {
6485 self.depth
6486 }
6487}
6488
6489fn item_width_estimate(depth: usize, item_text_chars: usize, is_symlink: bool) -> usize {
6490 const ICON_SIZE_FACTOR: usize = 2;
6491 let mut item_width = depth * ICON_SIZE_FACTOR + item_text_chars;
6492 if is_symlink {
6493 item_width += ICON_SIZE_FACTOR;
6494 }
6495 item_width
6496}
6497
6498impl Render for ProjectPanel {
6499 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
6500 let has_worktree = !self.state.visible_entries.is_empty();
6501 let project = self.project.read(cx);
6502 let panel_settings = ProjectPanelSettings::get_global(cx);
6503 let indent_size = panel_settings.indent_size;
6504 let show_indent_guides = panel_settings.indent_guides.show == ShowIndentGuides::Always;
6505 let horizontal_scroll = panel_settings.scrollbar.horizontal_scroll;
6506 let show_sticky_entries = {
6507 if panel_settings.sticky_scroll {
6508 let is_scrollable = self.scroll_handle.is_scrollable();
6509 let is_scrolled = self.scroll_handle.offset().y < px(0.);
6510 is_scrollable && is_scrolled
6511 } else {
6512 false
6513 }
6514 };
6515
6516 let is_local = project.is_local();
6517
6518 if has_worktree {
6519 let item_count = self
6520 .state
6521 .visible_entries
6522 .iter()
6523 .map(|worktree| worktree.entries.len())
6524 .sum();
6525
6526 fn handle_drag_move<T: 'static>(
6527 this: &mut ProjectPanel,
6528 e: &DragMoveEvent<T>,
6529 window: &mut Window,
6530 cx: &mut Context<ProjectPanel>,
6531 ) {
6532 if let Some(previous_position) = this.previous_drag_position {
6533 // Refresh cursor only when an actual drag happens,
6534 // because modifiers are not updated when the cursor is not moved.
6535 if e.event.position != previous_position {
6536 this.refresh_drag_cursor_style(&e.event.modifiers, window, cx);
6537 }
6538 }
6539 this.previous_drag_position = Some(e.event.position);
6540
6541 if !e.bounds.contains(&e.event.position) {
6542 this.drag_target_entry = None;
6543 return;
6544 }
6545 this.hover_scroll_task.take();
6546 let panel_height = e.bounds.size.height;
6547 if panel_height <= px(0.) {
6548 return;
6549 }
6550
6551 let event_offset = e.event.position.y - e.bounds.origin.y;
6552 // How far along in the project panel is our cursor? (0. is the top of a list, 1. is the bottom)
6553 let hovered_region_offset = event_offset / panel_height;
6554
6555 // We want the scrolling to be a bit faster when the cursor is closer to the edge of a list.
6556 // These pixels offsets were picked arbitrarily.
6557 let vertical_scroll_offset = if hovered_region_offset <= 0.05 {
6558 8.
6559 } else if hovered_region_offset <= 0.15 {
6560 5.
6561 } else if hovered_region_offset >= 0.95 {
6562 -8.
6563 } else if hovered_region_offset >= 0.85 {
6564 -5.
6565 } else {
6566 return;
6567 };
6568 let adjustment = point(px(0.), px(vertical_scroll_offset));
6569 this.hover_scroll_task = Some(cx.spawn_in(window, async move |this, cx| {
6570 loop {
6571 let should_stop_scrolling = this
6572 .update(cx, |this, cx| {
6573 this.hover_scroll_task.as_ref()?;
6574 let handle = this.scroll_handle.0.borrow_mut();
6575 let offset = handle.base_handle.offset();
6576
6577 handle.base_handle.set_offset(offset + adjustment);
6578 cx.notify();
6579 Some(())
6580 })
6581 .ok()
6582 .flatten()
6583 .is_some();
6584 if should_stop_scrolling {
6585 return;
6586 }
6587 cx.background_executor()
6588 .timer(Duration::from_millis(16))
6589 .await;
6590 }
6591 }));
6592 }
6593 h_flex()
6594 .id("project-panel")
6595 .group("project-panel")
6596 .when(panel_settings.drag_and_drop, |this| {
6597 this.on_drag_move(cx.listener(handle_drag_move::<ExternalPaths>))
6598 .on_drag_move(cx.listener(handle_drag_move::<DraggedSelection>))
6599 })
6600 .size_full()
6601 .relative()
6602 .on_modifiers_changed(cx.listener(
6603 |this, event: &ModifiersChangedEvent, window, cx| {
6604 this.refresh_drag_cursor_style(&event.modifiers, window, cx);
6605 },
6606 ))
6607 .key_context(self.dispatch_context(window, cx))
6608 .on_action(cx.listener(Self::scroll_up))
6609 .on_action(cx.listener(Self::scroll_down))
6610 .on_action(cx.listener(Self::scroll_cursor_center))
6611 .on_action(cx.listener(Self::scroll_cursor_top))
6612 .on_action(cx.listener(Self::scroll_cursor_bottom))
6613 .on_action(cx.listener(Self::select_next))
6614 .on_action(cx.listener(Self::select_previous))
6615 .on_action(cx.listener(Self::select_first))
6616 .on_action(cx.listener(Self::select_last))
6617 .on_action(cx.listener(Self::select_parent))
6618 .on_action(cx.listener(Self::select_next_git_entry))
6619 .on_action(cx.listener(Self::select_prev_git_entry))
6620 .on_action(cx.listener(Self::select_next_diagnostic))
6621 .on_action(cx.listener(Self::select_prev_diagnostic))
6622 .on_action(cx.listener(Self::select_next_directory))
6623 .on_action(cx.listener(Self::select_prev_directory))
6624 .on_action(cx.listener(Self::expand_selected_entry))
6625 .on_action(cx.listener(Self::collapse_selected_entry))
6626 .on_action(cx.listener(Self::collapse_all_entries))
6627 .on_action(cx.listener(Self::collapse_selected_entry_and_children))
6628 .on_action(cx.listener(Self::open))
6629 .on_action(cx.listener(Self::open_permanent))
6630 .on_action(cx.listener(Self::open_split_vertical))
6631 .on_action(cx.listener(Self::open_split_horizontal))
6632 .on_action(cx.listener(Self::confirm))
6633 .on_action(cx.listener(Self::cancel))
6634 .on_action(cx.listener(Self::copy_path))
6635 .on_action(cx.listener(Self::copy_relative_path))
6636 .on_action(cx.listener(Self::new_search_in_directory))
6637 .on_action(cx.listener(Self::unfold_directory))
6638 .on_action(cx.listener(Self::fold_directory))
6639 .on_action(cx.listener(Self::remove_from_project))
6640 .on_action(cx.listener(Self::compare_marked_files))
6641 .when(cx.has_flag::<ProjectPanelUndoRedoFeatureFlag>(), |el| {
6642 el.on_action(cx.listener(Self::undo))
6643 })
6644 .when(!project.is_read_only(cx), |el| {
6645 el.on_action(cx.listener(Self::new_file))
6646 .on_action(cx.listener(Self::new_directory))
6647 .on_action(cx.listener(Self::rename))
6648 .on_action(cx.listener(Self::delete))
6649 .on_action(cx.listener(Self::cut))
6650 .on_action(cx.listener(Self::copy))
6651 .on_action(cx.listener(Self::paste))
6652 .on_action(cx.listener(Self::duplicate))
6653 .on_action(cx.listener(Self::restore_file))
6654 .when(!project.is_remote(), |el| {
6655 el.on_action(cx.listener(Self::trash))
6656 })
6657 })
6658 .when(
6659 project.is_local() || project.is_via_wsl_with_host_interop(cx),
6660 |el| {
6661 el.on_action(cx.listener(Self::reveal_in_finder))
6662 .on_action(cx.listener(Self::open_system))
6663 .on_action(cx.listener(Self::open_in_terminal))
6664 },
6665 )
6666 .when(project.is_via_remote_server(), |el| {
6667 el.on_action(cx.listener(Self::open_in_terminal))
6668 .on_action(cx.listener(Self::download_from_remote))
6669 })
6670 .track_focus(&self.focus_handle(cx))
6671 .child(
6672 v_flex()
6673 .child(
6674 uniform_list("entries", item_count, {
6675 cx.processor(|this, range: Range<usize>, window, cx| {
6676 this.rendered_entries_len = range.end - range.start;
6677 let mut items = Vec::with_capacity(this.rendered_entries_len);
6678 this.for_each_visible_entry(
6679 range,
6680 window,
6681 cx,
6682 &mut |id, details, window, cx| {
6683 items.push(this.render_entry(id, details, window, cx));
6684 },
6685 );
6686 items
6687 })
6688 })
6689 .when(show_indent_guides, |list| {
6690 list.with_decoration(
6691 ui::indent_guides(
6692 px(indent_size),
6693 IndentGuideColors::panel(cx),
6694 )
6695 .with_compute_indents_fn(
6696 cx.entity(),
6697 |this, range, window, cx| {
6698 let mut items =
6699 SmallVec::with_capacity(range.end - range.start);
6700 this.iter_visible_entries(
6701 range,
6702 window,
6703 cx,
6704 &mut |entry, _, entries, _, _| {
6705 let (depth, _) =
6706 Self::calculate_depth_and_difference(
6707 entry, entries,
6708 );
6709 items.push(depth);
6710 },
6711 );
6712 items
6713 },
6714 )
6715 .on_click(cx.listener(
6716 |this,
6717 active_indent_guide: &IndentGuideLayout,
6718 window,
6719 cx| {
6720 if window.modifiers().secondary() {
6721 let ix = active_indent_guide.offset.y;
6722 let Some((target_entry, worktree)) = maybe!({
6723 let (worktree_id, entry) =
6724 this.entry_at_index(ix)?;
6725 let worktree = this
6726 .project
6727 .read(cx)
6728 .worktree_for_id(worktree_id, cx)?;
6729 let target_entry = worktree
6730 .read(cx)
6731 .entry_for_path(&entry.path.parent()?)?;
6732 Some((target_entry, worktree))
6733 }) else {
6734 return;
6735 };
6736
6737 this.collapse_entry(
6738 target_entry.clone(),
6739 worktree,
6740 window,
6741 cx,
6742 );
6743 }
6744 },
6745 ))
6746 .with_render_fn(
6747 cx.entity(),
6748 move |this, params, _, cx| {
6749 const LEFT_OFFSET: Pixels = px(14.);
6750 const PADDING_Y: Pixels = px(4.);
6751 const HITBOX_OVERDRAW: Pixels = px(3.);
6752
6753 let active_indent_guide_index = this
6754 .find_active_indent_guide(
6755 ¶ms.indent_guides,
6756 cx,
6757 );
6758
6759 let indent_size = params.indent_size;
6760 let item_height = params.item_height;
6761
6762 params
6763 .indent_guides
6764 .into_iter()
6765 .enumerate()
6766 .map(|(idx, layout)| {
6767 let offset = if layout.continues_offscreen {
6768 px(0.)
6769 } else {
6770 PADDING_Y
6771 };
6772 let bounds = Bounds::new(
6773 point(
6774 layout.offset.x * indent_size
6775 + LEFT_OFFSET,
6776 layout.offset.y * item_height + offset,
6777 ),
6778 size(
6779 px(1.),
6780 layout.length * item_height
6781 - offset * 2.,
6782 ),
6783 );
6784 ui::RenderedIndentGuide {
6785 bounds,
6786 layout,
6787 is_active: Some(idx)
6788 == active_indent_guide_index,
6789 hitbox: Some(Bounds::new(
6790 point(
6791 bounds.origin.x - HITBOX_OVERDRAW,
6792 bounds.origin.y,
6793 ),
6794 size(
6795 bounds.size.width
6796 + HITBOX_OVERDRAW * 2.,
6797 bounds.size.height,
6798 ),
6799 )),
6800 }
6801 })
6802 .collect()
6803 },
6804 ),
6805 )
6806 })
6807 .when(show_sticky_entries, |list| {
6808 let sticky_items = ui::sticky_items(
6809 cx.entity(),
6810 |this, range, window, cx| {
6811 let mut items =
6812 SmallVec::with_capacity(range.end - range.start);
6813 this.iter_visible_entries(
6814 range,
6815 window,
6816 cx,
6817 &mut |entry, index, entries, _, _| {
6818 let (depth, _) =
6819 Self::calculate_depth_and_difference(
6820 entry, entries,
6821 );
6822 let candidate =
6823 StickyProjectPanelCandidate { index, depth };
6824 items.push(candidate);
6825 },
6826 );
6827 items
6828 },
6829 |this, marker_entry, window, cx| {
6830 let sticky_entries =
6831 this.render_sticky_entries(marker_entry, window, cx);
6832 this.sticky_items_count = sticky_entries.len();
6833 sticky_entries
6834 },
6835 );
6836 list.with_decoration(if show_indent_guides {
6837 sticky_items.with_decoration(
6838 ui::indent_guides(
6839 px(indent_size),
6840 IndentGuideColors::panel(cx),
6841 )
6842 .with_render_fn(
6843 cx.entity(),
6844 move |_, params, _, _| {
6845 const LEFT_OFFSET: Pixels = px(14.);
6846
6847 let indent_size = params.indent_size;
6848 let item_height = params.item_height;
6849
6850 params
6851 .indent_guides
6852 .into_iter()
6853 .map(|layout| {
6854 let bounds = Bounds::new(
6855 point(
6856 layout.offset.x * indent_size
6857 + LEFT_OFFSET,
6858 layout.offset.y * item_height,
6859 ),
6860 size(
6861 px(1.),
6862 layout.length * item_height,
6863 ),
6864 );
6865 ui::RenderedIndentGuide {
6866 bounds,
6867 layout,
6868 is_active: false,
6869 hitbox: None,
6870 }
6871 })
6872 .collect()
6873 },
6874 ),
6875 )
6876 } else {
6877 sticky_items
6878 })
6879 })
6880 .with_sizing_behavior(ListSizingBehavior::Infer)
6881 .with_horizontal_sizing_behavior(if horizontal_scroll {
6882 ListHorizontalSizingBehavior::Unconstrained
6883 } else {
6884 ListHorizontalSizingBehavior::FitList
6885 })
6886 .when(horizontal_scroll, |list| {
6887 list.with_width_from_item(self.state.max_width_item_index)
6888 })
6889 .track_scroll(&self.scroll_handle),
6890 )
6891 .child(
6892 div()
6893 .id("project-panel-blank-area")
6894 .block_mouse_except_scroll()
6895 .flex_grow()
6896 .on_scroll_wheel({
6897 let scroll_handle = self.scroll_handle.clone();
6898 let entity_id = cx.entity().entity_id();
6899 move |event, window, cx| {
6900 let state = scroll_handle.0.borrow();
6901 let base_handle = &state.base_handle;
6902 let current_offset = base_handle.offset();
6903 let max_offset = base_handle.max_offset();
6904 let delta = event.delta.pixel_delta(window.line_height());
6905 let new_offset = (current_offset + delta)
6906 .clamp(&max_offset.neg(), &Point::default());
6907
6908 if new_offset != current_offset {
6909 base_handle.set_offset(new_offset);
6910 cx.notify(entity_id);
6911 }
6912 }
6913 })
6914 .when(
6915 self.drag_target_entry.as_ref().is_some_and(
6916 |entry| match entry {
6917 DragTarget::Background => true,
6918 DragTarget::Entry {
6919 highlight_entry_id, ..
6920 } => self.state.last_worktree_root_id.is_some_and(
6921 |root_id| *highlight_entry_id == root_id,
6922 ),
6923 },
6924 ),
6925 |div| div.bg(cx.theme().colors().drop_target_background),
6926 )
6927 .on_drag_move::<ExternalPaths>(cx.listener(
6928 move |this, event: &DragMoveEvent<ExternalPaths>, _, _| {
6929 let Some(_last_root_id) = this.state.last_worktree_root_id
6930 else {
6931 return;
6932 };
6933 if event.bounds.contains(&event.event.position) {
6934 this.drag_target_entry = Some(DragTarget::Background);
6935 } else {
6936 if this.drag_target_entry.as_ref().is_some_and(|e| {
6937 matches!(e, DragTarget::Background)
6938 }) {
6939 this.drag_target_entry = None;
6940 }
6941 }
6942 },
6943 ))
6944 .on_drag_move::<DraggedSelection>(cx.listener(
6945 move |this, event: &DragMoveEvent<DraggedSelection>, _, cx| {
6946 let Some(last_root_id) = this.state.last_worktree_root_id
6947 else {
6948 return;
6949 };
6950 if event.bounds.contains(&event.event.position) {
6951 let drag_state = event.drag(cx);
6952 if this.should_highlight_background_for_selection_drag(
6953 &drag_state,
6954 last_root_id,
6955 cx,
6956 ) {
6957 this.drag_target_entry =
6958 Some(DragTarget::Background);
6959 }
6960 } else {
6961 if this.drag_target_entry.as_ref().is_some_and(|e| {
6962 matches!(e, DragTarget::Background)
6963 }) {
6964 this.drag_target_entry = None;
6965 }
6966 }
6967 },
6968 ))
6969 .on_drop(cx.listener(
6970 move |this, external_paths: &ExternalPaths, window, cx| {
6971 this.drag_target_entry = None;
6972 this.hover_scroll_task.take();
6973 if let Some(entry_id) = this.state.last_worktree_root_id {
6974 this.drop_external_files(
6975 external_paths.paths(),
6976 entry_id,
6977 window,
6978 cx,
6979 );
6980 }
6981 cx.stop_propagation();
6982 },
6983 ))
6984 .on_drop(cx.listener(
6985 move |this, selections: &DraggedSelection, window, cx| {
6986 this.drag_target_entry = None;
6987 this.hover_scroll_task.take();
6988 if let Some(entry_id) = this.state.last_worktree_root_id {
6989 this.drag_onto(selections, entry_id, false, window, cx);
6990 }
6991 cx.stop_propagation();
6992 },
6993 ))
6994 .on_click(cx.listener(|this, event, window, cx| {
6995 if matches!(event, gpui::ClickEvent::Keyboard(_)) {
6996 return;
6997 }
6998 cx.stop_propagation();
6999 this.selection = None;
7000 this.marked_entries.clear();
7001 this.focus_handle(cx).focus(window, cx);
7002 }))
7003 .on_mouse_down(
7004 MouseButton::Right,
7005 cx.listener(move |this, event: &MouseDownEvent, window, cx| {
7006 // When deploying the context menu anywhere below the last project entry,
7007 // act as if the user clicked the root of the last worktree.
7008 if let Some(entry_id) = this.state.last_worktree_root_id {
7009 this.deploy_context_menu(
7010 event.position,
7011 entry_id,
7012 window,
7013 cx,
7014 );
7015 }
7016 }),
7017 )
7018 .when(!project.is_read_only(cx), |el| {
7019 el.on_click(cx.listener(
7020 |this, event: &gpui::ClickEvent, window, cx| {
7021 if event.click_count() > 1
7022 && let Some(entry_id) =
7023 this.state.last_worktree_root_id
7024 {
7025 let project = this.project.read(cx);
7026
7027 let worktree_id = if let Some(worktree) =
7028 project.worktree_for_entry(entry_id, cx)
7029 {
7030 worktree.read(cx).id()
7031 } else {
7032 return;
7033 };
7034
7035 this.selection = Some(SelectedEntry {
7036 worktree_id,
7037 entry_id,
7038 });
7039
7040 this.new_file(&NewFile, window, cx);
7041 }
7042 },
7043 ))
7044 }),
7045 )
7046 .size_full(),
7047 )
7048 .custom_scrollbars(
7049 {
7050 let mut scrollbars =
7051 Scrollbars::for_settings::<ProjectPanelScrollbarProxy>()
7052 .tracked_scroll_handle(&self.scroll_handle);
7053 if horizontal_scroll {
7054 scrollbars = scrollbars.with_track_along(
7055 ScrollAxes::Horizontal,
7056 cx.theme().colors().panel_background,
7057 );
7058 }
7059 scrollbars.notify_content()
7060 },
7061 window,
7062 cx,
7063 )
7064 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
7065 deferred(
7066 anchored()
7067 .position(*position)
7068 .anchor(gpui::Corner::TopLeft)
7069 .child(menu.clone()),
7070 )
7071 .with_priority(3)
7072 }))
7073 } else {
7074 let focus_handle = self.focus_handle(cx);
7075
7076 v_flex()
7077 .id("empty-project_panel")
7078 .p_4()
7079 .size_full()
7080 .items_center()
7081 .justify_center()
7082 .gap_1()
7083 .track_focus(&self.focus_handle(cx))
7084 .child(
7085 Button::new("open_project", "Open Project")
7086 .full_width()
7087 .key_binding(KeyBinding::for_action_in(
7088 &workspace::Open::default(),
7089 &focus_handle,
7090 cx,
7091 ))
7092 .on_click(cx.listener(|this, _, window, cx| {
7093 this.workspace
7094 .update(cx, |_, cx| {
7095 window.dispatch_action(
7096 workspace::Open::default().boxed_clone(),
7097 cx,
7098 );
7099 })
7100 .log_err();
7101 })),
7102 )
7103 .child(
7104 h_flex()
7105 .w_1_2()
7106 .gap_2()
7107 .child(Divider::horizontal())
7108 .child(Label::new("or").size(LabelSize::XSmall).color(Color::Muted))
7109 .child(Divider::horizontal()),
7110 )
7111 .child(
7112 Button::new("clone_repo", "Clone Repository")
7113 .full_width()
7114 .on_click(cx.listener(|this, _, window, cx| {
7115 this.workspace
7116 .update(cx, |_, cx| {
7117 window.dispatch_action(git::Clone.boxed_clone(), cx);
7118 })
7119 .log_err();
7120 })),
7121 )
7122 .when(is_local, |div| {
7123 div.when(panel_settings.drag_and_drop, |div| {
7124 div.drag_over::<ExternalPaths>(|style, _, _, cx| {
7125 style.bg(cx.theme().colors().drop_target_background)
7126 })
7127 .on_drop(cx.listener(
7128 move |this, external_paths: &ExternalPaths, window, cx| {
7129 this.drag_target_entry = None;
7130 this.hover_scroll_task.take();
7131 if let Some(task) = this
7132 .workspace
7133 .update(cx, |workspace, cx| {
7134 workspace.open_workspace_for_paths(
7135 OpenMode::Activate,
7136 external_paths.paths().to_owned(),
7137 window,
7138 cx,
7139 )
7140 })
7141 .log_err()
7142 {
7143 task.detach_and_log_err(cx);
7144 }
7145 cx.stop_propagation();
7146 },
7147 ))
7148 })
7149 })
7150 }
7151 }
7152}
7153
7154impl Render for DraggedProjectEntryView {
7155 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
7156 let ui_font = ThemeSettings::get_global(cx).ui_font.clone();
7157 h_flex()
7158 .font(ui_font)
7159 .pl(self.click_offset.x + px(12.))
7160 .pt(self.click_offset.y + px(12.))
7161 .child(
7162 div()
7163 .flex()
7164 .gap_1()
7165 .items_center()
7166 .py_1()
7167 .px_2()
7168 .rounded_lg()
7169 .bg(cx.theme().colors().background)
7170 .map(|this| {
7171 if self.selections.len() > 1 && self.selections.contains(&self.selection) {
7172 this.child(Label::new(format!("{} entries", self.selections.len())))
7173 } else {
7174 this.child(if let Some(icon) = &self.icon {
7175 div().child(Icon::from_path(icon.clone()))
7176 } else {
7177 div()
7178 })
7179 .child(Label::new(self.filename.clone()))
7180 }
7181 }),
7182 )
7183 }
7184}
7185
7186impl EventEmitter<Event> for ProjectPanel {}
7187
7188impl EventEmitter<PanelEvent> for ProjectPanel {}
7189
7190impl Panel for ProjectPanel {
7191 fn position(&self, _: &Window, cx: &App) -> DockPosition {
7192 match ProjectPanelSettings::get_global(cx).dock {
7193 DockSide::Left => DockPosition::Left,
7194 DockSide::Right => DockPosition::Right,
7195 }
7196 }
7197
7198 fn position_is_valid(&self, position: DockPosition) -> bool {
7199 matches!(position, DockPosition::Left | DockPosition::Right)
7200 }
7201
7202 fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
7203 settings::update_settings_file(self.fs.clone(), cx, move |settings, _| {
7204 let dock = match position {
7205 DockPosition::Left | DockPosition::Bottom => DockSide::Left,
7206 DockPosition::Right => DockSide::Right,
7207 };
7208 settings.project_panel.get_or_insert_default().dock = Some(dock);
7209 });
7210 }
7211
7212 fn default_size(&self, _: &Window, cx: &App) -> Pixels {
7213 ProjectPanelSettings::get_global(cx).default_width
7214 }
7215
7216 fn icon(&self, _: &Window, cx: &App) -> Option<IconName> {
7217 ProjectPanelSettings::get_global(cx)
7218 .button
7219 .then_some(IconName::FileTree)
7220 }
7221
7222 fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
7223 Some("Project Panel")
7224 }
7225
7226 fn toggle_action(&self) -> Box<dyn Action> {
7227 Box::new(ToggleFocus)
7228 }
7229
7230 fn persistent_name() -> &'static str {
7231 "Project Panel"
7232 }
7233
7234 fn panel_key() -> &'static str {
7235 PROJECT_PANEL_KEY
7236 }
7237
7238 fn starts_open(&self, _: &Window, cx: &App) -> bool {
7239 if !ProjectPanelSettings::get_global(cx).starts_open {
7240 return false;
7241 }
7242
7243 let project = &self.project.read(cx);
7244 project.visible_worktrees(cx).any(|tree| {
7245 tree.read(cx)
7246 .root_entry()
7247 .is_some_and(|entry| entry.is_dir())
7248 })
7249 }
7250
7251 fn activation_priority(&self) -> u32 {
7252 1
7253 }
7254}
7255
7256impl Focusable for ProjectPanel {
7257 fn focus_handle(&self, _cx: &App) -> FocusHandle {
7258 self.focus_handle.clone()
7259 }
7260}
7261
7262impl ClipboardEntry {
7263 fn is_cut(&self) -> bool {
7264 matches!(self, Self::Cut { .. })
7265 }
7266
7267 fn items(&self) -> &BTreeSet<SelectedEntry> {
7268 match self {
7269 ClipboardEntry::Copied(entries) | ClipboardEntry::Cut(entries) => entries,
7270 }
7271 }
7272
7273 fn into_copy_entry(self) -> Self {
7274 match self {
7275 ClipboardEntry::Copied(_) => self,
7276 ClipboardEntry::Cut(entries) => ClipboardEntry::Copied(entries),
7277 }
7278 }
7279}
7280
7281#[inline]
7282fn cmp_worktree_entries(
7283 a: &Entry,
7284 b: &Entry,
7285 mode: &settings::ProjectPanelSortMode,
7286 order: &settings::ProjectPanelSortOrder,
7287) -> cmp::Ordering {
7288 let a = (&*a.path, a.is_file());
7289 let b = (&*b.path, b.is_file());
7290 util::paths::compare_rel_paths_by(a, b, (*mode).into(), (*order).into())
7291}
7292
7293pub fn sort_worktree_entries(
7294 entries: &mut [impl AsRef<Entry>],
7295 mode: settings::ProjectPanelSortMode,
7296 order: settings::ProjectPanelSortOrder,
7297) {
7298 entries.sort_by(|lhs, rhs| cmp_worktree_entries(lhs.as_ref(), rhs.as_ref(), &mode, &order));
7299}
7300
7301pub fn par_sort_worktree_entries(
7302 entries: &mut Vec<GitEntry>,
7303 mode: settings::ProjectPanelSortMode,
7304 order: settings::ProjectPanelSortOrder,
7305) {
7306 entries.par_sort_by(|lhs, rhs| cmp_worktree_entries(lhs, rhs, &mode, &order));
7307}
7308
7309fn git_status_indicator(git_status: GitSummary) -> Option<(&'static str, Color)> {
7310 if git_status.conflict > 0 {
7311 return Some(("!", Color::Conflict));
7312 }
7313 if git_status.untracked > 0 {
7314 return Some(("U", Color::Created));
7315 }
7316 if git_status.worktree.deleted > 0 {
7317 return Some(("D", Color::Deleted));
7318 }
7319 if git_status.worktree.modified > 0 {
7320 return Some(("M", Color::Warning));
7321 }
7322 if git_status.index.deleted > 0 {
7323 return Some(("D", Color::Deleted));
7324 }
7325 if git_status.index.modified > 0 {
7326 return Some(("M", Color::Modified));
7327 }
7328 if git_status.index.added > 0 {
7329 return Some(("A", Color::Created));
7330 }
7331 None
7332}
7333
7334#[cfg(test)]
7335mod project_panel_tests;