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