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