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