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