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