1pub mod project_panel_settings;
2mod undo;
3mod utils;
4
5use anyhow::{Context as _, Result};
6use client::{ErrorCode, ErrorExt};
7use collections::{BTreeSet, HashMap, hash_map};
8use command_palette_hooks::CommandPaletteFilter;
9use editor::{
10 Editor, EditorEvent, MultiBufferOffset,
11 items::{
12 entry_diagnostic_aware_icon_decoration_and_color,
13 entry_diagnostic_aware_icon_name_and_color, entry_git_aware_label_color,
14 },
15};
16use feature_flags::{FeatureFlagAppExt, ProjectPanelUndoRedoFeatureFlag};
17use file_icons::FileIcons;
18use git;
19use git::status::GitSummary;
20use git_ui;
21use git_ui::file_diff_view::FileDiffView;
22use gpui::{
23 Action, AnyElement, App, AsyncWindowContext, Bounds, ClipboardEntry as GpuiClipboardEntry,
24 ClipboardItem, Context, CursorStyle, DismissEvent, Div, DragMoveEvent, Entity, EventEmitter,
25 ExternalPaths, FocusHandle, Focusable, FontWeight, Hsla, InteractiveElement, KeyContext,
26 ListHorizontalSizingBehavior, ListSizingBehavior, Modifiers, ModifiersChangedEvent,
27 MouseButton, MouseDownEvent, ParentElement, PathPromptOptions, Pixels, Point, PromptLevel,
28 Render, ScrollStrategy, Stateful, Styled, Subscription, Task, UniformListScrollHandle,
29 WeakEntity, Window, actions, anchored, deferred, div, hsla, linear_color_stop, linear_gradient,
30 point, px, size, transparent_white, uniform_list,
31};
32use language::DiagnosticSeverity;
33use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
34use notifications::status_toast::{StatusToast, ToastIcon};
35use project::{
36 Entry, EntryKind, Fs, GitEntry, GitEntryRef, GitTraversal, Project, ProjectEntryId,
37 ProjectPath, Worktree, WorktreeId,
38 git_store::{GitStoreEvent, RepositoryEvent, git_traversal::ChildEntriesGitIter},
39 project_settings::GoToDiagnosticSeverityFilter,
40};
41use project_panel_settings::ProjectPanelSettings;
42use rayon::slice::ParallelSliceMut;
43use schemars::JsonSchema;
44use serde::Deserialize;
45use settings::{
46 DockSide, ProjectPanelEntrySpacing, Settings, SettingsStore, ShowDiagnostics, ShowIndentGuides,
47 update_settings_file,
48};
49use smallvec::SmallVec;
50use std::{
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(ToastIcon::new(IconName::XCircle).color(Color::Error))
2279 .dismiss_button(true)
2280 });
2281 panel
2282 .workspace
2283 .update(cx, |workspace, cx| {
2284 workspace.toggle_status_toast(toast, cx);
2285 })
2286 .ok();
2287 })
2288 .ok();
2289 }
2290
2291 panel
2292 .update(cx, |panel, cx| {
2293 panel.project.update(cx, |project, cx| {
2294 if let Some(buffer_id) = project
2295 .buffer_store()
2296 .read(cx)
2297 .buffer_id_for_project_path(&project_path)
2298 {
2299 if let Some(buffer) = project.buffer_for_id(*buffer_id, cx) {
2300 buffer.update(cx, |buffer, cx| {
2301 let _ = buffer.reload(cx);
2302 });
2303 }
2304 }
2305 })
2306 })
2307 .ok();
2308
2309 anyhow::Ok(())
2310 })
2311 .detach_and_log_err(cx);
2312
2313 Some(())
2314 });
2315 }
2316
2317 fn remove(
2318 &mut self,
2319 trash: bool,
2320 skip_prompt: bool,
2321 window: &mut Window,
2322 cx: &mut Context<ProjectPanel>,
2323 ) {
2324 maybe!({
2325 let items_to_delete = self.disjoint_effective_entries(cx);
2326 if items_to_delete.is_empty() {
2327 return None;
2328 }
2329 let project = self.project.read(cx);
2330
2331 let mut dirty_buffers = 0;
2332 let file_paths = items_to_delete
2333 .iter()
2334 .filter_map(|selection| {
2335 let project_path = project.path_for_entry(selection.entry_id, cx)?;
2336 dirty_buffers +=
2337 project.dirty_buffers(cx).any(|path| path == project_path) as usize;
2338
2339 Some((
2340 selection.entry_id,
2341 selection.worktree_id,
2342 project_path.path.file_name()?.to_string(),
2343 ))
2344 })
2345 .collect::<Vec<_>>();
2346 if file_paths.is_empty() {
2347 return None;
2348 }
2349 let answer = if !skip_prompt {
2350 let operation = if trash { "Trash" } else { "Delete" };
2351 let message_start = if trash {
2352 "Do you want to trash"
2353 } else {
2354 "Are you sure you want to permanently delete"
2355 };
2356 let prompt = match file_paths.first() {
2357 Some((_, _, path)) if file_paths.len() == 1 => {
2358 let unsaved_warning = if dirty_buffers > 0 {
2359 "\n\nIt has unsaved changes, which will be lost."
2360 } else {
2361 ""
2362 };
2363
2364 format!("{message_start} {path}?{unsaved_warning}")
2365 }
2366 _ => {
2367 const CUTOFF_POINT: usize = 10;
2368 let names = if file_paths.len() > CUTOFF_POINT {
2369 let truncated_path_counts = file_paths.len() - CUTOFF_POINT;
2370 let mut paths = file_paths
2371 .iter()
2372 .map(|(_, _, path)| path.clone())
2373 .take(CUTOFF_POINT)
2374 .collect::<Vec<_>>();
2375 paths.truncate(CUTOFF_POINT);
2376 if truncated_path_counts == 1 {
2377 paths.push(".. 1 file not shown".into());
2378 } else {
2379 paths.push(format!(".. {} files not shown", truncated_path_counts));
2380 }
2381 paths
2382 } else {
2383 file_paths.iter().map(|(_, _, path)| path.clone()).collect()
2384 };
2385 let unsaved_warning = if dirty_buffers == 0 {
2386 String::new()
2387 } else if dirty_buffers == 1 {
2388 "\n\n1 of these has unsaved changes, which will be lost.".to_string()
2389 } else {
2390 format!(
2391 "\n\n{dirty_buffers} of these have unsaved changes, which will be lost."
2392 )
2393 };
2394
2395 format!(
2396 "{message_start} the following {} files?\n{}{unsaved_warning}",
2397 file_paths.len(),
2398 names.join("\n")
2399 )
2400 }
2401 };
2402 let detail = (!trash).then_some("This cannot be undone.");
2403 Some(window.prompt(
2404 PromptLevel::Info,
2405 &prompt,
2406 detail,
2407 &[operation, "Cancel"],
2408 cx,
2409 ))
2410 } else {
2411 None
2412 };
2413 let next_selection = self.find_next_selection_after_deletion(items_to_delete, cx);
2414 cx.spawn_in(window, async move |panel, cx| {
2415 if let Some(answer) = answer
2416 && answer.await != Ok(0)
2417 {
2418 return anyhow::Ok(());
2419 }
2420
2421 let mut changes = Vec::new();
2422
2423 for (entry_id, worktree_id, _) in file_paths {
2424 let trashed_entry = panel
2425 .update(cx, |panel, cx| {
2426 panel
2427 .project
2428 .update(cx, |project, cx| project.delete_entry(entry_id, trash, cx))
2429 .context("no such entry")
2430 })??
2431 .await?;
2432
2433 // Keep track of trashed change so that we can then record
2434 // all of the changes at once, such that undoing and redoing
2435 // restores or trashes all files in batch.
2436 if trash && let Some(trashed_entry) = trashed_entry {
2437 changes.push(Change::Trashed(worktree_id, trashed_entry));
2438 }
2439 }
2440 panel.update_in(cx, |panel, window, cx| {
2441 if trash {
2442 panel.undo_manager.record(changes).log_err();
2443 }
2444
2445 if let Some(next_selection) = next_selection {
2446 panel.update_visible_entries(
2447 Some((next_selection.worktree_id, next_selection.entry_id)),
2448 false,
2449 true,
2450 window,
2451 cx,
2452 );
2453 } else {
2454 panel.select_last(&SelectLast {}, window, cx);
2455 }
2456 })?;
2457 Ok(())
2458 })
2459 .detach_and_log_err(cx);
2460 Some(())
2461 });
2462 }
2463
2464 fn find_next_selection_after_deletion(
2465 &self,
2466 sanitized_entries: BTreeSet<SelectedEntry>,
2467 cx: &mut Context<Self>,
2468 ) -> Option<SelectedEntry> {
2469 if sanitized_entries.is_empty() {
2470 return None;
2471 }
2472 let project = self.project.read(cx);
2473 let (worktree_id, worktree) = sanitized_entries
2474 .iter()
2475 .map(|entry| entry.worktree_id)
2476 .filter_map(|id| project.worktree_for_id(id, cx).map(|w| (id, w.read(cx))))
2477 .max_by(|(_, a), (_, b)| a.root_name().cmp(b.root_name()))?;
2478 let git_store = project.git_store().read(cx);
2479
2480 let marked_entries_in_worktree = sanitized_entries
2481 .iter()
2482 .filter(|e| e.worktree_id == worktree_id)
2483 .collect::<HashSet<_>>();
2484 let latest_entry = marked_entries_in_worktree
2485 .iter()
2486 .max_by(|a, b| {
2487 match (
2488 worktree.entry_for_id(a.entry_id),
2489 worktree.entry_for_id(b.entry_id),
2490 ) {
2491 (Some(a), Some(b)) => compare_paths(
2492 (a.path.as_std_path(), a.is_file()),
2493 (b.path.as_std_path(), b.is_file()),
2494 ),
2495 _ => cmp::Ordering::Equal,
2496 }
2497 })
2498 .and_then(|e| worktree.entry_for_id(e.entry_id))?;
2499
2500 let parent_path = latest_entry.path.parent()?;
2501 let parent_entry = worktree.entry_for_path(parent_path)?;
2502
2503 // Remove all siblings that are being deleted except the last marked entry
2504 let repo_snapshots = git_store.repo_snapshots(cx);
2505 let worktree_snapshot = worktree.snapshot();
2506 let hide_gitignore = ProjectPanelSettings::get_global(cx).hide_gitignore;
2507 let mut siblings: Vec<_> =
2508 ChildEntriesGitIter::new(&repo_snapshots, &worktree_snapshot, parent_path)
2509 .filter(|sibling| {
2510 (sibling.id == latest_entry.id)
2511 || (!marked_entries_in_worktree.contains(&&SelectedEntry {
2512 worktree_id,
2513 entry_id: sibling.id,
2514 }) && (!hide_gitignore || !sibling.is_ignored))
2515 })
2516 .map(|entry| entry.to_owned())
2517 .collect();
2518
2519 let sort_mode = ProjectPanelSettings::get_global(cx).sort_mode;
2520 let sort_order = ProjectPanelSettings::get_global(cx).sort_order;
2521 sort_worktree_entries(&mut siblings, sort_mode, sort_order);
2522 let sibling_entry_index = siblings
2523 .iter()
2524 .position(|sibling| sibling.id == latest_entry.id)?;
2525
2526 if let Some(next_sibling) = sibling_entry_index
2527 .checked_add(1)
2528 .and_then(|i| siblings.get(i))
2529 {
2530 return Some(SelectedEntry {
2531 worktree_id,
2532 entry_id: next_sibling.id,
2533 });
2534 }
2535 if let Some(prev_sibling) = sibling_entry_index
2536 .checked_sub(1)
2537 .and_then(|i| siblings.get(i))
2538 {
2539 return Some(SelectedEntry {
2540 worktree_id,
2541 entry_id: prev_sibling.id,
2542 });
2543 }
2544 // No neighbour sibling found, fall back to parent
2545 Some(SelectedEntry {
2546 worktree_id,
2547 entry_id: parent_entry.id,
2548 })
2549 }
2550
2551 fn unfold_directory(
2552 &mut self,
2553 _: &UnfoldDirectory,
2554 window: &mut Window,
2555 cx: &mut Context<Self>,
2556 ) {
2557 if let Some((worktree, entry)) = self.selected_entry(cx) {
2558 self.state.unfolded_dir_ids.insert(entry.id);
2559
2560 let snapshot = worktree.snapshot();
2561 let mut parent_path = entry.path.parent();
2562 while let Some(path) = parent_path {
2563 if let Some(parent_entry) = worktree.entry_for_path(path) {
2564 let mut children_iter = snapshot.child_entries(path);
2565
2566 if children_iter.by_ref().take(2).count() > 1 {
2567 break;
2568 }
2569
2570 self.state.unfolded_dir_ids.insert(parent_entry.id);
2571 parent_path = path.parent();
2572 } else {
2573 break;
2574 }
2575 }
2576
2577 self.update_visible_entries(None, false, true, window, cx);
2578 cx.notify();
2579 }
2580 }
2581
2582 fn fold_directory(&mut self, _: &FoldDirectory, window: &mut Window, cx: &mut Context<Self>) {
2583 if let Some((worktree, entry)) = self.selected_entry(cx) {
2584 self.state.unfolded_dir_ids.remove(&entry.id);
2585
2586 let snapshot = worktree.snapshot();
2587 let mut path = &*entry.path;
2588 loop {
2589 let mut child_entries_iter = snapshot.child_entries(path);
2590 if let Some(child) = child_entries_iter.next() {
2591 if child_entries_iter.next().is_none() && child.is_dir() {
2592 self.state.unfolded_dir_ids.remove(&child.id);
2593 path = &*child.path;
2594 } else {
2595 break;
2596 }
2597 } else {
2598 break;
2599 }
2600 }
2601
2602 self.update_visible_entries(None, false, true, window, cx);
2603 cx.notify();
2604 }
2605 }
2606
2607 fn scroll_up(&mut self, _: &ScrollUp, window: &mut Window, cx: &mut Context<Self>) {
2608 for _ in 0..self.rendered_entries_len / 2 {
2609 window.dispatch_action(SelectPrevious.boxed_clone(), cx);
2610 }
2611 }
2612
2613 fn scroll_down(&mut self, _: &ScrollDown, window: &mut Window, cx: &mut Context<Self>) {
2614 for _ in 0..self.rendered_entries_len / 2 {
2615 window.dispatch_action(SelectNext.boxed_clone(), cx);
2616 }
2617 }
2618
2619 fn scroll_cursor_center(
2620 &mut self,
2621 _: &ScrollCursorCenter,
2622 _: &mut Window,
2623 cx: &mut Context<Self>,
2624 ) {
2625 if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) {
2626 self.scroll_handle
2627 .scroll_to_item_strict(index, ScrollStrategy::Center);
2628 cx.notify();
2629 }
2630 }
2631
2632 fn scroll_cursor_top(&mut self, _: &ScrollCursorTop, _: &mut Window, cx: &mut Context<Self>) {
2633 if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) {
2634 self.scroll_handle
2635 .scroll_to_item_strict(index, ScrollStrategy::Top);
2636 cx.notify();
2637 }
2638 }
2639
2640 fn scroll_cursor_bottom(
2641 &mut self,
2642 _: &ScrollCursorBottom,
2643 _: &mut Window,
2644 cx: &mut Context<Self>,
2645 ) {
2646 if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) {
2647 self.scroll_handle
2648 .scroll_to_item_strict(index, ScrollStrategy::Bottom);
2649 cx.notify();
2650 }
2651 }
2652
2653 fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
2654 if let Some(edit_state) = &self.state.edit_state
2655 && edit_state.processing_filename.is_none()
2656 {
2657 self.filename_editor.update(cx, |editor, cx| {
2658 editor.move_to_end_of_line(
2659 &editor::actions::MoveToEndOfLine {
2660 stop_at_soft_wraps: false,
2661 },
2662 window,
2663 cx,
2664 );
2665 });
2666 return;
2667 }
2668 if let Some(selection) = self.selection {
2669 let (mut worktree_ix, mut entry_ix, _) =
2670 self.index_for_selection(selection).unwrap_or_default();
2671 if let Some(worktree_entries) = self
2672 .state
2673 .visible_entries
2674 .get(worktree_ix)
2675 .map(|v| &v.entries)
2676 {
2677 if entry_ix + 1 < worktree_entries.len() {
2678 entry_ix += 1;
2679 } else {
2680 worktree_ix += 1;
2681 entry_ix = 0;
2682 }
2683 }
2684
2685 if let Some(VisibleEntriesForWorktree {
2686 worktree_id,
2687 entries,
2688 ..
2689 }) = self.state.visible_entries.get(worktree_ix)
2690 && let Some(entry) = entries.get(entry_ix)
2691 {
2692 let selection = SelectedEntry {
2693 worktree_id: *worktree_id,
2694 entry_id: entry.id,
2695 };
2696 self.selection = Some(selection);
2697 if window.modifiers().shift {
2698 self.marked_entries.push(selection);
2699 }
2700
2701 self.autoscroll(cx);
2702 cx.notify();
2703 }
2704 } else {
2705 self.select_first(&SelectFirst {}, window, cx);
2706 }
2707 }
2708
2709 fn select_prev_diagnostic(
2710 &mut self,
2711 action: &SelectPrevDiagnostic,
2712 window: &mut Window,
2713 cx: &mut Context<Self>,
2714 ) {
2715 let selection = self.find_entry(
2716 self.selection.as_ref(),
2717 true,
2718 &|entry: GitEntryRef, worktree_id: WorktreeId| {
2719 self.selection.is_none_or(|selection| {
2720 if selection.worktree_id == worktree_id {
2721 selection.entry_id != entry.id
2722 } else {
2723 true
2724 }
2725 }) && entry.is_file()
2726 && self
2727 .diagnostics
2728 .get(&(worktree_id, entry.path.clone()))
2729 .is_some_and(|severity| action.severity.matches(*severity))
2730 },
2731 cx,
2732 );
2733
2734 if let Some(selection) = selection {
2735 self.selection = Some(selection);
2736 self.expand_entry(selection.worktree_id, selection.entry_id, cx);
2737 self.update_visible_entries(
2738 Some((selection.worktree_id, selection.entry_id)),
2739 false,
2740 true,
2741 window,
2742 cx,
2743 );
2744 cx.notify();
2745 }
2746 }
2747
2748 fn select_next_diagnostic(
2749 &mut self,
2750 action: &SelectNextDiagnostic,
2751 window: &mut Window,
2752 cx: &mut Context<Self>,
2753 ) {
2754 let selection = self.find_entry(
2755 self.selection.as_ref(),
2756 false,
2757 &|entry: GitEntryRef, worktree_id: WorktreeId| {
2758 self.selection.is_none_or(|selection| {
2759 if selection.worktree_id == worktree_id {
2760 selection.entry_id != entry.id
2761 } else {
2762 true
2763 }
2764 }) && entry.is_file()
2765 && self
2766 .diagnostics
2767 .get(&(worktree_id, entry.path.clone()))
2768 .is_some_and(|severity| action.severity.matches(*severity))
2769 },
2770 cx,
2771 );
2772
2773 if let Some(selection) = selection {
2774 self.selection = Some(selection);
2775 self.expand_entry(selection.worktree_id, selection.entry_id, cx);
2776 self.update_visible_entries(
2777 Some((selection.worktree_id, selection.entry_id)),
2778 false,
2779 true,
2780 window,
2781 cx,
2782 );
2783 cx.notify();
2784 }
2785 }
2786
2787 fn select_prev_git_entry(
2788 &mut self,
2789 _: &SelectPrevGitEntry,
2790 window: &mut Window,
2791 cx: &mut Context<Self>,
2792 ) {
2793 let selection = self.find_entry(
2794 self.selection.as_ref(),
2795 true,
2796 &|entry: GitEntryRef, worktree_id: WorktreeId| {
2797 (self.selection.is_none()
2798 || self.selection.is_some_and(|selection| {
2799 if selection.worktree_id == worktree_id {
2800 selection.entry_id != entry.id
2801 } else {
2802 true
2803 }
2804 }))
2805 && entry.is_file()
2806 && entry.git_summary.index.modified + entry.git_summary.worktree.modified > 0
2807 },
2808 cx,
2809 );
2810
2811 if let Some(selection) = selection {
2812 self.selection = Some(selection);
2813 self.expand_entry(selection.worktree_id, selection.entry_id, cx);
2814 self.update_visible_entries(
2815 Some((selection.worktree_id, selection.entry_id)),
2816 false,
2817 true,
2818 window,
2819 cx,
2820 );
2821 cx.notify();
2822 }
2823 }
2824
2825 fn select_prev_directory(
2826 &mut self,
2827 _: &SelectPrevDirectory,
2828 _: &mut Window,
2829 cx: &mut Context<Self>,
2830 ) {
2831 let selection = self.find_visible_entry(
2832 self.selection.as_ref(),
2833 true,
2834 &|entry: GitEntryRef, worktree_id: WorktreeId| {
2835 self.selection.is_none_or(|selection| {
2836 if selection.worktree_id == worktree_id {
2837 selection.entry_id != entry.id
2838 } else {
2839 true
2840 }
2841 }) && entry.is_dir()
2842 },
2843 cx,
2844 );
2845
2846 if let Some(selection) = selection {
2847 self.selection = Some(selection);
2848 self.autoscroll(cx);
2849 cx.notify();
2850 }
2851 }
2852
2853 fn select_next_directory(
2854 &mut self,
2855 _: &SelectNextDirectory,
2856 _: &mut Window,
2857 cx: &mut Context<Self>,
2858 ) {
2859 let selection = self.find_visible_entry(
2860 self.selection.as_ref(),
2861 false,
2862 &|entry: GitEntryRef, worktree_id: WorktreeId| {
2863 self.selection.is_none_or(|selection| {
2864 if selection.worktree_id == worktree_id {
2865 selection.entry_id != entry.id
2866 } else {
2867 true
2868 }
2869 }) && entry.is_dir()
2870 },
2871 cx,
2872 );
2873
2874 if let Some(selection) = selection {
2875 self.selection = Some(selection);
2876 self.autoscroll(cx);
2877 cx.notify();
2878 }
2879 }
2880
2881 fn select_next_git_entry(
2882 &mut self,
2883 _: &SelectNextGitEntry,
2884 window: &mut Window,
2885 cx: &mut Context<Self>,
2886 ) {
2887 let selection = self.find_entry(
2888 self.selection.as_ref(),
2889 false,
2890 &|entry: GitEntryRef, worktree_id: WorktreeId| {
2891 self.selection.is_none_or(|selection| {
2892 if selection.worktree_id == worktree_id {
2893 selection.entry_id != entry.id
2894 } else {
2895 true
2896 }
2897 }) && entry.is_file()
2898 && entry.git_summary.index.modified + entry.git_summary.worktree.modified > 0
2899 },
2900 cx,
2901 );
2902
2903 if let Some(selection) = selection {
2904 self.selection = Some(selection);
2905 self.expand_entry(selection.worktree_id, selection.entry_id, cx);
2906 self.update_visible_entries(
2907 Some((selection.worktree_id, selection.entry_id)),
2908 false,
2909 true,
2910 window,
2911 cx,
2912 );
2913 cx.notify();
2914 }
2915 }
2916
2917 fn select_parent(&mut self, _: &SelectParent, window: &mut Window, cx: &mut Context<Self>) {
2918 if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
2919 if let Some(parent) = entry.path.parent() {
2920 let worktree = worktree.read(cx);
2921 if let Some(parent_entry) = worktree.entry_for_path(parent) {
2922 self.selection = Some(SelectedEntry {
2923 worktree_id: worktree.id(),
2924 entry_id: parent_entry.id,
2925 });
2926 self.autoscroll(cx);
2927 cx.notify();
2928 }
2929 }
2930 } else {
2931 self.select_first(&SelectFirst {}, window, cx);
2932 }
2933 }
2934
2935 fn select_first(&mut self, _: &SelectFirst, window: &mut Window, cx: &mut Context<Self>) {
2936 if let Some(VisibleEntriesForWorktree {
2937 worktree_id,
2938 entries,
2939 ..
2940 }) = self.state.visible_entries.first()
2941 && let Some(entry) = entries.first()
2942 {
2943 let selection = SelectedEntry {
2944 worktree_id: *worktree_id,
2945 entry_id: entry.id,
2946 };
2947 self.selection = Some(selection);
2948 if window.modifiers().shift {
2949 self.marked_entries.push(selection);
2950 }
2951 self.autoscroll(cx);
2952 cx.notify();
2953 }
2954 }
2955
2956 fn select_last(&mut self, _: &SelectLast, _: &mut Window, cx: &mut Context<Self>) {
2957 if let Some(VisibleEntriesForWorktree {
2958 worktree_id,
2959 entries,
2960 ..
2961 }) = self.state.visible_entries.last()
2962 {
2963 let worktree = self.project.read(cx).worktree_for_id(*worktree_id, cx);
2964 if let (Some(worktree), Some(entry)) = (worktree, entries.last()) {
2965 let worktree = worktree.read(cx);
2966 if let Some(entry) = worktree.entry_for_id(entry.id) {
2967 let selection = SelectedEntry {
2968 worktree_id: *worktree_id,
2969 entry_id: entry.id,
2970 };
2971 self.selection = Some(selection);
2972 self.autoscroll(cx);
2973 cx.notify();
2974 }
2975 }
2976 }
2977 }
2978
2979 fn autoscroll(&mut self, cx: &mut Context<Self>) {
2980 if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) {
2981 self.scroll_handle.scroll_to_item_with_offset(
2982 index,
2983 ScrollStrategy::Center,
2984 self.sticky_items_count,
2985 );
2986 cx.notify();
2987 }
2988 }
2989
2990 fn cut(&mut self, _: &Cut, _: &mut Window, cx: &mut Context<Self>) {
2991 let entries = self.disjoint_effective_entries(cx);
2992 if !entries.is_empty() {
2993 self.write_entries_to_system_clipboard(&entries, cx);
2994 self.clipboard = Some(ClipboardEntry::Cut(entries));
2995 cx.notify();
2996 }
2997 }
2998
2999 fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context<Self>) {
3000 let entries = self.disjoint_effective_entries(cx);
3001 if !entries.is_empty() {
3002 self.write_entries_to_system_clipboard(&entries, cx);
3003 self.clipboard = Some(ClipboardEntry::Copied(entries));
3004 cx.notify();
3005 }
3006 }
3007
3008 fn create_paste_path(
3009 &self,
3010 source: &SelectedEntry,
3011 (worktree, target_entry): (Entity<Worktree>, &Entry),
3012 cx: &App,
3013 ) -> Option<(Arc<RelPath>, Option<Range<usize>>)> {
3014 let mut new_path = target_entry.path.to_rel_path_buf();
3015 // If we're pasting into a file, or a directory into itself, go up one level.
3016 if target_entry.is_file() || (target_entry.is_dir() && target_entry.id == source.entry_id) {
3017 new_path.pop();
3018 }
3019
3020 let source_worktree = self
3021 .project
3022 .read(cx)
3023 .worktree_for_entry(source.entry_id, cx)?;
3024 let source_entry = source_worktree.read(cx).entry_for_id(source.entry_id)?;
3025
3026 let clipboard_entry_file_name = source_entry.path.file_name()?.to_string();
3027 new_path.push(RelPath::unix(&clipboard_entry_file_name).unwrap());
3028
3029 let (extension, file_name_without_extension) = if source_entry.is_file() {
3030 (
3031 new_path.extension().map(|s| s.to_string()),
3032 new_path.file_stem()?.to_string(),
3033 )
3034 } else {
3035 (None, clipboard_entry_file_name.clone())
3036 };
3037
3038 let file_name_len = file_name_without_extension.len();
3039 let mut disambiguation_range = None;
3040 let mut ix = 0;
3041 {
3042 let worktree = worktree.read(cx);
3043 while worktree.entry_for_path(&new_path).is_some() {
3044 new_path.pop();
3045
3046 let mut new_file_name = file_name_without_extension.to_string();
3047
3048 let disambiguation = " copy";
3049 let mut disambiguation_len = disambiguation.len();
3050
3051 new_file_name.push_str(disambiguation);
3052
3053 if ix > 0 {
3054 let extra_disambiguation = format!(" {}", ix);
3055 disambiguation_len += extra_disambiguation.len();
3056 new_file_name.push_str(&extra_disambiguation);
3057 }
3058 if let Some(extension) = extension.as_ref() {
3059 new_file_name.push_str(".");
3060 new_file_name.push_str(extension);
3061 }
3062
3063 new_path.push(RelPath::unix(&new_file_name).unwrap());
3064
3065 disambiguation_range = Some(0..(file_name_len + disambiguation_len));
3066 ix += 1;
3067 }
3068 }
3069 Some((new_path.as_rel_path().into(), disambiguation_range))
3070 }
3071
3072 fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
3073 if let Some(external_paths) = self.external_paths_from_system_clipboard(cx) {
3074 let target_entry_id = self
3075 .selection
3076 .map(|s| s.entry_id)
3077 .or(self.state.last_worktree_root_id);
3078 if let Some(entry_id) = target_entry_id {
3079 self.drop_external_files(external_paths.paths(), entry_id, window, cx);
3080 }
3081 return;
3082 }
3083
3084 maybe!({
3085 let (worktree, entry) = self.selected_entry_handle(cx)?;
3086 let entry = entry.clone();
3087 let worktree_id = worktree.read(cx).id();
3088 let clipboard_entries = self
3089 .clipboard
3090 .as_ref()
3091 .filter(|clipboard| !clipboard.items().is_empty())?;
3092
3093 enum PasteTask {
3094 Rename {
3095 task: Task<Result<CreatedEntry>>,
3096 from: ProjectPath,
3097 to: ProjectPath,
3098 },
3099 Copy {
3100 task: Task<Result<Option<Entry>>>,
3101 destination: ProjectPath,
3102 },
3103 }
3104
3105 let mut paste_tasks = Vec::new();
3106 let mut disambiguation_range = None;
3107 let clip_is_cut = clipboard_entries.is_cut();
3108 for clipboard_entry in clipboard_entries.items() {
3109 let (new_path, new_disambiguation_range) =
3110 self.create_paste_path(clipboard_entry, self.selected_sub_entry(cx)?, cx)?;
3111 let clip_entry_id = clipboard_entry.entry_id;
3112 let destination: ProjectPath = (worktree_id, new_path).into();
3113 let task = if clipboard_entries.is_cut() {
3114 let original_path = self.project.read(cx).path_for_entry(clip_entry_id, cx)?;
3115 let task = self.project.update(cx, |project, cx| {
3116 project.rename_entry(clip_entry_id, destination.clone(), cx)
3117 });
3118 PasteTask::Rename {
3119 task,
3120 from: original_path,
3121 to: destination,
3122 }
3123 } else {
3124 let task = self.project.update(cx, |project, cx| {
3125 project.copy_entry(clip_entry_id, destination.clone(), cx)
3126 });
3127 PasteTask::Copy { task, destination }
3128 };
3129 paste_tasks.push(task);
3130 disambiguation_range = new_disambiguation_range.or(disambiguation_range);
3131 }
3132
3133 let item_count = paste_tasks.len();
3134 let workspace = self.workspace.clone();
3135
3136 cx.spawn_in(window, async move |project_panel, mut cx| {
3137 let mut last_succeed = None;
3138 let mut changes = Vec::new();
3139
3140 for task in paste_tasks {
3141 match task {
3142 PasteTask::Rename { task, from, to } => {
3143 if let Some(CreatedEntry::Included(entry)) = task
3144 .await
3145 .notify_workspace_async_err(workspace.clone(), &mut cx)
3146 {
3147 changes.push(Change::Renamed(from, to));
3148 last_succeed = Some(entry);
3149 }
3150 }
3151 PasteTask::Copy { task, destination } => {
3152 if let Some(Some(entry)) = task
3153 .await
3154 .notify_workspace_async_err(workspace.clone(), &mut cx)
3155 {
3156 changes.push(Change::Created(destination));
3157 last_succeed = Some(entry);
3158 }
3159 }
3160 }
3161 }
3162
3163 project_panel
3164 .update(cx, |this, _| {
3165 this.undo_manager.record(changes).log_err();
3166 })
3167 .ok();
3168
3169 // update selection
3170 if let Some(entry) = last_succeed {
3171 project_panel
3172 .update_in(cx, |project_panel, window, cx| {
3173 project_panel.selection = Some(SelectedEntry {
3174 worktree_id,
3175 entry_id: entry.id,
3176 });
3177
3178 if item_count == 1 {
3179 // open entry if not dir, setting is enabled, and only focus if rename is not pending
3180 if !entry.is_dir() {
3181 let settings = ProjectPanelSettings::get_global(cx);
3182 if settings.auto_open.should_open_on_paste() {
3183 project_panel.open_entry(
3184 entry.id,
3185 disambiguation_range.is_none(),
3186 false,
3187 cx,
3188 );
3189 }
3190 }
3191
3192 // if only one entry was pasted and it was disambiguated, open the rename editor
3193 if disambiguation_range.is_some() {
3194 cx.defer_in(window, |this, window, cx| {
3195 this.rename_impl(disambiguation_range, window, cx);
3196 });
3197 }
3198 }
3199 })
3200 .ok();
3201 }
3202
3203 anyhow::Ok(())
3204 })
3205 .detach_and_log_err(cx);
3206
3207 if clip_is_cut {
3208 // Convert the clipboard cut entry to a copy entry after the first paste.
3209 self.clipboard = self.clipboard.take().map(ClipboardEntry::into_copy_entry);
3210 }
3211
3212 self.expand_entry(worktree_id, entry.id, cx);
3213 Some(())
3214 });
3215 }
3216
3217 fn download_from_remote(
3218 &mut self,
3219 _: &DownloadFromRemote,
3220 window: &mut Window,
3221 cx: &mut Context<Self>,
3222 ) {
3223 let entries = self.effective_entries();
3224 if entries.is_empty() {
3225 return;
3226 }
3227
3228 let project = self.project.read(cx);
3229
3230 // Collect file entries with their worktree_id, path, and relative path for destination
3231 // For directories, we collect all files under them recursively
3232 let mut files_to_download: Vec<(WorktreeId, Arc<RelPath>, PathBuf)> = Vec::new();
3233
3234 for selected in entries.iter() {
3235 let Some(worktree) = project.worktree_for_id(selected.worktree_id, cx) else {
3236 continue;
3237 };
3238 let worktree = worktree.read(cx);
3239 let Some(entry) = worktree.entry_for_id(selected.entry_id) else {
3240 continue;
3241 };
3242
3243 if entry.is_file() {
3244 // Single file: use just the filename
3245 let filename = entry
3246 .path
3247 .file_name()
3248 .map(str::to_string)
3249 .unwrap_or_default();
3250 files_to_download.push((
3251 selected.worktree_id,
3252 entry.path.clone(),
3253 PathBuf::from(filename),
3254 ));
3255 } else if entry.is_dir() {
3256 // Directory: collect all files recursively, preserving relative paths
3257 let dir_name = entry
3258 .path
3259 .file_name()
3260 .map(str::to_string)
3261 .unwrap_or_default();
3262 let base_path = entry.path.clone();
3263
3264 // Use traverse_from_path to iterate all entries under this directory
3265 let mut traversal = worktree.traverse_from_path(true, true, true, &entry.path);
3266 while let Some(child_entry) = traversal.entry() {
3267 // Stop when we're no longer under the directory
3268 if !child_entry.path.starts_with(&base_path) {
3269 break;
3270 }
3271
3272 if child_entry.is_file() {
3273 // Calculate relative path from the directory root
3274 let relative_path = child_entry
3275 .path
3276 .strip_prefix(&base_path)
3277 .map(|p| PathBuf::from(dir_name.clone()).join(p.as_unix_str()))
3278 .unwrap_or_else(|_| {
3279 PathBuf::from(
3280 child_entry
3281 .path
3282 .file_name()
3283 .map(str::to_string)
3284 .unwrap_or_default(),
3285 )
3286 });
3287 files_to_download.push((
3288 selected.worktree_id,
3289 child_entry.path.clone(),
3290 relative_path,
3291 ));
3292 }
3293 traversal.advance();
3294 }
3295 }
3296 }
3297
3298 if files_to_download.is_empty() {
3299 return;
3300 }
3301
3302 let total_files = files_to_download.len();
3303 let workspace = self.workspace.clone();
3304
3305 let destination_dir = cx.prompt_for_paths(PathPromptOptions {
3306 files: false,
3307 directories: true,
3308 multiple: false,
3309 prompt: Some("Download".into()),
3310 });
3311
3312 let fs = self.fs.clone();
3313 let notification_id =
3314 workspace::notifications::NotificationId::Named("download-progress".into());
3315 cx.spawn_in(window, async move |this, cx| {
3316 if let Ok(Ok(Some(mut paths))) = destination_dir.await {
3317 if let Some(dest_dir) = paths.pop() {
3318 // Show initial toast
3319 workspace
3320 .update(cx, |workspace, cx| {
3321 workspace.show_toast(
3322 workspace::Toast::new(
3323 notification_id.clone(),
3324 format!("Downloading 0/{} files...", total_files),
3325 ),
3326 cx,
3327 );
3328 })
3329 .ok();
3330
3331 for (index, (worktree_id, entry_path, relative_path)) in
3332 files_to_download.into_iter().enumerate()
3333 {
3334 // Update progress toast
3335 workspace
3336 .update(cx, |workspace, cx| {
3337 workspace.show_toast(
3338 workspace::Toast::new(
3339 notification_id.clone(),
3340 format!(
3341 "Downloading {}/{} files...",
3342 index + 1,
3343 total_files
3344 ),
3345 ),
3346 cx,
3347 );
3348 })
3349 .ok();
3350
3351 let destination_path = dest_dir.join(&relative_path);
3352
3353 // Create parent directories if needed
3354 if let Some(parent) = destination_path.parent() {
3355 if !parent.exists() {
3356 fs.create_dir(parent).await.log_err();
3357 }
3358 }
3359
3360 let download_task = this.update(cx, |this, cx| {
3361 let project = this.project.clone();
3362 project.update(cx, |project, cx| {
3363 project.download_file(worktree_id, entry_path, destination_path, cx)
3364 })
3365 });
3366 if let Ok(task) = download_task {
3367 task.await.log_err();
3368 }
3369 }
3370
3371 // Show completion toast
3372 workspace
3373 .update(cx, |workspace, cx| {
3374 workspace.show_toast(
3375 workspace::Toast::new(
3376 notification_id.clone(),
3377 format!("Downloaded {} files", total_files),
3378 ),
3379 cx,
3380 );
3381 })
3382 .ok();
3383 }
3384 }
3385 })
3386 .detach();
3387 }
3388
3389 fn duplicate(&mut self, _: &Duplicate, window: &mut Window, cx: &mut Context<Self>) {
3390 self.copy(&Copy {}, window, cx);
3391 self.paste(&Paste {}, window, cx);
3392 }
3393
3394 fn copy_path(
3395 &mut self,
3396 _: &zed_actions::workspace::CopyPath,
3397 _: &mut Window,
3398 cx: &mut Context<Self>,
3399 ) {
3400 let abs_file_paths = {
3401 let project = self.project.read(cx);
3402 self.effective_entries()
3403 .into_iter()
3404 .filter_map(|entry| {
3405 let entry_path = project.path_for_entry(entry.entry_id, cx)?.path;
3406 Some(
3407 project
3408 .worktree_for_id(entry.worktree_id, cx)?
3409 .read(cx)
3410 .absolutize(&entry_path)
3411 .to_string_lossy()
3412 .to_string(),
3413 )
3414 })
3415 .collect::<Vec<_>>()
3416 };
3417 if !abs_file_paths.is_empty() {
3418 cx.write_to_clipboard(ClipboardItem::new_string(abs_file_paths.join("\n")));
3419 }
3420 }
3421
3422 fn copy_relative_path(
3423 &mut self,
3424 _: &zed_actions::workspace::CopyRelativePath,
3425 _: &mut Window,
3426 cx: &mut Context<Self>,
3427 ) {
3428 let path_style = self.project.read(cx).path_style(cx);
3429 let file_paths = {
3430 let project = self.project.read(cx);
3431 self.effective_entries()
3432 .into_iter()
3433 .filter_map(|entry| {
3434 Some(
3435 project
3436 .path_for_entry(entry.entry_id, cx)?
3437 .path
3438 .display(path_style)
3439 .into_owned(),
3440 )
3441 })
3442 .collect::<Vec<_>>()
3443 };
3444 if !file_paths.is_empty() {
3445 cx.write_to_clipboard(ClipboardItem::new_string(file_paths.join("\n")));
3446 }
3447 }
3448
3449 fn reveal_in_finder(
3450 &mut self,
3451 _: &RevealInFileManager,
3452 _: &mut Window,
3453 cx: &mut Context<Self>,
3454 ) {
3455 if let Some(path) = self.reveal_in_file_manager_path(cx) {
3456 self.project
3457 .update(cx, |project, cx| project.reveal_path(&path, cx));
3458 }
3459 }
3460
3461 fn remove_from_project(
3462 &mut self,
3463 _: &RemoveFromProject,
3464 _window: &mut Window,
3465 cx: &mut Context<Self>,
3466 ) {
3467 for entry in self.effective_entries().iter() {
3468 let worktree_id = entry.worktree_id;
3469 self.project
3470 .update(cx, |project, cx| project.remove_worktree(worktree_id, cx));
3471 }
3472 }
3473
3474 fn file_abs_paths_to_diff(&self, cx: &Context<Self>) -> Option<(PathBuf, PathBuf)> {
3475 let mut selections_abs_path = self
3476 .marked_entries
3477 .iter()
3478 .filter_map(|entry| {
3479 let project = self.project.read(cx);
3480 let worktree = project.worktree_for_id(entry.worktree_id, cx)?;
3481 let entry = worktree.read(cx).entry_for_id(entry.entry_id)?;
3482 if !entry.is_file() {
3483 return None;
3484 }
3485 Some(worktree.read(cx).absolutize(&entry.path))
3486 })
3487 .rev();
3488
3489 let last_path = selections_abs_path.next()?;
3490 let previous_to_last = selections_abs_path.next()?;
3491 Some((previous_to_last, last_path))
3492 }
3493
3494 fn compare_marked_files(
3495 &mut self,
3496 _: &CompareMarkedFiles,
3497 window: &mut Window,
3498 cx: &mut Context<Self>,
3499 ) {
3500 let selected_files = self.file_abs_paths_to_diff(cx);
3501 if let Some((file_path1, file_path2)) = selected_files {
3502 self.workspace
3503 .update(cx, |workspace, cx| {
3504 FileDiffView::open(file_path1, file_path2, workspace.weak_handle(), window, cx)
3505 .detach_and_log_err(cx);
3506 })
3507 .ok();
3508 }
3509 }
3510
3511 fn open_system(&mut self, _: &OpenWithSystem, _: &mut Window, cx: &mut Context<Self>) {
3512 if let Some((worktree, entry)) = self.selected_entry(cx) {
3513 let abs_path = worktree.absolutize(&entry.path);
3514 cx.open_with_system(&abs_path);
3515 }
3516 }
3517
3518 fn open_in_terminal(
3519 &mut self,
3520 _: &OpenInTerminal,
3521 window: &mut Window,
3522 cx: &mut Context<Self>,
3523 ) {
3524 if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
3525 let abs_path = match &entry.canonical_path {
3526 Some(canonical_path) => canonical_path.to_path_buf(),
3527 None => worktree.read(cx).absolutize(&entry.path),
3528 };
3529
3530 let working_directory = if entry.is_dir() {
3531 Some(abs_path)
3532 } else {
3533 abs_path.parent().map(|path| path.to_path_buf())
3534 };
3535 if let Some(working_directory) = working_directory {
3536 window.dispatch_action(
3537 workspace::OpenTerminal {
3538 working_directory,
3539 local: false,
3540 }
3541 .boxed_clone(),
3542 cx,
3543 )
3544 }
3545 }
3546 }
3547
3548 pub fn new_search_in_directory(
3549 &mut self,
3550 _: &NewSearchInDirectory,
3551 window: &mut Window,
3552 cx: &mut Context<Self>,
3553 ) {
3554 if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
3555 let dir_path = if entry.is_dir() {
3556 entry.path.clone()
3557 } else {
3558 // entry is a file, use its parent directory
3559 match entry.path.parent() {
3560 Some(parent) => Arc::from(parent),
3561 None => {
3562 // File at root, open search with empty filter
3563 self.workspace
3564 .update(cx, |workspace, cx| {
3565 search::ProjectSearchView::new_search_in_directory(
3566 workspace,
3567 RelPath::empty(),
3568 window,
3569 cx,
3570 );
3571 })
3572 .ok();
3573 return;
3574 }
3575 }
3576 };
3577
3578 let include_root = self.project.read(cx).visible_worktrees(cx).count() > 1;
3579 let dir_path = if include_root {
3580 worktree.read(cx).root_name().join(&dir_path)
3581 } else {
3582 dir_path
3583 };
3584
3585 self.workspace
3586 .update(cx, |workspace, cx| {
3587 search::ProjectSearchView::new_search_in_directory(
3588 workspace, &dir_path, window, cx,
3589 );
3590 })
3591 .ok();
3592 }
3593 }
3594
3595 fn move_entry(
3596 &mut self,
3597 entry_to_move: ProjectEntryId,
3598 destination: ProjectEntryId,
3599 destination_is_file: bool,
3600 cx: &mut Context<Self>,
3601 ) -> Option<Task<Result<CreatedEntry>>> {
3602 if self
3603 .project
3604 .read(cx)
3605 .entry_is_worktree_root(entry_to_move, cx)
3606 {
3607 self.move_worktree_root(entry_to_move, destination, cx);
3608 None
3609 } else {
3610 self.move_worktree_entry(entry_to_move, destination, destination_is_file, cx)
3611 }
3612 }
3613
3614 fn move_worktree_root(
3615 &mut self,
3616 entry_to_move: ProjectEntryId,
3617 destination: ProjectEntryId,
3618 cx: &mut Context<Self>,
3619 ) {
3620 self.project.update(cx, |project, cx| {
3621 let Some(worktree_to_move) = project.worktree_for_entry(entry_to_move, cx) else {
3622 return;
3623 };
3624 let Some(destination_worktree) = project.worktree_for_entry(destination, cx) else {
3625 return;
3626 };
3627
3628 let worktree_id = worktree_to_move.read(cx).id();
3629 let destination_id = destination_worktree.read(cx).id();
3630
3631 project
3632 .move_worktree(worktree_id, destination_id, cx)
3633 .log_err();
3634 });
3635 }
3636
3637 fn move_worktree_entry(
3638 &mut self,
3639 entry_to_move: ProjectEntryId,
3640 destination_entry: ProjectEntryId,
3641 destination_is_file: bool,
3642 cx: &mut Context<Self>,
3643 ) -> Option<Task<Result<CreatedEntry>>> {
3644 if entry_to_move == destination_entry {
3645 return None;
3646 }
3647
3648 let (destination_worktree, rename_task) = self.project.update(cx, |project, cx| {
3649 let Some(source_path) = project.path_for_entry(entry_to_move, cx) else {
3650 return (None, None);
3651 };
3652 let Some(destination_path) = project.path_for_entry(destination_entry, cx) else {
3653 return (None, None);
3654 };
3655 let destination_worktree_id = destination_path.worktree_id;
3656
3657 let destination_dir = if destination_is_file {
3658 destination_path.path.parent().unwrap_or(RelPath::empty())
3659 } else {
3660 destination_path.path.as_ref()
3661 };
3662
3663 let Some(source_name) = source_path.path.file_name() else {
3664 return (None, None);
3665 };
3666 let Ok(source_name) = RelPath::unix(source_name) else {
3667 return (None, None);
3668 };
3669
3670 let mut new_path = destination_dir.to_rel_path_buf();
3671 new_path.push(source_name);
3672 let rename_task = (new_path.as_rel_path() != source_path.path.as_ref()).then(|| {
3673 project.rename_entry(
3674 entry_to_move,
3675 (destination_worktree_id, new_path).into(),
3676 cx,
3677 )
3678 });
3679
3680 (
3681 project.worktree_id_for_entry(destination_entry, cx),
3682 rename_task,
3683 )
3684 });
3685
3686 if let Some(destination_worktree) = destination_worktree {
3687 self.expand_entry(destination_worktree, destination_entry, cx);
3688 }
3689 rename_task
3690 }
3691
3692 fn index_for_selection(&self, selection: SelectedEntry) -> Option<(usize, usize, usize)> {
3693 self.index_for_entry(selection.entry_id, selection.worktree_id)
3694 }
3695
3696 fn disjoint_effective_entries(&self, cx: &App) -> BTreeSet<SelectedEntry> {
3697 self.disjoint_entries(self.effective_entries(), cx)
3698 }
3699
3700 fn disjoint_entries(
3701 &self,
3702 entries: BTreeSet<SelectedEntry>,
3703 cx: &App,
3704 ) -> BTreeSet<SelectedEntry> {
3705 let mut sanitized_entries = BTreeSet::new();
3706 if entries.is_empty() {
3707 return sanitized_entries;
3708 }
3709
3710 let project = self.project.read(cx);
3711 let entries_by_worktree: HashMap<WorktreeId, Vec<SelectedEntry>> = entries
3712 .into_iter()
3713 .filter(|entry| !project.entry_is_worktree_root(entry.entry_id, cx))
3714 .fold(HashMap::default(), |mut map, entry| {
3715 map.entry(entry.worktree_id).or_default().push(entry);
3716 map
3717 });
3718
3719 for (worktree_id, worktree_entries) in entries_by_worktree {
3720 if let Some(worktree) = project.worktree_for_id(worktree_id, cx) {
3721 let worktree = worktree.read(cx);
3722 let dir_paths = worktree_entries
3723 .iter()
3724 .filter_map(|entry| {
3725 worktree.entry_for_id(entry.entry_id).and_then(|entry| {
3726 if entry.is_dir() {
3727 Some(entry.path.as_ref())
3728 } else {
3729 None
3730 }
3731 })
3732 })
3733 .collect::<BTreeSet<_>>();
3734
3735 sanitized_entries.extend(worktree_entries.into_iter().filter(|entry| {
3736 let Some(entry_info) = worktree.entry_for_id(entry.entry_id) else {
3737 return false;
3738 };
3739 let entry_path = entry_info.path.as_ref();
3740 let inside_selected_dir = dir_paths.iter().any(|&dir_path| {
3741 entry_path != dir_path && entry_path.starts_with(dir_path)
3742 });
3743 !inside_selected_dir
3744 }));
3745 }
3746 }
3747
3748 sanitized_entries
3749 }
3750
3751 fn effective_entries(&self) -> BTreeSet<SelectedEntry> {
3752 if let Some(selection) = self.selection {
3753 let selection = SelectedEntry {
3754 entry_id: self.resolve_entry(selection.entry_id),
3755 worktree_id: selection.worktree_id,
3756 };
3757
3758 // Default to using just the selected item when nothing is marked.
3759 if self.marked_entries.is_empty() {
3760 return BTreeSet::from([selection]);
3761 }
3762
3763 // Allow operating on the selected item even when something else is marked,
3764 // making it easier to perform one-off actions without clearing a mark.
3765 if self.marked_entries.len() == 1 && !self.marked_entries.contains(&selection) {
3766 return BTreeSet::from([selection]);
3767 }
3768 }
3769
3770 // Return only marked entries since we've already handled special cases where
3771 // only selection should take precedence. At this point, marked entries may or
3772 // may not include the current selection, which is intentional.
3773 self.marked_entries
3774 .iter()
3775 .map(|entry| SelectedEntry {
3776 entry_id: self.resolve_entry(entry.entry_id),
3777 worktree_id: entry.worktree_id,
3778 })
3779 .collect::<BTreeSet<_>>()
3780 }
3781
3782 /// Finds the currently selected subentry for a given leaf entry id. If a given entry
3783 /// has no ancestors, the project entry ID that's passed in is returned as-is.
3784 fn resolve_entry(&self, id: ProjectEntryId) -> ProjectEntryId {
3785 self.state
3786 .ancestors
3787 .get(&id)
3788 .and_then(|ancestors| ancestors.active_ancestor())
3789 .unwrap_or(id)
3790 }
3791
3792 pub fn selected_entry<'a>(&self, cx: &'a App) -> Option<(&'a Worktree, &'a project::Entry)> {
3793 let (worktree, entry) = self.selected_entry_handle(cx)?;
3794 Some((worktree.read(cx), entry))
3795 }
3796
3797 /// Compared to selected_entry, this function resolves to the currently
3798 /// selected subentry if dir auto-folding is enabled.
3799 fn selected_sub_entry<'a>(
3800 &self,
3801 cx: &'a App,
3802 ) -> Option<(Entity<Worktree>, &'a project::Entry)> {
3803 let (worktree, mut entry) = self.selected_entry_handle(cx)?;
3804
3805 let resolved_id = self.resolve_entry(entry.id);
3806 if resolved_id != entry.id {
3807 let worktree = worktree.read(cx);
3808 entry = worktree.entry_for_id(resolved_id)?;
3809 }
3810 Some((worktree, entry))
3811 }
3812
3813 fn reveal_in_file_manager_path(&self, cx: &App) -> Option<PathBuf> {
3814 if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
3815 return Some(worktree.read(cx).absolutize(&entry.path));
3816 }
3817
3818 let root_entry_id = self.state.last_worktree_root_id?;
3819 let project = self.project.read(cx);
3820 let worktree = project.worktree_for_entry(root_entry_id, cx)?;
3821 let worktree = worktree.read(cx);
3822 let root_entry = worktree.entry_for_id(root_entry_id)?;
3823 Some(worktree.absolutize(&root_entry.path))
3824 }
3825
3826 fn write_entries_to_system_clipboard(&self, entries: &BTreeSet<SelectedEntry>, cx: &mut App) {
3827 let project = self.project.read(cx);
3828 let paths: Vec<String> = entries
3829 .iter()
3830 .filter_map(|entry| {
3831 let worktree = project.worktree_for_id(entry.worktree_id, cx)?;
3832 let worktree = worktree.read(cx);
3833 let worktree_entry = worktree.entry_for_id(entry.entry_id)?;
3834 Some(
3835 worktree
3836 .abs_path()
3837 .join(worktree_entry.path.as_std_path())
3838 .to_string_lossy()
3839 .to_string(),
3840 )
3841 })
3842 .collect();
3843 if !paths.is_empty() {
3844 cx.write_to_clipboard(ClipboardItem::new_string(paths.join("\n")));
3845 }
3846 }
3847
3848 fn external_paths_from_system_clipboard(&self, cx: &App) -> Option<ExternalPaths> {
3849 let clipboard_item = cx.read_from_clipboard()?;
3850 for entry in clipboard_item.entries() {
3851 if let GpuiClipboardEntry::ExternalPaths(paths) = entry {
3852 if !paths.paths().is_empty() {
3853 return Some(paths.clone());
3854 }
3855 }
3856 }
3857 None
3858 }
3859
3860 fn has_pasteable_content(&self, cx: &App) -> bool {
3861 if self
3862 .clipboard
3863 .as_ref()
3864 .is_some_and(|c| !c.items().is_empty())
3865 {
3866 return true;
3867 }
3868 self.external_paths_from_system_clipboard(cx).is_some()
3869 }
3870
3871 fn selected_entry_handle<'a>(
3872 &self,
3873 cx: &'a App,
3874 ) -> Option<(Entity<Worktree>, &'a project::Entry)> {
3875 let selection = self.selection?;
3876 let project = self.project.read(cx);
3877 let worktree = project.worktree_for_id(selection.worktree_id, cx)?;
3878 let entry = worktree.read(cx).entry_for_id(selection.entry_id)?;
3879 Some((worktree, entry))
3880 }
3881
3882 fn expand_to_selection(&mut self, cx: &mut Context<Self>) -> Option<()> {
3883 let (worktree, entry) = self.selected_entry(cx)?;
3884 let expanded_dir_ids = self
3885 .state
3886 .expanded_dir_ids
3887 .entry(worktree.id())
3888 .or_default();
3889
3890 for path in entry.path.ancestors() {
3891 let Some(entry) = worktree.entry_for_path(path) else {
3892 continue;
3893 };
3894 if entry.is_dir()
3895 && let Err(idx) = expanded_dir_ids.binary_search(&entry.id)
3896 {
3897 expanded_dir_ids.insert(idx, entry.id);
3898 }
3899 }
3900
3901 Some(())
3902 }
3903
3904 fn create_new_git_entry(
3905 parent_entry: &Entry,
3906 git_summary: GitSummary,
3907 new_entry_kind: EntryKind,
3908 ) -> GitEntry {
3909 GitEntry {
3910 entry: Entry {
3911 id: NEW_ENTRY_ID,
3912 kind: new_entry_kind,
3913 path: parent_entry.path.join(RelPath::unix("\0").unwrap()),
3914 inode: 0,
3915 mtime: parent_entry.mtime,
3916 size: parent_entry.size,
3917 is_ignored: parent_entry.is_ignored,
3918 is_hidden: parent_entry.is_hidden,
3919 is_external: false,
3920 is_private: false,
3921 is_always_included: parent_entry.is_always_included,
3922 canonical_path: parent_entry.canonical_path.clone(),
3923 char_bag: parent_entry.char_bag,
3924 is_fifo: parent_entry.is_fifo,
3925 },
3926 git_summary,
3927 }
3928 }
3929
3930 fn update_visible_entries(
3931 &mut self,
3932 new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
3933 focus_filename_editor: bool,
3934 autoscroll: bool,
3935 window: &mut Window,
3936 cx: &mut Context<Self>,
3937 ) {
3938 let now = Instant::now();
3939 let settings = ProjectPanelSettings::get_global(cx);
3940 let auto_collapse_dirs = settings.auto_fold_dirs;
3941 let hide_gitignore = settings.hide_gitignore;
3942 let sort_mode = settings.sort_mode;
3943 let sort_order = settings.sort_order;
3944 let project = self.project.read(cx);
3945 let repo_snapshots = project.git_store().read(cx).repo_snapshots(cx);
3946
3947 let old_ancestors = self.state.ancestors.clone();
3948 let temporary_unfolded_pending_state = self.state.temporarily_unfolded_pending_state.take();
3949 let mut new_state = State::derive(&self.state);
3950 new_state.last_worktree_root_id = project
3951 .visible_worktrees(cx)
3952 .next_back()
3953 .and_then(|worktree| worktree.read(cx).root_entry())
3954 .map(|entry| entry.id);
3955 let mut max_width_item = None;
3956
3957 let visible_worktrees: Vec<_> = project
3958 .visible_worktrees(cx)
3959 .map(|worktree| worktree.read(cx).snapshot())
3960 .collect();
3961 let hide_root = settings.hide_root && visible_worktrees.len() == 1;
3962 let hide_hidden = settings.hide_hidden;
3963
3964 let visible_entries_task = cx.spawn_in(window, async move |this, cx| {
3965 let new_state = cx
3966 .background_spawn(async move {
3967 for worktree_snapshot in visible_worktrees {
3968 let worktree_id = worktree_snapshot.id();
3969
3970 let mut new_entry_parent_id = None;
3971 let mut new_entry_kind = EntryKind::Dir;
3972 if let Some(edit_state) = &new_state.edit_state
3973 && edit_state.worktree_id == worktree_id
3974 && edit_state.is_new_entry()
3975 {
3976 new_entry_parent_id = Some(edit_state.entry_id);
3977 new_entry_kind = if edit_state.is_dir {
3978 EntryKind::Dir
3979 } else {
3980 EntryKind::File
3981 };
3982 }
3983
3984 let mut visible_worktree_entries = Vec::new();
3985 let mut entry_iter =
3986 GitTraversal::new(&repo_snapshots, worktree_snapshot.entries(true, 0));
3987 let mut auto_folded_ancestors = vec![];
3988 let worktree_abs_path = worktree_snapshot.abs_path();
3989 while let Some(entry) = entry_iter.entry() {
3990 if hide_root && Some(entry.entry) == worktree_snapshot.root_entry() {
3991 if new_entry_parent_id == Some(entry.id) {
3992 visible_worktree_entries.push(Self::create_new_git_entry(
3993 entry.entry,
3994 entry.git_summary,
3995 new_entry_kind,
3996 ));
3997 new_entry_parent_id = None;
3998 }
3999 entry_iter.advance();
4000 continue;
4001 }
4002 if auto_collapse_dirs && entry.kind.is_dir() {
4003 auto_folded_ancestors.push(entry.id);
4004 if !new_state.is_unfolded(&entry.id)
4005 && let Some(root_path) = worktree_snapshot.root_entry()
4006 {
4007 let mut child_entries =
4008 worktree_snapshot.child_entries(&entry.path);
4009 if let Some(child) = child_entries.next()
4010 && entry.path != root_path.path
4011 && child_entries.next().is_none()
4012 && child.kind.is_dir()
4013 {
4014 entry_iter.advance();
4015
4016 continue;
4017 }
4018 }
4019 let depth = temporary_unfolded_pending_state
4020 .as_ref()
4021 .and_then(|state| {
4022 if state.previously_focused_leaf_entry.worktree_id
4023 == worktree_id
4024 && state.previously_focused_leaf_entry.entry_id
4025 == entry.id
4026 {
4027 auto_folded_ancestors.iter().rev().position(|id| {
4028 *id == state.temporarily_unfolded_active_entry_id
4029 })
4030 } else {
4031 None
4032 }
4033 })
4034 .unwrap_or_else(|| {
4035 old_ancestors
4036 .get(&entry.id)
4037 .map(|ancestor| ancestor.current_ancestor_depth)
4038 .unwrap_or_default()
4039 })
4040 .min(auto_folded_ancestors.len());
4041 if let Some(edit_state) = &mut new_state.edit_state
4042 && edit_state.entry_id == entry.id
4043 {
4044 edit_state.depth = depth;
4045 }
4046 let mut ancestors = std::mem::take(&mut auto_folded_ancestors);
4047 if ancestors.len() > 1 {
4048 ancestors.reverse();
4049 new_state.ancestors.insert(
4050 entry.id,
4051 FoldedAncestors {
4052 current_ancestor_depth: depth,
4053 ancestors,
4054 },
4055 );
4056 }
4057 }
4058 auto_folded_ancestors.clear();
4059 if (!hide_gitignore || !entry.is_ignored)
4060 && (!hide_hidden || !entry.is_hidden)
4061 {
4062 visible_worktree_entries.push(entry.to_owned());
4063 }
4064 let precedes_new_entry = if let Some(new_entry_id) = new_entry_parent_id
4065 {
4066 entry.id == new_entry_id || {
4067 new_state.ancestors.get(&entry.id).is_some_and(|entries| {
4068 entries.ancestors.contains(&new_entry_id)
4069 })
4070 }
4071 } else {
4072 false
4073 };
4074 if precedes_new_entry
4075 && (!hide_gitignore || !entry.is_ignored)
4076 && (!hide_hidden || !entry.is_hidden)
4077 {
4078 visible_worktree_entries.push(Self::create_new_git_entry(
4079 entry.entry,
4080 entry.git_summary,
4081 new_entry_kind,
4082 ));
4083 }
4084
4085 let (depth, chars) = if Some(entry.entry)
4086 == worktree_snapshot.root_entry()
4087 {
4088 let Some(path_name) = worktree_abs_path.file_name() else {
4089 entry_iter.advance();
4090 continue;
4091 };
4092 let depth = 0;
4093 (depth, path_name.to_string_lossy().chars().count())
4094 } else if entry.is_file() {
4095 let Some(path_name) = entry
4096 .path
4097 .file_name()
4098 .with_context(|| {
4099 format!("Non-root entry has no file name: {entry:?}")
4100 })
4101 .log_err()
4102 else {
4103 continue;
4104 };
4105 let depth = entry.path.ancestors().count() - 1;
4106 (depth, path_name.chars().count())
4107 } else {
4108 let path = new_state
4109 .ancestors
4110 .get(&entry.id)
4111 .and_then(|ancestors| {
4112 let outermost_ancestor = ancestors.ancestors.last()?;
4113 let root_folded_entry = worktree_snapshot
4114 .entry_for_id(*outermost_ancestor)?
4115 .path
4116 .as_ref();
4117 entry.path.strip_prefix(root_folded_entry).ok().and_then(
4118 |suffix| {
4119 Some(
4120 RelPath::unix(root_folded_entry.file_name()?)
4121 .unwrap()
4122 .join(suffix),
4123 )
4124 },
4125 )
4126 })
4127 .or_else(|| {
4128 entry.path.file_name().map(|file_name| {
4129 RelPath::unix(file_name).unwrap().into()
4130 })
4131 })
4132 .unwrap_or_else(|| entry.path.clone());
4133 let depth = path.components().count();
4134 (depth, path.as_unix_str().chars().count())
4135 };
4136 let width_estimate =
4137 item_width_estimate(depth, chars, entry.canonical_path.is_some());
4138
4139 match max_width_item.as_mut() {
4140 Some((id, worktree_id, width)) => {
4141 if *width < width_estimate {
4142 *id = entry.id;
4143 *worktree_id = worktree_snapshot.id();
4144 *width = width_estimate;
4145 }
4146 }
4147 None => {
4148 max_width_item =
4149 Some((entry.id, worktree_snapshot.id(), width_estimate))
4150 }
4151 }
4152
4153 let expanded_dir_ids =
4154 match new_state.expanded_dir_ids.entry(worktree_id) {
4155 hash_map::Entry::Occupied(e) => e.into_mut(),
4156 hash_map::Entry::Vacant(e) => {
4157 // The first time a worktree's root entry becomes available,
4158 // mark that root entry as expanded.
4159 if let Some(entry) = worktree_snapshot.root_entry() {
4160 e.insert(vec![entry.id]).as_slice()
4161 } else {
4162 &[]
4163 }
4164 }
4165 };
4166
4167 if expanded_dir_ids.binary_search(&entry.id).is_err()
4168 && entry_iter.advance_to_sibling()
4169 {
4170 continue;
4171 }
4172 entry_iter.advance();
4173 }
4174
4175 par_sort_worktree_entries(
4176 &mut visible_worktree_entries,
4177 sort_mode,
4178 sort_order,
4179 );
4180 new_state.visible_entries.push(VisibleEntriesForWorktree {
4181 worktree_id,
4182 entries: visible_worktree_entries,
4183 index: OnceCell::new(),
4184 })
4185 }
4186 if let Some((project_entry_id, worktree_id, _)) = max_width_item {
4187 let mut visited_worktrees_length = 0;
4188 let index = new_state
4189 .visible_entries
4190 .iter()
4191 .find_map(|visible_entries| {
4192 if worktree_id == visible_entries.worktree_id {
4193 visible_entries
4194 .entries
4195 .iter()
4196 .position(|entry| entry.id == project_entry_id)
4197 } else {
4198 visited_worktrees_length += visible_entries.entries.len();
4199 None
4200 }
4201 });
4202 if let Some(index) = index {
4203 new_state.max_width_item_index = Some(visited_worktrees_length + index);
4204 }
4205 }
4206 new_state
4207 })
4208 .await;
4209 this.update_in(cx, |this, window, cx| {
4210 this.state = new_state;
4211 if let Some((worktree_id, entry_id)) = new_selected_entry {
4212 this.selection = Some(SelectedEntry {
4213 worktree_id,
4214 entry_id,
4215 });
4216 }
4217 let elapsed = now.elapsed();
4218 if this.last_reported_update.elapsed() > Duration::from_secs(3600) {
4219 telemetry::event!(
4220 "Project Panel Updated",
4221 elapsed_ms = elapsed.as_millis() as u64,
4222 worktree_entries = this
4223 .state
4224 .visible_entries
4225 .iter()
4226 .map(|worktree| worktree.entries.len())
4227 .sum::<usize>(),
4228 )
4229 }
4230 if this.update_visible_entries_task.focus_filename_editor {
4231 this.update_visible_entries_task.focus_filename_editor = false;
4232 this.filename_editor.update(cx, |editor, cx| {
4233 window.focus(&editor.focus_handle(cx), cx);
4234 });
4235 }
4236 if this.update_visible_entries_task.autoscroll {
4237 this.update_visible_entries_task.autoscroll = false;
4238 this.autoscroll(cx);
4239 }
4240 cx.notify();
4241 })
4242 .ok();
4243 });
4244
4245 self.update_visible_entries_task = UpdateVisibleEntriesTask {
4246 _visible_entries_task: visible_entries_task,
4247 focus_filename_editor: focus_filename_editor
4248 || self.update_visible_entries_task.focus_filename_editor,
4249 autoscroll: autoscroll || self.update_visible_entries_task.autoscroll,
4250 };
4251 }
4252
4253 fn expand_entry(
4254 &mut self,
4255 worktree_id: WorktreeId,
4256 entry_id: ProjectEntryId,
4257 cx: &mut Context<Self>,
4258 ) {
4259 self.project.update(cx, |project, cx| {
4260 if let Some((worktree, expanded_dir_ids)) = project
4261 .worktree_for_id(worktree_id, cx)
4262 .zip(self.state.expanded_dir_ids.get_mut(&worktree_id))
4263 {
4264 project.expand_entry(worktree_id, entry_id, cx);
4265 let worktree = worktree.read(cx);
4266
4267 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
4268 loop {
4269 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
4270 expanded_dir_ids.insert(ix, entry.id);
4271 }
4272
4273 if let Some(parent_entry) =
4274 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
4275 {
4276 entry = parent_entry;
4277 } else {
4278 break;
4279 }
4280 }
4281 }
4282 }
4283 });
4284 }
4285
4286 fn drop_external_files(
4287 &mut self,
4288 paths: &[PathBuf],
4289 entry_id: ProjectEntryId,
4290 window: &mut Window,
4291 cx: &mut Context<Self>,
4292 ) {
4293 let mut paths: Vec<Arc<Path>> = paths.iter().map(|path| Arc::from(path.clone())).collect();
4294
4295 let open_file_after_drop = paths.len() == 1 && paths[0].is_file();
4296
4297 let Some((target_directory, worktree, fs)) = maybe!({
4298 let project = self.project.read(cx);
4299 let fs = project.fs().clone();
4300 let worktree = project.worktree_for_entry(entry_id, cx)?;
4301 let entry = worktree.read(cx).entry_for_id(entry_id)?;
4302 let path = entry.path.clone();
4303 let target_directory = if entry.is_dir() {
4304 path
4305 } else {
4306 path.parent()?.into()
4307 };
4308 Some((target_directory, worktree, fs))
4309 }) else {
4310 return;
4311 };
4312
4313 let mut paths_to_replace = Vec::new();
4314 for path in &paths {
4315 if let Some(name) = path.file_name()
4316 && let Some(name) = name.to_str()
4317 {
4318 let target_path = target_directory.join(RelPath::unix(name).unwrap());
4319 if worktree.read(cx).entry_for_path(&target_path).is_some() {
4320 paths_to_replace.push((name.to_string(), path.clone()));
4321 }
4322 }
4323 }
4324
4325 cx.spawn_in(window, async move |this, cx| {
4326 async move {
4327 for (filename, original_path) in &paths_to_replace {
4328 let prompt_message = format!(
4329 concat!(
4330 "A file or folder with name {} ",
4331 "already exists in the destination folder. ",
4332 "Do you want to replace it?"
4333 ),
4334 filename
4335 );
4336 let answer = cx
4337 .update(|window, cx| {
4338 window.prompt(
4339 PromptLevel::Info,
4340 &prompt_message,
4341 None,
4342 &["Replace", "Cancel"],
4343 cx,
4344 )
4345 })?
4346 .await?;
4347
4348 if answer == 1
4349 && let Some(item_idx) = paths.iter().position(|p| p == original_path)
4350 {
4351 paths.remove(item_idx);
4352 }
4353 }
4354
4355 if paths.is_empty() {
4356 return Ok(());
4357 }
4358
4359 let (worktree_id, task) = worktree.update(cx, |worktree, cx| {
4360 (
4361 worktree.id(),
4362 worktree.copy_external_entries(target_directory, paths, fs, cx),
4363 )
4364 });
4365
4366 let opened_entries: Vec<_> = task
4367 .await
4368 .with_context(|| "failed to copy external paths")?;
4369 this.update_in(cx, |this, window, cx| {
4370 let mut did_open = false;
4371 if open_file_after_drop && !opened_entries.is_empty() {
4372 let settings = ProjectPanelSettings::get_global(cx);
4373 if settings.auto_open.should_open_on_drop() {
4374 this.open_entry(opened_entries[0], true, false, cx);
4375 did_open = true;
4376 }
4377 }
4378
4379 if !did_open {
4380 let new_selection = opened_entries
4381 .last()
4382 .map(|&entry_id| (worktree_id, entry_id));
4383 for &entry_id in &opened_entries {
4384 this.expand_entry(worktree_id, entry_id, cx);
4385 }
4386 this.marked_entries.clear();
4387 this.update_visible_entries(new_selection, false, false, window, cx);
4388 }
4389
4390 let changes: Vec<Change> = opened_entries
4391 .iter()
4392 .filter_map(|entry_id| {
4393 worktree.read(cx).entry_for_id(*entry_id).map(|entry| {
4394 Change::Created(ProjectPath {
4395 worktree_id,
4396 path: entry.path.clone(),
4397 })
4398 })
4399 })
4400 .collect();
4401
4402 this.undo_manager.record(changes).log_err();
4403 })
4404 }
4405 .log_err()
4406 .await
4407 })
4408 .detach();
4409 }
4410
4411 fn refresh_drag_cursor_style(
4412 &self,
4413 modifiers: &Modifiers,
4414 window: &mut Window,
4415 cx: &mut Context<Self>,
4416 ) {
4417 if let Some(existing_cursor) = cx.active_drag_cursor_style() {
4418 let new_cursor = if Self::is_copy_modifier_set(modifiers) {
4419 CursorStyle::DragCopy
4420 } else {
4421 CursorStyle::PointingHand
4422 };
4423 if existing_cursor != new_cursor {
4424 cx.set_active_drag_cursor_style(new_cursor, window);
4425 }
4426 }
4427 }
4428
4429 fn is_copy_modifier_set(modifiers: &Modifiers) -> bool {
4430 cfg!(target_os = "macos") && modifiers.alt
4431 || cfg!(not(target_os = "macos")) && modifiers.control
4432 }
4433
4434 fn drag_onto(
4435 &mut self,
4436 selections: &DraggedSelection,
4437 target_entry_id: ProjectEntryId,
4438 is_file: bool,
4439 window: &mut Window,
4440 cx: &mut Context<Self>,
4441 ) {
4442 let resolved_selections = selections
4443 .items()
4444 .map(|entry| SelectedEntry {
4445 entry_id: self.resolve_entry(entry.entry_id),
4446 worktree_id: entry.worktree_id,
4447 })
4448 .collect::<BTreeSet<SelectedEntry>>();
4449 let entries = self.disjoint_entries(resolved_selections, cx);
4450
4451 if Self::is_copy_modifier_set(&window.modifiers()) {
4452 let _ = maybe!({
4453 let project = self.project.read(cx);
4454 let target_worktree = project.worktree_for_entry(target_entry_id, cx)?;
4455 let worktree_id = target_worktree.read(cx).id();
4456 let target_entry = target_worktree
4457 .read(cx)
4458 .entry_for_id(target_entry_id)?
4459 .clone();
4460
4461 let mut copy_tasks = Vec::new();
4462 let mut disambiguation_range = None;
4463 for selection in &entries {
4464 let (new_path, new_disambiguation_range) = self.create_paste_path(
4465 selection,
4466 (target_worktree.clone(), &target_entry),
4467 cx,
4468 )?;
4469
4470 let task = self.project.update(cx, |project, cx| {
4471 project.copy_entry(selection.entry_id, (worktree_id, new_path).into(), cx)
4472 });
4473 copy_tasks.push(task);
4474 disambiguation_range = new_disambiguation_range.or(disambiguation_range);
4475 }
4476
4477 let item_count = copy_tasks.len();
4478
4479 cx.spawn_in(window, async move |project_panel, cx| {
4480 let mut last_succeed = None;
4481 let mut changes = Vec::new();
4482 for task in copy_tasks.into_iter() {
4483 if let Some(Some(entry)) = task.await.log_err() {
4484 last_succeed = Some(entry.id);
4485 changes.push(Change::Created((worktree_id, entry.path).into()));
4486 }
4487 }
4488 // update selection
4489 if let Some(entry_id) = last_succeed {
4490 project_panel.update_in(cx, |project_panel, window, cx| {
4491 project_panel.selection = Some(SelectedEntry {
4492 worktree_id,
4493 entry_id,
4494 });
4495 // if only one entry was dragged and it was disambiguated, open the rename editor
4496 if item_count == 1 && disambiguation_range.is_some() {
4497 project_panel.rename_impl(disambiguation_range, window, cx);
4498 }
4499
4500 project_panel.undo_manager.record(changes)
4501 })??;
4502 }
4503
4504 std::result::Result::Ok::<(), anyhow::Error>(())
4505 })
4506 .detach();
4507 Some(())
4508 });
4509 } else {
4510 let update_marks = !self.marked_entries.is_empty();
4511 let active_selection = selections.active_selection;
4512
4513 // For folded selections, track the leaf suffix relative to the resolved
4514 // entry so we can refresh it after the move completes.
4515 let (folded_selection_info, folded_selection_entries): (
4516 Vec<(ProjectEntryId, RelPathBuf)>,
4517 HashSet<SelectedEntry>,
4518 ) = {
4519 let project = self.project.read(cx);
4520 let mut info = Vec::new();
4521 let mut folded_entries = HashSet::default();
4522
4523 for selection in selections.items() {
4524 let resolved_id = self.resolve_entry(selection.entry_id);
4525 if resolved_id == selection.entry_id {
4526 continue;
4527 }
4528 folded_entries.insert(*selection);
4529 let Some(source_path) = project.path_for_entry(resolved_id, cx) else {
4530 continue;
4531 };
4532 let Some(leaf_path) = project.path_for_entry(selection.entry_id, cx) else {
4533 continue;
4534 };
4535 let Ok(suffix) = leaf_path.path.strip_prefix(source_path.path.as_ref()) else {
4536 continue;
4537 };
4538 if suffix.as_unix_str().is_empty() {
4539 continue;
4540 }
4541
4542 info.push((resolved_id, suffix.to_rel_path_buf()));
4543 }
4544 (info, folded_entries)
4545 };
4546
4547 // Capture old paths before moving so we can record undo operations.
4548 let old_paths: HashMap<ProjectEntryId, ProjectPath> = {
4549 let project = self.project.read(cx);
4550 entries
4551 .iter()
4552 .filter_map(|entry| {
4553 let path = project.path_for_entry(entry.entry_id, cx)?;
4554 Some((entry.entry_id, path))
4555 })
4556 .collect()
4557 };
4558 let destination_worktree_id = self
4559 .project
4560 .read(cx)
4561 .worktree_for_entry(target_entry_id, cx)
4562 .map(|wt| wt.read(cx).id());
4563
4564 // Collect move tasks paired with their source entry ID so we can correlate
4565 // results with folded selections that need refreshing.
4566 let mut move_tasks: Vec<(ProjectEntryId, Task<Result<CreatedEntry>>)> = Vec::new();
4567 for entry in entries {
4568 if let Some(task) = self.move_entry(entry.entry_id, target_entry_id, is_file, cx) {
4569 move_tasks.push((entry.entry_id, task));
4570 }
4571 }
4572
4573 if move_tasks.is_empty() {
4574 return;
4575 }
4576
4577 let workspace = self.workspace.clone();
4578 if folded_selection_info.is_empty() {
4579 cx.spawn_in(window, async move |project_panel, mut cx| {
4580 let mut changes = Vec::new();
4581 for (entry_id, task) in move_tasks {
4582 if let Some(CreatedEntry::Included(new_entry)) = task
4583 .await
4584 .notify_workspace_async_err(workspace.clone(), &mut cx)
4585 {
4586 if let (Some(old_path), Some(worktree_id)) =
4587 (old_paths.get(&entry_id), destination_worktree_id)
4588 {
4589 changes.push(Change::Renamed(
4590 old_path.clone(),
4591 (worktree_id, new_entry.path).into(),
4592 ));
4593 }
4594 }
4595 }
4596 project_panel
4597 .update(cx, |this, _| {
4598 this.undo_manager.record(changes).log_err();
4599 })
4600 .ok();
4601 })
4602 .detach();
4603 } else {
4604 cx.spawn_in(window, async move |project_panel, mut cx| {
4605 // Await all move tasks and collect successful results
4606 let mut move_results: Vec<(ProjectEntryId, Entry)> = Vec::new();
4607 let mut operations = Vec::new();
4608 for (entry_id, task) in move_tasks {
4609 if let Some(CreatedEntry::Included(new_entry)) = task
4610 .await
4611 .notify_workspace_async_err(workspace.clone(), &mut cx)
4612 {
4613 if let (Some(old_path), Some(worktree_id)) =
4614 (old_paths.get(&entry_id), destination_worktree_id)
4615 {
4616 operations.push(Change::Renamed(
4617 old_path.clone(),
4618 (worktree_id, new_entry.path.clone()).into(),
4619 ));
4620 }
4621 move_results.push((entry_id, new_entry));
4622 }
4623 }
4624
4625 if move_results.is_empty() {
4626 return;
4627 }
4628
4629 project_panel
4630 .update(cx, |this, _| {
4631 this.undo_manager.record(operations).log_err();
4632 })
4633 .ok();
4634
4635 // For folded selections, we need to refresh the leaf paths (with suffixes)
4636 // because they may not be indexed yet after the parent directory was moved.
4637 // First collect the paths to refresh, then refresh them.
4638 let paths_to_refresh: Vec<(Entity<Worktree>, Arc<RelPath>)> = project_panel
4639 .update(cx, |project_panel, cx| {
4640 let project = project_panel.project.read(cx);
4641 folded_selection_info
4642 .iter()
4643 .filter_map(|(resolved_id, suffix)| {
4644 let (_, new_entry) =
4645 move_results.iter().find(|(id, _)| id == resolved_id)?;
4646 let worktree = project.worktree_for_entry(new_entry.id, cx)?;
4647 let leaf_path = new_entry.path.join(suffix);
4648 Some((worktree, leaf_path))
4649 })
4650 .collect()
4651 })
4652 .ok()
4653 .unwrap_or_default();
4654
4655 let refresh_tasks: Vec<_> = paths_to_refresh
4656 .into_iter()
4657 .filter_map(|(worktree, leaf_path)| {
4658 worktree.update(cx, |worktree, cx| {
4659 worktree
4660 .as_local_mut()
4661 .map(|local| local.refresh_entry(leaf_path, None, cx))
4662 })
4663 })
4664 .collect();
4665
4666 for task in refresh_tasks {
4667 task.await.log_err();
4668 }
4669
4670 if update_marks && !folded_selection_entries.is_empty() {
4671 project_panel
4672 .update(cx, |project_panel, cx| {
4673 project_panel.marked_entries.retain(|entry| {
4674 !folded_selection_entries.contains(entry)
4675 || *entry == active_selection
4676 });
4677 cx.notify();
4678 })
4679 .ok();
4680 }
4681 })
4682 .detach();
4683 }
4684 }
4685 }
4686
4687 fn index_for_entry(
4688 &self,
4689 entry_id: ProjectEntryId,
4690 worktree_id: WorktreeId,
4691 ) -> Option<(usize, usize, usize)> {
4692 let mut total_ix = 0;
4693 for (worktree_ix, visible) in self.state.visible_entries.iter().enumerate() {
4694 if worktree_id != visible.worktree_id {
4695 total_ix += visible.entries.len();
4696 continue;
4697 }
4698
4699 return visible
4700 .entries
4701 .iter()
4702 .enumerate()
4703 .find(|(_, entry)| entry.id == entry_id)
4704 .map(|(ix, _)| (worktree_ix, ix, total_ix + ix));
4705 }
4706 None
4707 }
4708
4709 fn entry_at_index(&self, index: usize) -> Option<(WorktreeId, GitEntryRef<'_>)> {
4710 let mut offset = 0;
4711 for worktree in &self.state.visible_entries {
4712 let current_len = worktree.entries.len();
4713 if index < offset + current_len {
4714 return worktree
4715 .entries
4716 .get(index - offset)
4717 .map(|entry| (worktree.worktree_id, entry.to_ref()));
4718 }
4719 offset += current_len;
4720 }
4721 None
4722 }
4723
4724 fn iter_visible_entries(
4725 &self,
4726 range: Range<usize>,
4727 window: &mut Window,
4728 cx: &mut Context<ProjectPanel>,
4729 callback: &mut dyn FnMut(
4730 &Entry,
4731 usize,
4732 &HashSet<Arc<RelPath>>,
4733 &mut Window,
4734 &mut Context<ProjectPanel>,
4735 ),
4736 ) {
4737 let mut ix = 0;
4738 for visible in &self.state.visible_entries {
4739 if ix >= range.end {
4740 return;
4741 }
4742
4743 if ix + visible.entries.len() <= range.start {
4744 ix += visible.entries.len();
4745 continue;
4746 }
4747
4748 let end_ix = range.end.min(ix + visible.entries.len());
4749 let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
4750 let entries = visible
4751 .index
4752 .get_or_init(|| visible.entries.iter().map(|e| e.path.clone()).collect());
4753 let base_index = ix + entry_range.start;
4754 for (i, entry) in visible.entries[entry_range].iter().enumerate() {
4755 let global_index = base_index + i;
4756 callback(entry, global_index, entries, window, cx);
4757 }
4758 ix = end_ix;
4759 }
4760 }
4761
4762 fn for_each_visible_entry(
4763 &self,
4764 range: Range<usize>,
4765 window: &mut Window,
4766 cx: &mut Context<ProjectPanel>,
4767 callback: &mut dyn FnMut(
4768 ProjectEntryId,
4769 EntryDetails,
4770 &mut Window,
4771 &mut Context<ProjectPanel>,
4772 ),
4773 ) {
4774 let mut ix = 0;
4775 for visible in &self.state.visible_entries {
4776 if ix >= range.end {
4777 return;
4778 }
4779
4780 if ix + visible.entries.len() <= range.start {
4781 ix += visible.entries.len();
4782 continue;
4783 }
4784
4785 let end_ix = range.end.min(ix + visible.entries.len());
4786 let git_status_setting = {
4787 let settings = ProjectPanelSettings::get_global(cx);
4788 settings.git_status
4789 };
4790 if let Some(worktree) = self
4791 .project
4792 .read(cx)
4793 .worktree_for_id(visible.worktree_id, cx)
4794 {
4795 let snapshot = worktree.read(cx).snapshot();
4796 let root_name = snapshot.root_name();
4797
4798 let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
4799 let entries = visible
4800 .index
4801 .get_or_init(|| visible.entries.iter().map(|e| e.path.clone()).collect());
4802 for entry in visible.entries[entry_range].iter() {
4803 let status = git_status_setting
4804 .then_some(entry.git_summary)
4805 .unwrap_or_default();
4806
4807 let mut details = self.details_for_entry(
4808 entry,
4809 visible.worktree_id,
4810 root_name,
4811 entries,
4812 status,
4813 None,
4814 window,
4815 cx,
4816 );
4817
4818 if let Some(edit_state) = &self.state.edit_state {
4819 let is_edited_entry = if edit_state.is_new_entry() {
4820 entry.id == NEW_ENTRY_ID
4821 } else {
4822 entry.id == edit_state.entry_id
4823 || self.state.ancestors.get(&entry.id).is_some_and(
4824 |auto_folded_dirs| {
4825 auto_folded_dirs.ancestors.contains(&edit_state.entry_id)
4826 },
4827 )
4828 };
4829
4830 if is_edited_entry {
4831 if let Some(processing_filename) = &edit_state.processing_filename {
4832 details.is_processing = true;
4833 if let Some(ancestors) = edit_state
4834 .leaf_entry_id
4835 .and_then(|entry| self.state.ancestors.get(&entry))
4836 {
4837 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;
4838 let all_components = ancestors.ancestors.len();
4839
4840 let prefix_components = all_components - position;
4841 let suffix_components = position.checked_sub(1);
4842 let mut previous_components =
4843 Path::new(&details.filename).components();
4844 let mut new_path = previous_components
4845 .by_ref()
4846 .take(prefix_components)
4847 .collect::<PathBuf>();
4848 if let Some(last_component) =
4849 processing_filename.components().next_back()
4850 {
4851 new_path.push(last_component);
4852 previous_components.next();
4853 }
4854
4855 if suffix_components.is_some() {
4856 new_path.push(previous_components);
4857 }
4858 if let Some(str) = new_path.to_str() {
4859 details.filename.clear();
4860 details.filename.push_str(str);
4861 }
4862 } else {
4863 details.filename.clear();
4864 details.filename.push_str(processing_filename.as_unix_str());
4865 }
4866 } else {
4867 if edit_state.is_new_entry() {
4868 details.filename.clear();
4869 }
4870 details.is_editing = true;
4871 }
4872 }
4873 }
4874
4875 callback(entry.id, details, window, cx);
4876 }
4877 }
4878 ix = end_ix;
4879 }
4880 }
4881
4882 fn find_entry_in_worktree(
4883 &self,
4884 worktree_id: WorktreeId,
4885 reverse_search: bool,
4886 only_visible_entries: bool,
4887 predicate: &dyn Fn(GitEntryRef, WorktreeId) -> bool,
4888 cx: &mut Context<Self>,
4889 ) -> Option<GitEntry> {
4890 if only_visible_entries {
4891 let entries = self
4892 .state
4893 .visible_entries
4894 .iter()
4895 .find_map(|visible| {
4896 if worktree_id == visible.worktree_id {
4897 Some(&visible.entries)
4898 } else {
4899 None
4900 }
4901 })?
4902 .clone();
4903
4904 return utils::ReversibleIterable::new(entries.iter(), reverse_search)
4905 .find(|ele| predicate(ele.to_ref(), worktree_id))
4906 .cloned();
4907 }
4908
4909 let repo_snapshots = self
4910 .project
4911 .read(cx)
4912 .git_store()
4913 .read(cx)
4914 .repo_snapshots(cx);
4915 let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
4916 worktree.read_with(cx, |tree, _| {
4917 utils::ReversibleIterable::new(
4918 GitTraversal::new(&repo_snapshots, tree.entries(true, 0usize)),
4919 reverse_search,
4920 )
4921 .find_single_ended(|ele| predicate(*ele, worktree_id))
4922 .map(|ele| ele.to_owned())
4923 })
4924 }
4925
4926 fn find_entry(
4927 &self,
4928 start: Option<&SelectedEntry>,
4929 reverse_search: bool,
4930 predicate: &dyn Fn(GitEntryRef, WorktreeId) -> bool,
4931 cx: &mut Context<Self>,
4932 ) -> Option<SelectedEntry> {
4933 let mut worktree_ids: Vec<_> = self
4934 .state
4935 .visible_entries
4936 .iter()
4937 .map(|worktree| worktree.worktree_id)
4938 .collect();
4939 let repo_snapshots = self
4940 .project
4941 .read(cx)
4942 .git_store()
4943 .read(cx)
4944 .repo_snapshots(cx);
4945
4946 let mut last_found: Option<SelectedEntry> = None;
4947
4948 if let Some(start) = start {
4949 let worktree = self
4950 .project
4951 .read(cx)
4952 .worktree_for_id(start.worktree_id, cx)?
4953 .read(cx);
4954
4955 let search = {
4956 let entry = worktree.entry_for_id(start.entry_id)?;
4957 let root_entry = worktree.root_entry()?;
4958 let tree_id = worktree.id();
4959
4960 let mut first_iter = GitTraversal::new(
4961 &repo_snapshots,
4962 worktree.traverse_from_path(true, true, true, entry.path.as_ref()),
4963 );
4964
4965 if reverse_search {
4966 first_iter.next();
4967 }
4968
4969 let first = first_iter
4970 .enumerate()
4971 .take_until(|(count, entry)| entry.entry == root_entry && *count != 0usize)
4972 .map(|(_, entry)| entry)
4973 .find(|ele| predicate(*ele, tree_id))
4974 .map(|ele| ele.to_owned());
4975
4976 let second_iter =
4977 GitTraversal::new(&repo_snapshots, worktree.entries(true, 0usize));
4978
4979 let second = if reverse_search {
4980 second_iter
4981 .take_until(|ele| ele.id == start.entry_id)
4982 .filter(|ele| predicate(*ele, tree_id))
4983 .last()
4984 .map(|ele| ele.to_owned())
4985 } else {
4986 second_iter
4987 .take_while(|ele| ele.id != start.entry_id)
4988 .filter(|ele| predicate(*ele, tree_id))
4989 .last()
4990 .map(|ele| ele.to_owned())
4991 };
4992
4993 if reverse_search {
4994 Some((second, first))
4995 } else {
4996 Some((first, second))
4997 }
4998 };
4999
5000 if let Some((first, second)) = search {
5001 let first = first.map(|entry| SelectedEntry {
5002 worktree_id: start.worktree_id,
5003 entry_id: entry.id,
5004 });
5005
5006 let second = second.map(|entry| SelectedEntry {
5007 worktree_id: start.worktree_id,
5008 entry_id: entry.id,
5009 });
5010
5011 if first.is_some() {
5012 return first;
5013 }
5014 last_found = second;
5015
5016 let idx = worktree_ids
5017 .iter()
5018 .enumerate()
5019 .find(|(_, ele)| **ele == start.worktree_id)
5020 .map(|(idx, _)| idx);
5021
5022 if let Some(idx) = idx {
5023 worktree_ids.rotate_left(idx + 1usize);
5024 worktree_ids.pop();
5025 }
5026 }
5027 }
5028
5029 for tree_id in worktree_ids.into_iter() {
5030 if let Some(found) =
5031 self.find_entry_in_worktree(tree_id, reverse_search, false, &predicate, cx)
5032 {
5033 return Some(SelectedEntry {
5034 worktree_id: tree_id,
5035 entry_id: found.id,
5036 });
5037 }
5038 }
5039
5040 last_found
5041 }
5042
5043 fn find_visible_entry(
5044 &self,
5045 start: Option<&SelectedEntry>,
5046 reverse_search: bool,
5047 predicate: &dyn Fn(GitEntryRef, WorktreeId) -> bool,
5048 cx: &mut Context<Self>,
5049 ) -> Option<SelectedEntry> {
5050 let mut worktree_ids: Vec<_> = self
5051 .state
5052 .visible_entries
5053 .iter()
5054 .map(|worktree| worktree.worktree_id)
5055 .collect();
5056
5057 let mut last_found: Option<SelectedEntry> = None;
5058
5059 if let Some(start) = start {
5060 let entries = self
5061 .state
5062 .visible_entries
5063 .iter()
5064 .find(|worktree| worktree.worktree_id == start.worktree_id)
5065 .map(|worktree| &worktree.entries)?;
5066
5067 let mut start_idx = entries
5068 .iter()
5069 .enumerate()
5070 .find(|(_, ele)| ele.id == start.entry_id)
5071 .map(|(idx, _)| idx)?;
5072
5073 if reverse_search {
5074 start_idx = start_idx.saturating_add(1usize);
5075 }
5076
5077 let (left, right) = entries.split_at_checked(start_idx)?;
5078
5079 let (first_iter, second_iter) = if reverse_search {
5080 (
5081 utils::ReversibleIterable::new(left.iter(), reverse_search),
5082 utils::ReversibleIterable::new(right.iter(), reverse_search),
5083 )
5084 } else {
5085 (
5086 utils::ReversibleIterable::new(right.iter(), reverse_search),
5087 utils::ReversibleIterable::new(left.iter(), reverse_search),
5088 )
5089 };
5090
5091 let first_search = first_iter.find(|ele| predicate(ele.to_ref(), start.worktree_id));
5092 let second_search = second_iter.find(|ele| predicate(ele.to_ref(), start.worktree_id));
5093
5094 if first_search.is_some() {
5095 return first_search.map(|entry| SelectedEntry {
5096 worktree_id: start.worktree_id,
5097 entry_id: entry.id,
5098 });
5099 }
5100
5101 last_found = second_search.map(|entry| SelectedEntry {
5102 worktree_id: start.worktree_id,
5103 entry_id: entry.id,
5104 });
5105
5106 let idx = worktree_ids
5107 .iter()
5108 .enumerate()
5109 .find(|(_, ele)| **ele == start.worktree_id)
5110 .map(|(idx, _)| idx);
5111
5112 if let Some(idx) = idx {
5113 worktree_ids.rotate_left(idx + 1usize);
5114 worktree_ids.pop();
5115 }
5116 }
5117
5118 for tree_id in worktree_ids.into_iter() {
5119 if let Some(found) =
5120 self.find_entry_in_worktree(tree_id, reverse_search, true, &predicate, cx)
5121 {
5122 return Some(SelectedEntry {
5123 worktree_id: tree_id,
5124 entry_id: found.id,
5125 });
5126 }
5127 }
5128
5129 last_found
5130 }
5131
5132 fn calculate_depth_and_difference(
5133 entry: &Entry,
5134 visible_worktree_entries: &HashSet<Arc<RelPath>>,
5135 ) -> (usize, usize) {
5136 let (depth, difference) = entry
5137 .path
5138 .ancestors()
5139 .skip(1) // Skip the entry itself
5140 .find_map(|ancestor| {
5141 if let Some(parent_entry) = visible_worktree_entries.get(ancestor) {
5142 let entry_path_components_count = entry.path.components().count();
5143 let parent_path_components_count = parent_entry.components().count();
5144 let difference = entry_path_components_count - parent_path_components_count;
5145 let depth = parent_entry
5146 .ancestors()
5147 .skip(1)
5148 .filter(|ancestor| visible_worktree_entries.contains(*ancestor))
5149 .count();
5150 Some((depth + 1, difference))
5151 } else {
5152 None
5153 }
5154 })
5155 .unwrap_or_else(|| (0, entry.path.components().count()));
5156
5157 (depth, difference)
5158 }
5159
5160 fn highlight_entry_for_external_drag(
5161 &self,
5162 target_entry: &Entry,
5163 target_worktree: &Worktree,
5164 ) -> Option<ProjectEntryId> {
5165 // Always highlight directory or parent directory if it's file
5166 if target_entry.is_dir() {
5167 Some(target_entry.id)
5168 } else {
5169 target_entry
5170 .path
5171 .parent()
5172 .and_then(|parent_path| target_worktree.entry_for_path(parent_path))
5173 .map(|parent_entry| parent_entry.id)
5174 }
5175 }
5176
5177 fn highlight_entry_for_selection_drag(
5178 &self,
5179 target_entry: &Entry,
5180 target_worktree: &Worktree,
5181 drag_state: &DraggedSelection,
5182 cx: &Context<Self>,
5183 ) -> Option<ProjectEntryId> {
5184 let target_parent_path = target_entry.path.parent();
5185
5186 // In case of single item drag, we do not highlight existing
5187 // directory which item belongs too
5188 if drag_state.items().count() == 1
5189 && drag_state.active_selection.worktree_id == target_worktree.id()
5190 {
5191 let active_entry_path = self
5192 .project
5193 .read(cx)
5194 .path_for_entry(drag_state.active_selection.entry_id, cx)?;
5195
5196 if let Some(active_parent_path) = active_entry_path.path.parent() {
5197 // Do not highlight active entry parent
5198 if active_parent_path == target_entry.path.as_ref() {
5199 return None;
5200 }
5201
5202 // Do not highlight active entry sibling files
5203 if Some(active_parent_path) == target_parent_path && target_entry.is_file() {
5204 return None;
5205 }
5206 }
5207 }
5208
5209 // Always highlight directory or parent directory if it's file
5210 if target_entry.is_dir() {
5211 Some(target_entry.id)
5212 } else {
5213 target_parent_path
5214 .and_then(|parent_path| target_worktree.entry_for_path(parent_path))
5215 .map(|parent_entry| parent_entry.id)
5216 }
5217 }
5218
5219 fn should_highlight_background_for_selection_drag(
5220 &self,
5221 drag_state: &DraggedSelection,
5222 last_root_id: ProjectEntryId,
5223 cx: &App,
5224 ) -> bool {
5225 // Always highlight for multiple entries
5226 if drag_state.items().count() > 1 {
5227 return true;
5228 }
5229
5230 // Since root will always have empty relative path
5231 if let Some(entry_path) = self
5232 .project
5233 .read(cx)
5234 .path_for_entry(drag_state.active_selection.entry_id, cx)
5235 {
5236 if let Some(parent_path) = entry_path.path.parent() {
5237 if !parent_path.is_empty() {
5238 return true;
5239 }
5240 }
5241 }
5242
5243 // If parent is empty, check if different worktree
5244 if let Some(last_root_worktree_id) = self
5245 .project
5246 .read(cx)
5247 .worktree_id_for_entry(last_root_id, cx)
5248 {
5249 if drag_state.active_selection.worktree_id != last_root_worktree_id {
5250 return true;
5251 }
5252 }
5253
5254 false
5255 }
5256
5257 fn render_entry(
5258 &self,
5259 entry_id: ProjectEntryId,
5260 details: EntryDetails,
5261 window: &mut Window,
5262 cx: &mut Context<Self>,
5263 ) -> Stateful<Div> {
5264 const GROUP_NAME: &str = "project_entry";
5265
5266 let kind = details.kind;
5267 let is_sticky = details.sticky.is_some();
5268 let sticky_index = details.sticky.as_ref().map(|this| this.sticky_index);
5269 let settings = ProjectPanelSettings::get_global(cx);
5270 let show_editor = details.is_editing && !details.is_processing;
5271
5272 let selection = SelectedEntry {
5273 worktree_id: details.worktree_id,
5274 entry_id,
5275 };
5276
5277 let is_marked = self.marked_entries.contains(&selection);
5278 let is_active = self
5279 .selection
5280 .is_some_and(|selection| selection.entry_id == entry_id);
5281
5282 let file_name = details.filename.clone();
5283
5284 let mut icon = details.icon.clone();
5285 if settings.file_icons && show_editor && details.kind.is_file() {
5286 let filename = self.filename_editor.read(cx).text(cx);
5287 if filename.len() > 2 {
5288 icon = FileIcons::get_icon(Path::new(&filename), cx);
5289 }
5290 }
5291
5292 let filename_text_color = details.filename_text_color;
5293 let diagnostic_severity = details.diagnostic_severity;
5294 let diagnostic_count = details.diagnostic_count;
5295 let item_colors = get_item_color(is_sticky, cx);
5296
5297 let canonical_path = details
5298 .canonical_path
5299 .as_ref()
5300 .map(|f| f.to_string_lossy().into_owned());
5301 let path_style = self.project.read(cx).path_style(cx);
5302 let path = details.path.clone();
5303 let path_for_external_paths = path.clone();
5304 let path_for_dragged_selection = path.clone();
5305
5306 let depth = details.depth;
5307 let worktree_id = details.worktree_id;
5308 let dragged_selection = DraggedSelection {
5309 active_selection: SelectedEntry {
5310 worktree_id: selection.worktree_id,
5311 entry_id: selection.entry_id,
5312 },
5313 marked_selections: Arc::from(self.marked_entries.clone()),
5314 };
5315
5316 let bg_color = if is_marked {
5317 item_colors.marked
5318 } else {
5319 item_colors.default
5320 };
5321
5322 let bg_hover_color = if is_marked {
5323 item_colors.marked
5324 } else {
5325 item_colors.hover
5326 };
5327
5328 let validation_color_and_message = if show_editor {
5329 match self
5330 .state
5331 .edit_state
5332 .as_ref()
5333 .map_or(ValidationState::None, |e| e.validation_state.clone())
5334 {
5335 ValidationState::Error(msg) => Some((Color::Error.color(cx), msg)),
5336 ValidationState::Warning(msg) => Some((Color::Warning.color(cx), msg)),
5337 ValidationState::None => None,
5338 }
5339 } else {
5340 None
5341 };
5342
5343 let border_color =
5344 if !self.mouse_down && is_active && self.focus_handle.contains_focused(window, cx) {
5345 match validation_color_and_message {
5346 Some((color, _)) => color,
5347 None => item_colors.focused,
5348 }
5349 } else {
5350 bg_color
5351 };
5352
5353 let border_hover_color =
5354 if !self.mouse_down && is_active && self.focus_handle.contains_focused(window, cx) {
5355 match validation_color_and_message {
5356 Some((color, _)) => color,
5357 None => item_colors.focused,
5358 }
5359 } else {
5360 bg_hover_color
5361 };
5362
5363 let folded_directory_drag_target = self.folded_directory_drag_target;
5364 let is_highlighted = {
5365 if let Some(highlight_entry_id) =
5366 self.drag_target_entry
5367 .as_ref()
5368 .and_then(|drag_target| match drag_target {
5369 DragTarget::Entry {
5370 highlight_entry_id, ..
5371 } => Some(*highlight_entry_id),
5372 DragTarget::Background => self.state.last_worktree_root_id,
5373 })
5374 {
5375 // Highlight if same entry or it's children
5376 if entry_id == highlight_entry_id {
5377 true
5378 } else {
5379 maybe!({
5380 let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
5381 let highlight_entry = worktree.read(cx).entry_for_id(highlight_entry_id)?;
5382 Some(path.starts_with(&highlight_entry.path))
5383 })
5384 .unwrap_or(false)
5385 }
5386 } else {
5387 false
5388 }
5389 };
5390 let git_indicator = settings
5391 .git_status_indicator
5392 .then(|| git_status_indicator(details.git_status))
5393 .flatten();
5394
5395 let id: ElementId = if is_sticky {
5396 SharedString::from(format!("project_panel_sticky_item_{}", entry_id.to_usize())).into()
5397 } else {
5398 (entry_id.to_proto() as usize).into()
5399 };
5400
5401 div()
5402 .id(id.clone())
5403 .relative()
5404 .group(GROUP_NAME)
5405 .cursor_pointer()
5406 .rounded_none()
5407 .bg(bg_color)
5408 .border_1()
5409 .border_r_2()
5410 .border_color(border_color)
5411 .hover(|style| style.bg(bg_hover_color).border_color(border_hover_color))
5412 .when(is_sticky, |this| this.block_mouse_except_scroll())
5413 .when(!is_sticky, |this| {
5414 this.when(
5415 is_highlighted && folded_directory_drag_target.is_none(),
5416 |this| {
5417 this.border_color(transparent_white())
5418 .bg(item_colors.drag_over)
5419 },
5420 )
5421 .when(settings.drag_and_drop, |this| {
5422 this.on_drag_move::<ExternalPaths>(cx.listener(
5423 move |this, event: &DragMoveEvent<ExternalPaths>, _, cx| {
5424 let is_current_target =
5425 this.drag_target_entry
5426 .as_ref()
5427 .and_then(|entry| match entry {
5428 DragTarget::Entry {
5429 entry_id: target_id,
5430 ..
5431 } => Some(*target_id),
5432 DragTarget::Background { .. } => None,
5433 })
5434 == Some(entry_id);
5435
5436 if !event.bounds.contains(&event.event.position) {
5437 // Entry responsible for setting drag target is also responsible to
5438 // clear it up after drag is out of bounds
5439 if is_current_target {
5440 this.drag_target_entry = None;
5441 }
5442 return;
5443 }
5444
5445 if is_current_target {
5446 return;
5447 }
5448
5449 this.marked_entries.clear();
5450
5451 let Some((entry_id, highlight_entry_id)) = maybe!({
5452 let target_worktree = this
5453 .project
5454 .read(cx)
5455 .worktree_for_id(selection.worktree_id, cx)?
5456 .read(cx);
5457 let target_entry =
5458 target_worktree.entry_for_path(&path_for_external_paths)?;
5459 let highlight_entry_id = this.highlight_entry_for_external_drag(
5460 target_entry,
5461 target_worktree,
5462 )?;
5463 Some((target_entry.id, highlight_entry_id))
5464 }) else {
5465 return;
5466 };
5467
5468 this.drag_target_entry = Some(DragTarget::Entry {
5469 entry_id,
5470 highlight_entry_id,
5471 });
5472 },
5473 ))
5474 .on_drop(cx.listener(
5475 move |this, external_paths: &ExternalPaths, window, cx| {
5476 this.drag_target_entry = None;
5477 this.hover_scroll_task.take();
5478 this.drop_external_files(external_paths.paths(), entry_id, window, cx);
5479 cx.stop_propagation();
5480 },
5481 ))
5482 .on_drag_move::<DraggedSelection>(cx.listener(
5483 move |this, event: &DragMoveEvent<DraggedSelection>, window, cx| {
5484 let is_current_target =
5485 this.drag_target_entry
5486 .as_ref()
5487 .and_then(|entry| match entry {
5488 DragTarget::Entry {
5489 entry_id: target_id,
5490 ..
5491 } => Some(*target_id),
5492 DragTarget::Background { .. } => None,
5493 })
5494 == Some(entry_id);
5495
5496 if !event.bounds.contains(&event.event.position) {
5497 // Entry responsible for setting drag target is also responsible to
5498 // clear it up after drag is out of bounds
5499 if is_current_target {
5500 this.drag_target_entry = None;
5501 }
5502 return;
5503 }
5504
5505 if is_current_target {
5506 return;
5507 }
5508
5509 let drag_state = event.drag(cx);
5510
5511 if drag_state.items().count() == 1 {
5512 this.marked_entries.clear();
5513 this.marked_entries.push(drag_state.active_selection);
5514 }
5515
5516 let Some((entry_id, highlight_entry_id)) = maybe!({
5517 let target_worktree = this
5518 .project
5519 .read(cx)
5520 .worktree_for_id(selection.worktree_id, cx)?
5521 .read(cx);
5522 let target_entry =
5523 target_worktree.entry_for_path(&path_for_dragged_selection)?;
5524 let highlight_entry_id = this.highlight_entry_for_selection_drag(
5525 target_entry,
5526 target_worktree,
5527 drag_state,
5528 cx,
5529 )?;
5530 Some((target_entry.id, highlight_entry_id))
5531 }) else {
5532 return;
5533 };
5534
5535 this.drag_target_entry = Some(DragTarget::Entry {
5536 entry_id,
5537 highlight_entry_id,
5538 });
5539
5540 this.hover_expand_task.take();
5541
5542 if !kind.is_dir()
5543 || this
5544 .state
5545 .expanded_dir_ids
5546 .get(&details.worktree_id)
5547 .is_some_and(|ids| ids.binary_search(&entry_id).is_ok())
5548 {
5549 return;
5550 }
5551
5552 let bounds = event.bounds;
5553 this.hover_expand_task =
5554 Some(cx.spawn_in(window, async move |this, cx| {
5555 cx.background_executor()
5556 .timer(Duration::from_millis(500))
5557 .await;
5558 this.update_in(cx, |this, window, cx| {
5559 this.hover_expand_task.take();
5560 if this.drag_target_entry.as_ref().and_then(|entry| {
5561 match entry {
5562 DragTarget::Entry {
5563 entry_id: target_id,
5564 ..
5565 } => Some(*target_id),
5566 DragTarget::Background { .. } => None,
5567 }
5568 }) == Some(entry_id)
5569 && bounds.contains(&window.mouse_position())
5570 {
5571 this.expand_entry(worktree_id, entry_id, cx);
5572 this.update_visible_entries(
5573 Some((worktree_id, entry_id)),
5574 false,
5575 false,
5576 window,
5577 cx,
5578 );
5579 cx.notify();
5580 }
5581 })
5582 .ok();
5583 }));
5584 },
5585 ))
5586 .on_drag(dragged_selection, {
5587 let active_component =
5588 self.state.ancestors.get(&entry_id).and_then(|ancestors| {
5589 ancestors.active_component(&details.filename)
5590 });
5591 move |selection, click_offset, _window, cx| {
5592 let filename = active_component
5593 .as_ref()
5594 .unwrap_or_else(|| &details.filename);
5595 cx.new(|_| DraggedProjectEntryView {
5596 icon: details.icon.clone(),
5597 filename: filename.clone(),
5598 click_offset,
5599 selection: selection.active_selection,
5600 selections: selection.marked_selections.clone(),
5601 })
5602 }
5603 })
5604 .on_drop(cx.listener(
5605 move |this, selections: &DraggedSelection, window, cx| {
5606 this.drag_target_entry = None;
5607 this.hover_scroll_task.take();
5608 this.hover_expand_task.take();
5609 if folded_directory_drag_target.is_some() {
5610 return;
5611 }
5612 this.drag_onto(selections, entry_id, kind.is_file(), window, cx);
5613 },
5614 ))
5615 })
5616 })
5617 .on_mouse_down(
5618 MouseButton::Left,
5619 cx.listener(move |this, _, _, cx| {
5620 this.mouse_down = true;
5621 cx.propagate();
5622 }),
5623 )
5624 .on_click(
5625 cx.listener(move |project_panel, event: &gpui::ClickEvent, window, cx| {
5626 if event.is_right_click() || event.first_focus() || show_editor {
5627 return;
5628 }
5629 if event.standard_click() {
5630 project_panel.mouse_down = false;
5631 }
5632 cx.stop_propagation();
5633
5634 if let Some(selection) =
5635 project_panel.selection.filter(|_| event.modifiers().shift)
5636 {
5637 let current_selection = project_panel.index_for_selection(selection);
5638 let clicked_entry = SelectedEntry {
5639 entry_id,
5640 worktree_id,
5641 };
5642 let target_selection = project_panel.index_for_selection(clicked_entry);
5643 if let Some(((_, _, source_index), (_, _, target_index))) =
5644 current_selection.zip(target_selection)
5645 {
5646 let range_start = source_index.min(target_index);
5647 let range_end = source_index.max(target_index) + 1;
5648 let mut new_selections = Vec::new();
5649 project_panel.for_each_visible_entry(
5650 range_start..range_end,
5651 window,
5652 cx,
5653 &mut |entry_id, details, _, _| {
5654 new_selections.push(SelectedEntry {
5655 entry_id,
5656 worktree_id: details.worktree_id,
5657 });
5658 },
5659 );
5660
5661 for selection in &new_selections {
5662 if !project_panel.marked_entries.contains(selection) {
5663 project_panel.marked_entries.push(*selection);
5664 }
5665 }
5666
5667 project_panel.selection = Some(clicked_entry);
5668 if !project_panel.marked_entries.contains(&clicked_entry) {
5669 project_panel.marked_entries.push(clicked_entry);
5670 }
5671 }
5672 } else if event.modifiers().secondary() {
5673 if event.click_count() > 1 {
5674 project_panel.split_entry(entry_id, false, None, cx);
5675 } else {
5676 project_panel.selection = Some(selection);
5677 if let Some(position) = project_panel
5678 .marked_entries
5679 .iter()
5680 .position(|e| *e == selection)
5681 {
5682 project_panel.marked_entries.remove(position);
5683 } else {
5684 project_panel.marked_entries.push(selection);
5685 }
5686 }
5687 } else if kind.is_dir() {
5688 project_panel.marked_entries.clear();
5689 if is_sticky
5690 && let Some((_, _, index)) =
5691 project_panel.index_for_entry(entry_id, worktree_id)
5692 {
5693 project_panel
5694 .scroll_handle
5695 .scroll_to_item_strict_with_offset(
5696 index,
5697 ScrollStrategy::Top,
5698 sticky_index.unwrap_or(0),
5699 );
5700 cx.notify();
5701 // move down by 1px so that clicked item
5702 // don't count as sticky anymore
5703 cx.on_next_frame(window, |_, window, cx| {
5704 cx.on_next_frame(window, |this, _, cx| {
5705 let mut offset = this.scroll_handle.offset();
5706 offset.y += px(1.);
5707 this.scroll_handle.set_offset(offset);
5708 cx.notify();
5709 });
5710 });
5711 return;
5712 }
5713 if event.modifiers().alt {
5714 project_panel.toggle_expand_all(entry_id, window, cx);
5715 } else {
5716 project_panel.toggle_expanded(entry_id, window, cx);
5717 }
5718 } else {
5719 let preview_tabs_enabled =
5720 PreviewTabsSettings::get_global(cx).enable_preview_from_project_panel;
5721 let click_count = event.click_count();
5722 let focus_opened_item = click_count > 1;
5723 let allow_preview = preview_tabs_enabled && click_count == 1;
5724 project_panel.open_entry(entry_id, focus_opened_item, allow_preview, cx);
5725 }
5726 }),
5727 )
5728 .child(
5729 ListItem::new(id)
5730 .indent_level(depth)
5731 .indent_step_size(px(settings.indent_size))
5732 .spacing(match settings.entry_spacing {
5733 ProjectPanelEntrySpacing::Comfortable => ListItemSpacing::Dense,
5734 ProjectPanelEntrySpacing::Standard => ListItemSpacing::ExtraDense,
5735 })
5736 .selectable(false)
5737 .when(
5738 canonical_path.is_some()
5739 || diagnostic_count.is_some()
5740 || git_indicator.is_some(),
5741 |this| {
5742 let symlink_element = canonical_path.map(|path| {
5743 div()
5744 .id("symlink_icon")
5745 .tooltip(move |_window, cx| {
5746 Tooltip::with_meta(
5747 path.to_string(),
5748 None,
5749 "Symbolic Link",
5750 cx,
5751 )
5752 })
5753 .child(
5754 Icon::new(IconName::ArrowUpRight)
5755 .size(IconSize::Indicator)
5756 .color(filename_text_color),
5757 )
5758 });
5759 this.end_slot::<AnyElement>(
5760 h_flex()
5761 .gap_1()
5762 .flex_none()
5763 .pr_3()
5764 .when_some(diagnostic_count, |this, count| {
5765 this.when(count.error_count > 0, |this| {
5766 this.child(
5767 Label::new(count.capped_error_count())
5768 .size(LabelSize::Small)
5769 .color(Color::Error),
5770 )
5771 })
5772 .when(
5773 count.warning_count > 0,
5774 |this| {
5775 this.child(
5776 Label::new(count.capped_warning_count())
5777 .size(LabelSize::Small)
5778 .color(Color::Warning),
5779 )
5780 },
5781 )
5782 })
5783 .when_some(git_indicator, |this, (label, color)| {
5784 let git_indicator = if kind.is_dir() {
5785 Indicator::dot()
5786 .color(Color::Custom(color.color(cx).opacity(0.5)))
5787 .into_any_element()
5788 } else {
5789 Label::new(label)
5790 .size(LabelSize::Small)
5791 .color(color)
5792 .into_any_element()
5793 };
5794
5795 this.child(git_indicator)
5796 })
5797 .when_some(symlink_element, |this, el| this.child(el))
5798 .into_any_element(),
5799 )
5800 },
5801 )
5802 .child(if let Some(icon) = &icon {
5803 if let Some((_, decoration_color)) =
5804 entry_diagnostic_aware_icon_decoration_and_color(diagnostic_severity)
5805 {
5806 let is_warning = diagnostic_severity
5807 .map(|severity| matches!(severity, DiagnosticSeverity::WARNING))
5808 .unwrap_or(false);
5809 div().child(
5810 DecoratedIcon::new(
5811 Icon::from_path(icon.clone()).color(Color::Muted),
5812 Some(
5813 IconDecoration::new(
5814 if kind.is_file() {
5815 if is_warning {
5816 IconDecorationKind::Triangle
5817 } else {
5818 IconDecorationKind::X
5819 }
5820 } else {
5821 IconDecorationKind::Dot
5822 },
5823 bg_color,
5824 cx,
5825 )
5826 .group_name(Some(GROUP_NAME.into()))
5827 .knockout_hover_color(bg_hover_color)
5828 .color(decoration_color.color(cx))
5829 .position(Point {
5830 x: px(-2.),
5831 y: px(-2.),
5832 }),
5833 ),
5834 )
5835 .into_any_element(),
5836 )
5837 } else {
5838 h_flex().child(Icon::from_path(icon.to_string()).color(Color::Muted))
5839 }
5840 } else if let Some((icon_name, color)) =
5841 entry_diagnostic_aware_icon_name_and_color(diagnostic_severity)
5842 {
5843 h_flex()
5844 .size(IconSize::default().rems())
5845 .child(Icon::new(icon_name).color(color).size(IconSize::Small))
5846 } else {
5847 h_flex()
5848 .size(IconSize::default().rems())
5849 .invisible()
5850 .flex_none()
5851 })
5852 .child(if show_editor {
5853 h_flex().h_6().w_full().child(self.filename_editor.clone())
5854 } else {
5855 h_flex()
5856 .h_6()
5857 .map(|this| match self.state.ancestors.get(&entry_id) {
5858 Some(folded_ancestors) => {
5859 this.children(self.render_folder_elements(
5860 folded_ancestors,
5861 entry_id,
5862 file_name,
5863 path_style,
5864 is_sticky,
5865 kind.is_file(),
5866 is_active || is_marked,
5867 settings.drag_and_drop,
5868 settings.bold_folder_labels,
5869 item_colors.drag_over,
5870 folded_directory_drag_target,
5871 filename_text_color,
5872 cx,
5873 ))
5874 }
5875
5876 None => this.child(
5877 Label::new(file_name)
5878 .single_line()
5879 .color(filename_text_color)
5880 .when(
5881 settings.bold_folder_labels && kind.is_dir(),
5882 |this| this.weight(FontWeight::SEMIBOLD),
5883 )
5884 .into_any_element(),
5885 ),
5886 })
5887 })
5888 .on_secondary_mouse_down(cx.listener(
5889 move |this, event: &MouseDownEvent, window, cx| {
5890 // Stop propagation to prevent the catch-all context menu for the project
5891 // panel from being deployed.
5892 cx.stop_propagation();
5893 // Some context menu actions apply to all marked entries. If the user
5894 // right-clicks on an entry that is not marked, they may not realize the
5895 // action applies to multiple entries. To avoid inadvertent changes, all
5896 // entries are unmarked.
5897 if !this.marked_entries.contains(&selection) {
5898 this.marked_entries.clear();
5899 }
5900 this.deploy_context_menu(event.position, entry_id, window, cx);
5901 },
5902 ))
5903 .overflow_x(),
5904 )
5905 .when_some(validation_color_and_message, |this, (color, message)| {
5906 this.relative().child(deferred(
5907 div()
5908 .occlude()
5909 .absolute()
5910 .top_full()
5911 .left(px(-1.)) // Used px over rem so that it doesn't change with font size
5912 .right(px(-0.5))
5913 .py_1()
5914 .px_2()
5915 .border_1()
5916 .border_color(color)
5917 .bg(cx.theme().colors().background)
5918 .child(
5919 Label::new(message)
5920 .color(Color::from(color))
5921 .size(LabelSize::Small),
5922 ),
5923 ))
5924 })
5925 }
5926
5927 fn render_folder_elements(
5928 &self,
5929 folded_ancestors: &FoldedAncestors,
5930 entry_id: ProjectEntryId,
5931 file_name: String,
5932 path_style: PathStyle,
5933 is_sticky: bool,
5934 is_file: bool,
5935 is_active_or_marked: bool,
5936 drag_and_drop_enabled: bool,
5937 bold_folder_labels: bool,
5938 drag_over_color: Hsla,
5939 folded_directory_drag_target: Option<FoldedDirectoryDragTarget>,
5940 filename_text_color: Color,
5941 cx: &Context<Self>,
5942 ) -> impl Iterator<Item = AnyElement> {
5943 let components = Path::new(&file_name)
5944 .components()
5945 .map(|comp| comp.as_os_str().to_string_lossy().into_owned())
5946 .collect::<Vec<_>>();
5947 let active_index = folded_ancestors.active_index();
5948 let components_len = components.len();
5949 let delimiter = SharedString::new(path_style.primary_separator());
5950
5951 let path_component_elements =
5952 components
5953 .into_iter()
5954 .enumerate()
5955 .map(move |(index, component)| {
5956 div()
5957 .id(SharedString::from(format!(
5958 "project_panel_path_component_{}_{index}",
5959 entry_id.to_usize()
5960 )))
5961 .when(index == 0, |this| this.ml_neg_0p5())
5962 .px_0p5()
5963 .rounded_xs()
5964 .hover(|style| style.bg(cx.theme().colors().element_active))
5965 .when(!is_sticky, |div| {
5966 div.when(index != components_len - 1, |div| {
5967 let target_entry_id = folded_ancestors
5968 .ancestors
5969 .get(components_len - 1 - index)
5970 .cloned();
5971 div.when(drag_and_drop_enabled, |div| {
5972 div.on_drag_move(cx.listener(
5973 move |this,
5974 event: &DragMoveEvent<DraggedSelection>,
5975 _,
5976 _| {
5977 if event.bounds.contains(&event.event.position) {
5978 this.folded_directory_drag_target =
5979 Some(FoldedDirectoryDragTarget {
5980 entry_id,
5981 index,
5982 is_delimiter_target: false,
5983 });
5984 } else {
5985 let is_current_target = this
5986 .folded_directory_drag_target
5987 .as_ref()
5988 .is_some_and(|target| {
5989 target.entry_id == entry_id
5990 && target.index == index
5991 && !target.is_delimiter_target
5992 });
5993 if is_current_target {
5994 this.folded_directory_drag_target = None;
5995 }
5996 }
5997 },
5998 ))
5999 .on_drop(cx.listener(
6000 move |this, selections: &DraggedSelection, window, cx| {
6001 this.hover_scroll_task.take();
6002 this.drag_target_entry = None;
6003 this.folded_directory_drag_target = None;
6004 if let Some(target_entry_id) = target_entry_id {
6005 this.drag_onto(
6006 selections,
6007 target_entry_id,
6008 is_file,
6009 window,
6010 cx,
6011 );
6012 }
6013 },
6014 ))
6015 .when(
6016 folded_directory_drag_target.is_some_and(|target| {
6017 target.entry_id == entry_id && target.index == index
6018 }),
6019 |this| this.bg(drag_over_color),
6020 )
6021 })
6022 })
6023 })
6024 .on_mouse_down(
6025 MouseButton::Left,
6026 cx.listener(move |this, _, _, cx| {
6027 if let Some(folds) = this.state.ancestors.get_mut(&entry_id) {
6028 if folds.set_active_index(index) {
6029 cx.notify();
6030 }
6031 }
6032 }),
6033 )
6034 .on_mouse_down(
6035 MouseButton::Right,
6036 cx.listener(move |this, _, _, cx| {
6037 if let Some(folds) = this.state.ancestors.get_mut(&entry_id) {
6038 if folds.set_active_index(index) {
6039 cx.notify();
6040 }
6041 }
6042 }),
6043 )
6044 .child(
6045 Label::new(component)
6046 .single_line()
6047 .color(filename_text_color)
6048 .when(bold_folder_labels && !is_file, |this| {
6049 this.weight(FontWeight::SEMIBOLD)
6050 })
6051 .when(index == active_index && is_active_or_marked, |this| {
6052 this.underline()
6053 }),
6054 )
6055 .into_any()
6056 });
6057
6058 let mut separator_index = 0;
6059 itertools::intersperse_with(path_component_elements, move || {
6060 separator_index += 1;
6061 self.render_entry_path_separator(
6062 entry_id,
6063 separator_index,
6064 components_len,
6065 is_sticky,
6066 is_file,
6067 drag_and_drop_enabled,
6068 filename_text_color,
6069 &delimiter,
6070 folded_ancestors,
6071 cx,
6072 )
6073 .into_any()
6074 })
6075 }
6076
6077 fn render_entry_path_separator(
6078 &self,
6079 entry_id: ProjectEntryId,
6080 index: usize,
6081 components_len: usize,
6082 is_sticky: bool,
6083 is_file: bool,
6084 drag_and_drop_enabled: bool,
6085 filename_text_color: Color,
6086 delimiter: &SharedString,
6087 folded_ancestors: &FoldedAncestors,
6088 cx: &Context<Self>,
6089 ) -> Div {
6090 let delimiter_target_index = index - 1;
6091 let target_entry_id = folded_ancestors
6092 .ancestors
6093 .get(components_len - 1 - delimiter_target_index)
6094 .cloned();
6095 div()
6096 .when(!is_sticky, |div| {
6097 div.when(drag_and_drop_enabled, |div| {
6098 div.on_drop(cx.listener(
6099 move |this, selections: &DraggedSelection, window, cx| {
6100 this.hover_scroll_task.take();
6101 this.drag_target_entry = None;
6102 this.folded_directory_drag_target = None;
6103 if let Some(target_entry_id) = target_entry_id {
6104 this.drag_onto(selections, target_entry_id, is_file, window, cx);
6105 }
6106 },
6107 ))
6108 .on_drag_move(cx.listener(
6109 move |this, event: &DragMoveEvent<DraggedSelection>, _, _| {
6110 if event.bounds.contains(&event.event.position) {
6111 this.folded_directory_drag_target =
6112 Some(FoldedDirectoryDragTarget {
6113 entry_id,
6114 index: delimiter_target_index,
6115 is_delimiter_target: true,
6116 });
6117 } else {
6118 let is_current_target =
6119 this.folded_directory_drag_target.is_some_and(|target| {
6120 target.entry_id == entry_id
6121 && target.index == delimiter_target_index
6122 && target.is_delimiter_target
6123 });
6124 if is_current_target {
6125 this.folded_directory_drag_target = None;
6126 }
6127 }
6128 },
6129 ))
6130 })
6131 })
6132 .child(
6133 Label::new(delimiter.clone())
6134 .single_line()
6135 .color(filename_text_color),
6136 )
6137 }
6138
6139 fn details_for_entry(
6140 &self,
6141 entry: &Entry,
6142 worktree_id: WorktreeId,
6143 root_name: &RelPath,
6144 entries_paths: &HashSet<Arc<RelPath>>,
6145 git_status: GitSummary,
6146 sticky: Option<StickyDetails>,
6147 _window: &mut Window,
6148 cx: &mut Context<Self>,
6149 ) -> EntryDetails {
6150 let (show_file_icons, show_folder_icons) = {
6151 let settings = ProjectPanelSettings::get_global(cx);
6152 (settings.file_icons, settings.folder_icons)
6153 };
6154
6155 let expanded_entry_ids = self
6156 .state
6157 .expanded_dir_ids
6158 .get(&worktree_id)
6159 .map(Vec::as_slice)
6160 .unwrap_or(&[]);
6161 let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok();
6162
6163 let icon = match entry.kind {
6164 EntryKind::File => {
6165 if show_file_icons {
6166 FileIcons::get_icon(entry.path.as_std_path(), cx)
6167 } else {
6168 None
6169 }
6170 }
6171 _ => {
6172 if show_folder_icons {
6173 FileIcons::get_folder_icon(is_expanded, entry.path.as_std_path(), cx)
6174 } else {
6175 FileIcons::get_chevron_icon(is_expanded, cx)
6176 }
6177 }
6178 };
6179
6180 let path_style = self.project.read(cx).path_style(cx);
6181 let (depth, difference) =
6182 ProjectPanel::calculate_depth_and_difference(entry, entries_paths);
6183
6184 let filename = if difference > 1 {
6185 entry
6186 .path
6187 .last_n_components(difference)
6188 .map_or(String::new(), |suffix| {
6189 suffix.display(path_style).to_string()
6190 })
6191 } else {
6192 entry
6193 .path
6194 .file_name()
6195 .map(|name| name.to_string())
6196 .unwrap_or_else(|| root_name.as_unix_str().to_string())
6197 };
6198
6199 let selection = SelectedEntry {
6200 worktree_id,
6201 entry_id: entry.id,
6202 };
6203 let is_marked = self.marked_entries.contains(&selection);
6204 let is_selected = self.selection == Some(selection);
6205
6206 let diagnostic_severity = self
6207 .diagnostics
6208 .get(&(worktree_id, entry.path.clone()))
6209 .cloned();
6210
6211 let diagnostic_count = self
6212 .diagnostic_counts
6213 .get(&(worktree_id, entry.path.clone()))
6214 .copied();
6215
6216 let filename_text_color =
6217 entry_git_aware_label_color(git_status, entry.is_ignored, is_marked);
6218
6219 let is_cut = self
6220 .clipboard
6221 .as_ref()
6222 .is_some_and(|e| e.is_cut() && e.items().contains(&selection));
6223
6224 EntryDetails {
6225 filename,
6226 icon,
6227 path: entry.path.clone(),
6228 depth,
6229 kind: entry.kind,
6230 is_ignored: entry.is_ignored,
6231 is_expanded,
6232 is_selected,
6233 is_marked,
6234 is_editing: false,
6235 is_processing: false,
6236 is_cut,
6237 sticky,
6238 filename_text_color,
6239 diagnostic_severity,
6240 diagnostic_count,
6241 git_status,
6242 is_private: entry.is_private,
6243 worktree_id,
6244 canonical_path: entry.canonical_path.clone(),
6245 }
6246 }
6247
6248 fn dispatch_context(&self, window: &Window, cx: &Context<Self>) -> KeyContext {
6249 let mut dispatch_context = KeyContext::new_with_defaults();
6250 dispatch_context.add("ProjectPanel");
6251 dispatch_context.add("menu");
6252
6253 let identifier = if self.filename_editor.focus_handle(cx).is_focused(window) {
6254 "editing"
6255 } else {
6256 "not_editing"
6257 };
6258
6259 dispatch_context.add(identifier);
6260 dispatch_context
6261 }
6262
6263 fn reveal_entry(
6264 &mut self,
6265 project: Entity<Project>,
6266 entry_id: ProjectEntryId,
6267 skip_ignored: bool,
6268 window: &mut Window,
6269 cx: &mut Context<Self>,
6270 ) -> Result<()> {
6271 let worktree = project
6272 .read(cx)
6273 .worktree_for_entry(entry_id, cx)
6274 .context("can't reveal a non-existent entry in the project panel")?;
6275 let worktree = worktree.read(cx);
6276 let worktree_id = worktree.id();
6277 let is_ignored = worktree
6278 .entry_for_id(entry_id)
6279 .is_none_or(|entry| entry.is_ignored && !entry.is_always_included);
6280 if skip_ignored && is_ignored {
6281 if self.index_for_entry(entry_id, worktree_id).is_none() {
6282 anyhow::bail!("can't reveal an ignored entry in the project panel");
6283 }
6284
6285 self.selection = Some(SelectedEntry {
6286 worktree_id,
6287 entry_id,
6288 });
6289 self.marked_entries.clear();
6290 self.marked_entries.push(SelectedEntry {
6291 worktree_id,
6292 entry_id,
6293 });
6294 self.autoscroll(cx);
6295 cx.notify();
6296 return Ok(());
6297 }
6298 let is_active_item_file_diff_view = self
6299 .workspace
6300 .upgrade()
6301 .and_then(|ws| ws.read(cx).active_item(cx))
6302 .map(|item| item.act_as_type(TypeId::of::<FileDiffView>(), cx).is_some())
6303 .unwrap_or(false);
6304 if is_active_item_file_diff_view {
6305 return Ok(());
6306 }
6307
6308 self.expand_entry(worktree_id, entry_id, cx);
6309 self.update_visible_entries(Some((worktree_id, entry_id)), false, true, window, cx);
6310 self.marked_entries.clear();
6311 self.marked_entries.push(SelectedEntry {
6312 worktree_id,
6313 entry_id,
6314 });
6315 cx.notify();
6316 Ok(())
6317 }
6318
6319 fn find_active_indent_guide(
6320 &self,
6321 indent_guides: &[IndentGuideLayout],
6322 cx: &App,
6323 ) -> Option<usize> {
6324 let (worktree, entry) = self.selected_entry(cx)?;
6325
6326 // Find the parent entry of the indent guide, this will either be the
6327 // expanded folder we have selected, or the parent of the currently
6328 // selected file/collapsed directory
6329 let mut entry = entry;
6330 loop {
6331 let is_expanded_dir = entry.is_dir()
6332 && self
6333 .state
6334 .expanded_dir_ids
6335 .get(&worktree.id())
6336 .map(|ids| ids.binary_search(&entry.id).is_ok())
6337 .unwrap_or(false);
6338 if is_expanded_dir {
6339 break;
6340 }
6341 entry = worktree.entry_for_path(&entry.path.parent()?)?;
6342 }
6343
6344 let (active_indent_range, depth) = {
6345 let (worktree_ix, child_offset, ix) = self.index_for_entry(entry.id, worktree.id())?;
6346 let child_paths = &self.state.visible_entries[worktree_ix].entries;
6347 let mut child_count = 0;
6348 let depth = entry.path.ancestors().count();
6349 while let Some(entry) = child_paths.get(child_offset + child_count + 1) {
6350 if entry.path.ancestors().count() <= depth {
6351 break;
6352 }
6353 child_count += 1;
6354 }
6355
6356 let start = ix + 1;
6357 let end = start + child_count;
6358
6359 let visible_worktree = &self.state.visible_entries[worktree_ix];
6360 let visible_worktree_entries = visible_worktree.index.get_or_init(|| {
6361 visible_worktree
6362 .entries
6363 .iter()
6364 .map(|e| e.path.clone())
6365 .collect()
6366 });
6367
6368 // Calculate the actual depth of the entry, taking into account that directories can be auto-folded.
6369 let (depth, _) = Self::calculate_depth_and_difference(entry, visible_worktree_entries);
6370 (start..end, depth)
6371 };
6372
6373 let candidates = indent_guides
6374 .iter()
6375 .enumerate()
6376 .filter(|(_, indent_guide)| indent_guide.offset.x == depth);
6377
6378 for (i, indent) in candidates {
6379 // Find matches that are either an exact match, partially on screen, or inside the enclosing indent
6380 if active_indent_range.start <= indent.offset.y + indent.length
6381 && indent.offset.y <= active_indent_range.end
6382 {
6383 return Some(i);
6384 }
6385 }
6386 None
6387 }
6388
6389 fn render_sticky_entries(
6390 &self,
6391 child: StickyProjectPanelCandidate,
6392 window: &mut Window,
6393 cx: &mut Context<Self>,
6394 ) -> SmallVec<[AnyElement; 8]> {
6395 let project = self.project.read(cx);
6396
6397 let Some((worktree_id, entry_ref)) = self.entry_at_index(child.index) else {
6398 return SmallVec::new();
6399 };
6400
6401 let Some(visible) = self
6402 .state
6403 .visible_entries
6404 .iter()
6405 .find(|worktree| worktree.worktree_id == worktree_id)
6406 else {
6407 return SmallVec::new();
6408 };
6409
6410 let Some(worktree) = project.worktree_for_id(worktree_id, cx) else {
6411 return SmallVec::new();
6412 };
6413 let worktree = worktree.read(cx).snapshot();
6414
6415 let paths = visible
6416 .index
6417 .get_or_init(|| visible.entries.iter().map(|e| e.path.clone()).collect());
6418
6419 let mut sticky_parents = Vec::new();
6420 let mut current_path = entry_ref.path.clone();
6421
6422 'outer: loop {
6423 if let Some(parent_path) = current_path.parent() {
6424 for ancestor_path in parent_path.ancestors() {
6425 if paths.contains(ancestor_path)
6426 && let Some(parent_entry) = worktree.entry_for_path(ancestor_path)
6427 {
6428 sticky_parents.push(parent_entry.clone());
6429 current_path = parent_entry.path.clone();
6430 continue 'outer;
6431 }
6432 }
6433 }
6434 break 'outer;
6435 }
6436
6437 if sticky_parents.is_empty() {
6438 return SmallVec::new();
6439 }
6440
6441 sticky_parents.reverse();
6442
6443 let panel_settings = ProjectPanelSettings::get_global(cx);
6444 let git_status_enabled = panel_settings.git_status;
6445 let root_name = worktree.root_name();
6446
6447 let git_summaries_by_id = if git_status_enabled {
6448 visible
6449 .entries
6450 .iter()
6451 .map(|e| (e.id, e.git_summary))
6452 .collect::<HashMap<_, _>>()
6453 } else {
6454 Default::default()
6455 };
6456
6457 // already checked if non empty above
6458 let last_item_index = sticky_parents.len() - 1;
6459 sticky_parents
6460 .iter()
6461 .enumerate()
6462 .map(|(index, entry)| {
6463 let git_status = git_summaries_by_id
6464 .get(&entry.id)
6465 .copied()
6466 .unwrap_or_default();
6467 let sticky_details = Some(StickyDetails {
6468 sticky_index: index,
6469 });
6470 let details = self.details_for_entry(
6471 entry,
6472 worktree_id,
6473 root_name,
6474 paths,
6475 git_status,
6476 sticky_details,
6477 window,
6478 cx,
6479 );
6480 self.render_entry(entry.id, details, window, cx)
6481 .when(index == last_item_index, |this| {
6482 let shadow_color_top = hsla(0.0, 0.0, 0.0, 0.1);
6483 let shadow_color_bottom = hsla(0.0, 0.0, 0.0, 0.);
6484 let sticky_shadow = div()
6485 .absolute()
6486 .left_0()
6487 .bottom_neg_1p5()
6488 .h_1p5()
6489 .w_full()
6490 .bg(linear_gradient(
6491 0.,
6492 linear_color_stop(shadow_color_top, 1.),
6493 linear_color_stop(shadow_color_bottom, 0.),
6494 ));
6495 this.child(sticky_shadow)
6496 })
6497 .into_any()
6498 })
6499 .collect()
6500 }
6501}
6502
6503#[derive(Clone)]
6504struct StickyProjectPanelCandidate {
6505 index: usize,
6506 depth: usize,
6507}
6508
6509impl StickyCandidate for StickyProjectPanelCandidate {
6510 fn depth(&self) -> usize {
6511 self.depth
6512 }
6513}
6514
6515fn item_width_estimate(depth: usize, item_text_chars: usize, is_symlink: bool) -> usize {
6516 const ICON_SIZE_FACTOR: usize = 2;
6517 let mut item_width = depth * ICON_SIZE_FACTOR + item_text_chars;
6518 if is_symlink {
6519 item_width += ICON_SIZE_FACTOR;
6520 }
6521 item_width
6522}
6523
6524impl Render for ProjectPanel {
6525 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
6526 let has_worktree = !self.state.visible_entries.is_empty();
6527 let project = self.project.read(cx);
6528 let panel_settings = ProjectPanelSettings::get_global(cx);
6529 let indent_size = panel_settings.indent_size;
6530 let show_indent_guides = panel_settings.indent_guides.show == ShowIndentGuides::Always;
6531 let horizontal_scroll = panel_settings.scrollbar.horizontal_scroll;
6532 let show_sticky_entries = {
6533 if panel_settings.sticky_scroll {
6534 let is_scrollable = self.scroll_handle.is_scrollable();
6535 let is_scrolled = self.scroll_handle.offset().y < px(0.);
6536 is_scrollable && is_scrolled
6537 } else {
6538 false
6539 }
6540 };
6541
6542 let is_local = project.is_local();
6543
6544 if has_worktree {
6545 let item_count = self
6546 .state
6547 .visible_entries
6548 .iter()
6549 .map(|worktree| worktree.entries.len())
6550 .sum();
6551
6552 fn handle_drag_move<T: 'static>(
6553 this: &mut ProjectPanel,
6554 e: &DragMoveEvent<T>,
6555 window: &mut Window,
6556 cx: &mut Context<ProjectPanel>,
6557 ) {
6558 if let Some(previous_position) = this.previous_drag_position {
6559 // Refresh cursor only when an actual drag happens,
6560 // because modifiers are not updated when the cursor is not moved.
6561 if e.event.position != previous_position {
6562 this.refresh_drag_cursor_style(&e.event.modifiers, window, cx);
6563 }
6564 }
6565 this.previous_drag_position = Some(e.event.position);
6566
6567 if !e.bounds.contains(&e.event.position) {
6568 this.drag_target_entry = None;
6569 return;
6570 }
6571 this.hover_scroll_task.take();
6572 let panel_height = e.bounds.size.height;
6573 if panel_height <= px(0.) {
6574 return;
6575 }
6576
6577 let event_offset = e.event.position.y - e.bounds.origin.y;
6578 // How far along in the project panel is our cursor? (0. is the top of a list, 1. is the bottom)
6579 let hovered_region_offset = event_offset / panel_height;
6580
6581 // We want the scrolling to be a bit faster when the cursor is closer to the edge of a list.
6582 // These pixels offsets were picked arbitrarily.
6583 let vertical_scroll_offset = if hovered_region_offset <= 0.05 {
6584 8.
6585 } else if hovered_region_offset <= 0.15 {
6586 5.
6587 } else if hovered_region_offset >= 0.95 {
6588 -8.
6589 } else if hovered_region_offset >= 0.85 {
6590 -5.
6591 } else {
6592 return;
6593 };
6594 let adjustment = point(px(0.), px(vertical_scroll_offset));
6595 this.hover_scroll_task = Some(cx.spawn_in(window, async move |this, cx| {
6596 loop {
6597 let should_stop_scrolling = this
6598 .update(cx, |this, cx| {
6599 this.hover_scroll_task.as_ref()?;
6600 let handle = this.scroll_handle.0.borrow_mut();
6601 let offset = handle.base_handle.offset();
6602
6603 handle.base_handle.set_offset(offset + adjustment);
6604 cx.notify();
6605 Some(())
6606 })
6607 .ok()
6608 .flatten()
6609 .is_some();
6610 if should_stop_scrolling {
6611 return;
6612 }
6613 cx.background_executor()
6614 .timer(Duration::from_millis(16))
6615 .await;
6616 }
6617 }));
6618 }
6619 h_flex()
6620 .id("project-panel")
6621 .group("project-panel")
6622 .when(panel_settings.drag_and_drop, |this| {
6623 this.on_drag_move(cx.listener(handle_drag_move::<ExternalPaths>))
6624 .on_drag_move(cx.listener(handle_drag_move::<DraggedSelection>))
6625 })
6626 .size_full()
6627 .relative()
6628 .on_modifiers_changed(cx.listener(
6629 |this, event: &ModifiersChangedEvent, window, cx| {
6630 this.refresh_drag_cursor_style(&event.modifiers, window, cx);
6631 },
6632 ))
6633 .key_context(self.dispatch_context(window, cx))
6634 .on_action(cx.listener(Self::scroll_up))
6635 .on_action(cx.listener(Self::scroll_down))
6636 .on_action(cx.listener(Self::scroll_cursor_center))
6637 .on_action(cx.listener(Self::scroll_cursor_top))
6638 .on_action(cx.listener(Self::scroll_cursor_bottom))
6639 .on_action(cx.listener(Self::select_next))
6640 .on_action(cx.listener(Self::select_previous))
6641 .on_action(cx.listener(Self::select_first))
6642 .on_action(cx.listener(Self::select_last))
6643 .on_action(cx.listener(Self::select_parent))
6644 .on_action(cx.listener(Self::select_next_git_entry))
6645 .on_action(cx.listener(Self::select_prev_git_entry))
6646 .on_action(cx.listener(Self::select_next_diagnostic))
6647 .on_action(cx.listener(Self::select_prev_diagnostic))
6648 .on_action(cx.listener(Self::select_next_directory))
6649 .on_action(cx.listener(Self::select_prev_directory))
6650 .on_action(cx.listener(Self::expand_selected_entry))
6651 .on_action(cx.listener(Self::collapse_selected_entry))
6652 .on_action(cx.listener(Self::collapse_all_entries))
6653 .on_action(cx.listener(Self::collapse_selected_entry_and_children))
6654 .on_action(cx.listener(Self::open))
6655 .on_action(cx.listener(Self::open_permanent))
6656 .on_action(cx.listener(Self::open_split_vertical))
6657 .on_action(cx.listener(Self::open_split_horizontal))
6658 .on_action(cx.listener(Self::confirm))
6659 .on_action(cx.listener(Self::cancel))
6660 .on_action(cx.listener(Self::copy_path))
6661 .on_action(cx.listener(Self::copy_relative_path))
6662 .on_action(cx.listener(Self::new_search_in_directory))
6663 .on_action(cx.listener(Self::unfold_directory))
6664 .on_action(cx.listener(Self::fold_directory))
6665 .on_action(cx.listener(Self::remove_from_project))
6666 .on_action(cx.listener(Self::compare_marked_files))
6667 .when(cx.has_flag::<ProjectPanelUndoRedoFeatureFlag>(), |el| {
6668 el.on_action(cx.listener(Self::undo))
6669 .on_action(cx.listener(Self::redo))
6670 })
6671 .when(!project.is_read_only(cx), |el| {
6672 el.on_action(cx.listener(Self::new_file))
6673 .on_action(cx.listener(Self::new_directory))
6674 .on_action(cx.listener(Self::rename))
6675 .on_action(cx.listener(Self::delete))
6676 .on_action(cx.listener(Self::cut))
6677 .on_action(cx.listener(Self::copy))
6678 .on_action(cx.listener(Self::paste))
6679 .on_action(cx.listener(Self::duplicate))
6680 .on_action(cx.listener(Self::restore_file))
6681 .when(!project.is_remote(), |el| {
6682 el.on_action(cx.listener(Self::trash))
6683 })
6684 })
6685 .when(
6686 project.is_local() || project.is_via_wsl_with_host_interop(cx),
6687 |el| {
6688 el.on_action(cx.listener(Self::reveal_in_finder))
6689 .on_action(cx.listener(Self::open_system))
6690 .on_action(cx.listener(Self::open_in_terminal))
6691 },
6692 )
6693 .when(project.is_via_remote_server(), |el| {
6694 el.on_action(cx.listener(Self::open_in_terminal))
6695 .on_action(cx.listener(Self::download_from_remote))
6696 })
6697 .track_focus(&self.focus_handle(cx))
6698 .child(
6699 v_flex()
6700 .child(
6701 uniform_list("entries", item_count, {
6702 cx.processor(|this, range: Range<usize>, window, cx| {
6703 this.rendered_entries_len = range.end - range.start;
6704 let mut items = Vec::with_capacity(this.rendered_entries_len);
6705 this.for_each_visible_entry(
6706 range,
6707 window,
6708 cx,
6709 &mut |id, details, window, cx| {
6710 items.push(this.render_entry(id, details, window, cx));
6711 },
6712 );
6713 items
6714 })
6715 })
6716 .when(show_indent_guides, |list| {
6717 list.with_decoration(
6718 ui::indent_guides(
6719 px(indent_size),
6720 IndentGuideColors::panel(cx),
6721 )
6722 .with_compute_indents_fn(
6723 cx.entity(),
6724 |this, range, window, cx| {
6725 let mut items =
6726 SmallVec::with_capacity(range.end - range.start);
6727 this.iter_visible_entries(
6728 range,
6729 window,
6730 cx,
6731 &mut |entry, _, entries, _, _| {
6732 let (depth, _) =
6733 Self::calculate_depth_and_difference(
6734 entry, entries,
6735 );
6736 items.push(depth);
6737 },
6738 );
6739 items
6740 },
6741 )
6742 .on_click(cx.listener(
6743 |this,
6744 active_indent_guide: &IndentGuideLayout,
6745 window,
6746 cx| {
6747 if window.modifiers().secondary() {
6748 let ix = active_indent_guide.offset.y;
6749 let Some((target_entry, worktree)) = maybe!({
6750 let (worktree_id, entry) =
6751 this.entry_at_index(ix)?;
6752 let worktree = this
6753 .project
6754 .read(cx)
6755 .worktree_for_id(worktree_id, cx)?;
6756 let target_entry = worktree
6757 .read(cx)
6758 .entry_for_path(&entry.path.parent()?)?;
6759 Some((target_entry, worktree))
6760 }) else {
6761 return;
6762 };
6763
6764 this.collapse_entry(
6765 target_entry.clone(),
6766 worktree,
6767 window,
6768 cx,
6769 );
6770 }
6771 },
6772 ))
6773 .with_render_fn(
6774 cx.entity(),
6775 move |this, params, _, cx| {
6776 const LEFT_OFFSET: Pixels = px(14.);
6777 const PADDING_Y: Pixels = px(4.);
6778 const HITBOX_OVERDRAW: Pixels = px(3.);
6779
6780 let active_indent_guide_index = this
6781 .find_active_indent_guide(
6782 ¶ms.indent_guides,
6783 cx,
6784 );
6785
6786 let indent_size = params.indent_size;
6787 let item_height = params.item_height;
6788
6789 params
6790 .indent_guides
6791 .into_iter()
6792 .enumerate()
6793 .map(|(idx, layout)| {
6794 let offset = if layout.continues_offscreen {
6795 px(0.)
6796 } else {
6797 PADDING_Y
6798 };
6799 let bounds = Bounds::new(
6800 point(
6801 layout.offset.x * indent_size
6802 + LEFT_OFFSET,
6803 layout.offset.y * item_height + offset,
6804 ),
6805 size(
6806 px(1.),
6807 layout.length * item_height
6808 - offset * 2.,
6809 ),
6810 );
6811 ui::RenderedIndentGuide {
6812 bounds,
6813 layout,
6814 is_active: Some(idx)
6815 == active_indent_guide_index,
6816 hitbox: Some(Bounds::new(
6817 point(
6818 bounds.origin.x - HITBOX_OVERDRAW,
6819 bounds.origin.y,
6820 ),
6821 size(
6822 bounds.size.width
6823 + HITBOX_OVERDRAW * 2.,
6824 bounds.size.height,
6825 ),
6826 )),
6827 }
6828 })
6829 .collect()
6830 },
6831 ),
6832 )
6833 })
6834 .when(show_sticky_entries, |list| {
6835 let sticky_items = ui::sticky_items(
6836 cx.entity(),
6837 |this, range, window, cx| {
6838 let mut items =
6839 SmallVec::with_capacity(range.end - range.start);
6840 this.iter_visible_entries(
6841 range,
6842 window,
6843 cx,
6844 &mut |entry, index, entries, _, _| {
6845 let (depth, _) =
6846 Self::calculate_depth_and_difference(
6847 entry, entries,
6848 );
6849 let candidate =
6850 StickyProjectPanelCandidate { index, depth };
6851 items.push(candidate);
6852 },
6853 );
6854 items
6855 },
6856 |this, marker_entry, window, cx| {
6857 let sticky_entries =
6858 this.render_sticky_entries(marker_entry, window, cx);
6859 this.sticky_items_count = sticky_entries.len();
6860 sticky_entries
6861 },
6862 );
6863 list.with_decoration(if show_indent_guides {
6864 sticky_items.with_decoration(
6865 ui::indent_guides(
6866 px(indent_size),
6867 IndentGuideColors::panel(cx),
6868 )
6869 .with_render_fn(
6870 cx.entity(),
6871 move |_, params, _, _| {
6872 const LEFT_OFFSET: Pixels = px(14.);
6873
6874 let indent_size = params.indent_size;
6875 let item_height = params.item_height;
6876
6877 params
6878 .indent_guides
6879 .into_iter()
6880 .map(|layout| {
6881 let bounds = Bounds::new(
6882 point(
6883 layout.offset.x * indent_size
6884 + LEFT_OFFSET,
6885 layout.offset.y * item_height,
6886 ),
6887 size(
6888 px(1.),
6889 layout.length * item_height,
6890 ),
6891 );
6892 ui::RenderedIndentGuide {
6893 bounds,
6894 layout,
6895 is_active: false,
6896 hitbox: None,
6897 }
6898 })
6899 .collect()
6900 },
6901 ),
6902 )
6903 } else {
6904 sticky_items
6905 })
6906 })
6907 .with_sizing_behavior(ListSizingBehavior::Infer)
6908 .with_horizontal_sizing_behavior(if horizontal_scroll {
6909 ListHorizontalSizingBehavior::Unconstrained
6910 } else {
6911 ListHorizontalSizingBehavior::FitList
6912 })
6913 .when(horizontal_scroll, |list| {
6914 list.with_width_from_item(self.state.max_width_item_index)
6915 })
6916 .track_scroll(&self.scroll_handle),
6917 )
6918 .child(
6919 div()
6920 .id("project-panel-blank-area")
6921 .block_mouse_except_scroll()
6922 .flex_grow()
6923 .on_scroll_wheel({
6924 let scroll_handle = self.scroll_handle.clone();
6925 let entity_id = cx.entity().entity_id();
6926 move |event, window, cx| {
6927 let state = scroll_handle.0.borrow();
6928 let base_handle = &state.base_handle;
6929 let current_offset = base_handle.offset();
6930 let max_offset = base_handle.max_offset();
6931 let delta = event.delta.pixel_delta(window.line_height());
6932 let new_offset = (current_offset + delta)
6933 .clamp(&max_offset.neg(), &Point::default());
6934
6935 if new_offset != current_offset {
6936 base_handle.set_offset(new_offset);
6937 cx.notify(entity_id);
6938 }
6939 }
6940 })
6941 .when(
6942 self.drag_target_entry.as_ref().is_some_and(
6943 |entry| match entry {
6944 DragTarget::Background => true,
6945 DragTarget::Entry {
6946 highlight_entry_id, ..
6947 } => self.state.last_worktree_root_id.is_some_and(
6948 |root_id| *highlight_entry_id == root_id,
6949 ),
6950 },
6951 ),
6952 |div| div.bg(cx.theme().colors().drop_target_background),
6953 )
6954 .on_drag_move::<ExternalPaths>(cx.listener(
6955 move |this, event: &DragMoveEvent<ExternalPaths>, _, _| {
6956 let Some(_last_root_id) = this.state.last_worktree_root_id
6957 else {
6958 return;
6959 };
6960 if event.bounds.contains(&event.event.position) {
6961 this.drag_target_entry = Some(DragTarget::Background);
6962 } else {
6963 if this.drag_target_entry.as_ref().is_some_and(|e| {
6964 matches!(e, DragTarget::Background)
6965 }) {
6966 this.drag_target_entry = None;
6967 }
6968 }
6969 },
6970 ))
6971 .on_drag_move::<DraggedSelection>(cx.listener(
6972 move |this, event: &DragMoveEvent<DraggedSelection>, _, cx| {
6973 let Some(last_root_id) = this.state.last_worktree_root_id
6974 else {
6975 return;
6976 };
6977 if event.bounds.contains(&event.event.position) {
6978 let drag_state = event.drag(cx);
6979 if this.should_highlight_background_for_selection_drag(
6980 &drag_state,
6981 last_root_id,
6982 cx,
6983 ) {
6984 this.drag_target_entry =
6985 Some(DragTarget::Background);
6986 }
6987 } else {
6988 if this.drag_target_entry.as_ref().is_some_and(|e| {
6989 matches!(e, DragTarget::Background)
6990 }) {
6991 this.drag_target_entry = None;
6992 }
6993 }
6994 },
6995 ))
6996 .on_drop(cx.listener(
6997 move |this, external_paths: &ExternalPaths, window, cx| {
6998 this.drag_target_entry = None;
6999 this.hover_scroll_task.take();
7000 if let Some(entry_id) = this.state.last_worktree_root_id {
7001 this.drop_external_files(
7002 external_paths.paths(),
7003 entry_id,
7004 window,
7005 cx,
7006 );
7007 }
7008 cx.stop_propagation();
7009 },
7010 ))
7011 .on_drop(cx.listener(
7012 move |this, selections: &DraggedSelection, window, cx| {
7013 this.drag_target_entry = None;
7014 this.hover_scroll_task.take();
7015 if let Some(entry_id) = this.state.last_worktree_root_id {
7016 this.drag_onto(selections, entry_id, false, window, cx);
7017 }
7018 cx.stop_propagation();
7019 },
7020 ))
7021 .on_click(cx.listener(|this, event, window, cx| {
7022 if matches!(event, gpui::ClickEvent::Keyboard(_)) {
7023 return;
7024 }
7025 cx.stop_propagation();
7026 this.selection = None;
7027 this.marked_entries.clear();
7028 this.focus_handle(cx).focus(window, cx);
7029 }))
7030 .on_mouse_down(
7031 MouseButton::Right,
7032 cx.listener(move |this, event: &MouseDownEvent, window, cx| {
7033 // When deploying the context menu anywhere below the last project entry,
7034 // act as if the user clicked the root of the last worktree.
7035 if let Some(entry_id) = this.state.last_worktree_root_id {
7036 this.deploy_context_menu(
7037 event.position,
7038 entry_id,
7039 window,
7040 cx,
7041 );
7042 }
7043 }),
7044 )
7045 .when(!project.is_read_only(cx), |el| {
7046 el.on_click(cx.listener(
7047 |this, event: &gpui::ClickEvent, window, cx| {
7048 if event.click_count() > 1
7049 && let Some(entry_id) =
7050 this.state.last_worktree_root_id
7051 {
7052 let project = this.project.read(cx);
7053
7054 let worktree_id = if let Some(worktree) =
7055 project.worktree_for_entry(entry_id, cx)
7056 {
7057 worktree.read(cx).id()
7058 } else {
7059 return;
7060 };
7061
7062 this.selection = Some(SelectedEntry {
7063 worktree_id,
7064 entry_id,
7065 });
7066
7067 this.new_file(&NewFile, window, cx);
7068 }
7069 },
7070 ))
7071 }),
7072 )
7073 .size_full(),
7074 )
7075 .custom_scrollbars(
7076 {
7077 let mut scrollbars =
7078 Scrollbars::for_settings::<ProjectPanelScrollbarProxy>()
7079 .tracked_scroll_handle(&self.scroll_handle);
7080 if horizontal_scroll {
7081 scrollbars = scrollbars.with_track_along(
7082 ScrollAxes::Horizontal,
7083 cx.theme().colors().panel_background,
7084 );
7085 }
7086 scrollbars.notify_content()
7087 },
7088 window,
7089 cx,
7090 )
7091 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
7092 deferred(
7093 anchored()
7094 .position(*position)
7095 .anchor(gpui::Corner::TopLeft)
7096 .child(menu.clone()),
7097 )
7098 .with_priority(3)
7099 }))
7100 } else {
7101 let focus_handle = self.focus_handle(cx);
7102
7103 v_flex()
7104 .id("empty-project_panel")
7105 .p_4()
7106 .size_full()
7107 .items_center()
7108 .justify_center()
7109 .gap_1()
7110 .track_focus(&self.focus_handle(cx))
7111 .child(
7112 Button::new("open_project", "Open Project")
7113 .full_width()
7114 .key_binding(KeyBinding::for_action_in(
7115 &workspace::Open::default(),
7116 &focus_handle,
7117 cx,
7118 ))
7119 .on_click(cx.listener(|this, _, window, cx| {
7120 this.workspace
7121 .update(cx, |_, cx| {
7122 window.dispatch_action(
7123 workspace::Open::default().boxed_clone(),
7124 cx,
7125 );
7126 })
7127 .log_err();
7128 })),
7129 )
7130 .child(
7131 h_flex()
7132 .w_1_2()
7133 .gap_2()
7134 .child(Divider::horizontal())
7135 .child(Label::new("or").size(LabelSize::XSmall).color(Color::Muted))
7136 .child(Divider::horizontal()),
7137 )
7138 .child(
7139 Button::new("clone_repo", "Clone Repository")
7140 .full_width()
7141 .on_click(cx.listener(|this, _, window, cx| {
7142 this.workspace
7143 .update(cx, |_, cx| {
7144 window.dispatch_action(git::Clone.boxed_clone(), cx);
7145 })
7146 .log_err();
7147 })),
7148 )
7149 .when(is_local, |div| {
7150 div.when(panel_settings.drag_and_drop, |div| {
7151 div.drag_over::<ExternalPaths>(|style, _, _, cx| {
7152 style.bg(cx.theme().colors().drop_target_background)
7153 })
7154 .on_drop(cx.listener(
7155 move |this, external_paths: &ExternalPaths, window, cx| {
7156 this.drag_target_entry = None;
7157 this.hover_scroll_task.take();
7158 if let Some(task) = this
7159 .workspace
7160 .update(cx, |workspace, cx| {
7161 workspace.open_workspace_for_paths(
7162 OpenMode::Activate,
7163 external_paths.paths().to_owned(),
7164 window,
7165 cx,
7166 )
7167 })
7168 .log_err()
7169 {
7170 task.detach_and_log_err(cx);
7171 }
7172 cx.stop_propagation();
7173 },
7174 ))
7175 })
7176 })
7177 }
7178 }
7179}
7180
7181impl Render for DraggedProjectEntryView {
7182 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
7183 let ui_font = ThemeSettings::get_global(cx).ui_font.clone();
7184 h_flex()
7185 .font(ui_font)
7186 .pl(self.click_offset.x + px(12.))
7187 .pt(self.click_offset.y + px(12.))
7188 .child(
7189 div()
7190 .flex()
7191 .gap_1()
7192 .items_center()
7193 .py_1()
7194 .px_2()
7195 .rounded_lg()
7196 .bg(cx.theme().colors().background)
7197 .map(|this| {
7198 if self.selections.len() > 1 && self.selections.contains(&self.selection) {
7199 this.child(Label::new(format!("{} entries", self.selections.len())))
7200 } else {
7201 this.child(if let Some(icon) = &self.icon {
7202 div().child(Icon::from_path(icon.clone()))
7203 } else {
7204 div()
7205 })
7206 .child(Label::new(self.filename.clone()))
7207 }
7208 }),
7209 )
7210 }
7211}
7212
7213impl EventEmitter<Event> for ProjectPanel {}
7214
7215impl EventEmitter<PanelEvent> for ProjectPanel {}
7216
7217impl Panel for ProjectPanel {
7218 fn position(&self, _: &Window, cx: &App) -> DockPosition {
7219 match ProjectPanelSettings::get_global(cx).dock {
7220 DockSide::Left => DockPosition::Left,
7221 DockSide::Right => DockPosition::Right,
7222 }
7223 }
7224
7225 fn position_is_valid(&self, position: DockPosition) -> bool {
7226 matches!(position, DockPosition::Left | DockPosition::Right)
7227 }
7228
7229 fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
7230 settings::update_settings_file(self.fs.clone(), cx, move |settings, _| {
7231 let dock = match position {
7232 DockPosition::Left | DockPosition::Bottom => DockSide::Left,
7233 DockPosition::Right => DockSide::Right,
7234 };
7235 settings.project_panel.get_or_insert_default().dock = Some(dock);
7236 });
7237 }
7238
7239 fn default_size(&self, _: &Window, cx: &App) -> Pixels {
7240 ProjectPanelSettings::get_global(cx).default_width
7241 }
7242
7243 fn icon(&self, _: &Window, cx: &App) -> Option<IconName> {
7244 ProjectPanelSettings::get_global(cx)
7245 .button
7246 .then_some(IconName::FileTree)
7247 }
7248
7249 fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
7250 Some("Project Panel")
7251 }
7252
7253 fn toggle_action(&self) -> Box<dyn Action> {
7254 Box::new(ToggleFocus)
7255 }
7256
7257 fn persistent_name() -> &'static str {
7258 "Project Panel"
7259 }
7260
7261 fn panel_key() -> &'static str {
7262 PROJECT_PANEL_KEY
7263 }
7264
7265 fn starts_open(&self, _: &Window, cx: &App) -> bool {
7266 if !ProjectPanelSettings::get_global(cx).starts_open {
7267 return false;
7268 }
7269
7270 let project = &self.project.read(cx);
7271 project.visible_worktrees(cx).any(|tree| {
7272 tree.read(cx)
7273 .root_entry()
7274 .is_some_and(|entry| entry.is_dir())
7275 })
7276 }
7277
7278 fn activation_priority(&self) -> u32 {
7279 1
7280 }
7281}
7282
7283impl Focusable for ProjectPanel {
7284 fn focus_handle(&self, _cx: &App) -> FocusHandle {
7285 self.focus_handle.clone()
7286 }
7287}
7288
7289impl ClipboardEntry {
7290 fn is_cut(&self) -> bool {
7291 matches!(self, Self::Cut { .. })
7292 }
7293
7294 fn items(&self) -> &BTreeSet<SelectedEntry> {
7295 match self {
7296 ClipboardEntry::Copied(entries) | ClipboardEntry::Cut(entries) => entries,
7297 }
7298 }
7299
7300 fn into_copy_entry(self) -> Self {
7301 match self {
7302 ClipboardEntry::Copied(_) => self,
7303 ClipboardEntry::Cut(entries) => ClipboardEntry::Copied(entries),
7304 }
7305 }
7306}
7307
7308#[inline]
7309fn cmp_worktree_entries(
7310 a: &Entry,
7311 b: &Entry,
7312 mode: &settings::ProjectPanelSortMode,
7313 order: &settings::ProjectPanelSortOrder,
7314) -> cmp::Ordering {
7315 let a = (&*a.path, a.is_file());
7316 let b = (&*b.path, b.is_file());
7317 util::paths::compare_rel_paths_by(a, b, (*mode).into(), (*order).into())
7318}
7319
7320pub fn sort_worktree_entries(
7321 entries: &mut [impl AsRef<Entry>],
7322 mode: settings::ProjectPanelSortMode,
7323 order: settings::ProjectPanelSortOrder,
7324) {
7325 entries.sort_by(|lhs, rhs| cmp_worktree_entries(lhs.as_ref(), rhs.as_ref(), &mode, &order));
7326}
7327
7328pub fn par_sort_worktree_entries(
7329 entries: &mut Vec<GitEntry>,
7330 mode: settings::ProjectPanelSortMode,
7331 order: settings::ProjectPanelSortOrder,
7332) {
7333 entries.par_sort_by(|lhs, rhs| cmp_worktree_entries(lhs, rhs, &mode, &order));
7334}
7335
7336fn git_status_indicator(git_status: GitSummary) -> Option<(&'static str, Color)> {
7337 if git_status.conflict > 0 {
7338 return Some(("!", Color::Conflict));
7339 }
7340 if git_status.untracked > 0 {
7341 return Some(("U", Color::Created));
7342 }
7343 if git_status.worktree.deleted > 0 {
7344 return Some(("D", Color::Deleted));
7345 }
7346 if git_status.worktree.modified > 0 {
7347 return Some(("M", Color::Warning));
7348 }
7349 if git_status.index.deleted > 0 {
7350 return Some(("D", Color::Deleted));
7351 }
7352 if git_status.index.modified > 0 {
7353 return Some(("M", Color::Modified));
7354 }
7355 if git_status.index.added > 0 {
7356 return Some(("A", Color::Created));
7357 }
7358 None
7359}
7360
7361#[cfg(test)]
7362mod project_panel_tests;
7363mod tests;