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