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