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