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