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((worktree, entry)) = self.selected_sub_entry(cx) {
3407 let path = worktree.read(cx).absolutize(&entry.path);
3408 self.project
3409 .update(cx, |project, cx| project.reveal_path(&path, cx));
3410 }
3411 }
3412
3413 fn remove_from_project(
3414 &mut self,
3415 _: &RemoveFromProject,
3416 _window: &mut Window,
3417 cx: &mut Context<Self>,
3418 ) {
3419 for entry in self.effective_entries().iter() {
3420 let worktree_id = entry.worktree_id;
3421 self.project
3422 .update(cx, |project, cx| project.remove_worktree(worktree_id, cx));
3423 }
3424 }
3425
3426 fn file_abs_paths_to_diff(&self, cx: &Context<Self>) -> Option<(PathBuf, PathBuf)> {
3427 let mut selections_abs_path = self
3428 .marked_entries
3429 .iter()
3430 .filter_map(|entry| {
3431 let project = self.project.read(cx);
3432 let worktree = project.worktree_for_id(entry.worktree_id, cx)?;
3433 let entry = worktree.read(cx).entry_for_id(entry.entry_id)?;
3434 if !entry.is_file() {
3435 return None;
3436 }
3437 Some(worktree.read(cx).absolutize(&entry.path))
3438 })
3439 .rev();
3440
3441 let last_path = selections_abs_path.next()?;
3442 let previous_to_last = selections_abs_path.next()?;
3443 Some((previous_to_last, last_path))
3444 }
3445
3446 fn compare_marked_files(
3447 &mut self,
3448 _: &CompareMarkedFiles,
3449 window: &mut Window,
3450 cx: &mut Context<Self>,
3451 ) {
3452 let selected_files = self.file_abs_paths_to_diff(cx);
3453 if let Some((file_path1, file_path2)) = selected_files {
3454 self.workspace
3455 .update(cx, |workspace, cx| {
3456 FileDiffView::open(file_path1, file_path2, workspace.weak_handle(), window, cx)
3457 .detach_and_log_err(cx);
3458 })
3459 .ok();
3460 }
3461 }
3462
3463 fn open_system(&mut self, _: &OpenWithSystem, _: &mut Window, cx: &mut Context<Self>) {
3464 if let Some((worktree, entry)) = self.selected_entry(cx) {
3465 let abs_path = worktree.absolutize(&entry.path);
3466 cx.open_with_system(&abs_path);
3467 }
3468 }
3469
3470 fn open_in_terminal(
3471 &mut self,
3472 _: &OpenInTerminal,
3473 window: &mut Window,
3474 cx: &mut Context<Self>,
3475 ) {
3476 if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
3477 let abs_path = match &entry.canonical_path {
3478 Some(canonical_path) => canonical_path.to_path_buf(),
3479 None => worktree.read(cx).absolutize(&entry.path),
3480 };
3481
3482 let working_directory = if entry.is_dir() {
3483 Some(abs_path)
3484 } else {
3485 abs_path.parent().map(|path| path.to_path_buf())
3486 };
3487 if let Some(working_directory) = working_directory {
3488 window.dispatch_action(
3489 workspace::OpenTerminal {
3490 working_directory,
3491 local: false,
3492 }
3493 .boxed_clone(),
3494 cx,
3495 )
3496 }
3497 }
3498 }
3499
3500 pub fn new_search_in_directory(
3501 &mut self,
3502 _: &NewSearchInDirectory,
3503 window: &mut Window,
3504 cx: &mut Context<Self>,
3505 ) {
3506 if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
3507 let dir_path = if entry.is_dir() {
3508 entry.path.clone()
3509 } else {
3510 // entry is a file, use its parent directory
3511 match entry.path.parent() {
3512 Some(parent) => Arc::from(parent),
3513 None => {
3514 // File at root, open search with empty filter
3515 self.workspace
3516 .update(cx, |workspace, cx| {
3517 search::ProjectSearchView::new_search_in_directory(
3518 workspace,
3519 RelPath::empty(),
3520 window,
3521 cx,
3522 );
3523 })
3524 .ok();
3525 return;
3526 }
3527 }
3528 };
3529
3530 let include_root = self.project.read(cx).visible_worktrees(cx).count() > 1;
3531 let dir_path = if include_root {
3532 worktree.read(cx).root_name().join(&dir_path)
3533 } else {
3534 dir_path
3535 };
3536
3537 self.workspace
3538 .update(cx, |workspace, cx| {
3539 search::ProjectSearchView::new_search_in_directory(
3540 workspace, &dir_path, window, cx,
3541 );
3542 })
3543 .ok();
3544 }
3545 }
3546
3547 fn move_entry(
3548 &mut self,
3549 entry_to_move: ProjectEntryId,
3550 destination: ProjectEntryId,
3551 destination_is_file: bool,
3552 cx: &mut Context<Self>,
3553 ) -> Option<Task<Result<CreatedEntry>>> {
3554 if self
3555 .project
3556 .read(cx)
3557 .entry_is_worktree_root(entry_to_move, cx)
3558 {
3559 self.move_worktree_root(entry_to_move, destination, cx);
3560 None
3561 } else {
3562 self.move_worktree_entry(entry_to_move, destination, destination_is_file, cx)
3563 }
3564 }
3565
3566 fn move_worktree_root(
3567 &mut self,
3568 entry_to_move: ProjectEntryId,
3569 destination: ProjectEntryId,
3570 cx: &mut Context<Self>,
3571 ) {
3572 self.project.update(cx, |project, cx| {
3573 let Some(worktree_to_move) = project.worktree_for_entry(entry_to_move, cx) else {
3574 return;
3575 };
3576 let Some(destination_worktree) = project.worktree_for_entry(destination, cx) else {
3577 return;
3578 };
3579
3580 let worktree_id = worktree_to_move.read(cx).id();
3581 let destination_id = destination_worktree.read(cx).id();
3582
3583 project
3584 .move_worktree(worktree_id, destination_id, cx)
3585 .log_err();
3586 });
3587 }
3588
3589 fn move_worktree_entry(
3590 &mut self,
3591 entry_to_move: ProjectEntryId,
3592 destination_entry: ProjectEntryId,
3593 destination_is_file: bool,
3594 cx: &mut Context<Self>,
3595 ) -> Option<Task<Result<CreatedEntry>>> {
3596 if entry_to_move == destination_entry {
3597 return None;
3598 }
3599
3600 let (destination_worktree, rename_task) = self.project.update(cx, |project, cx| {
3601 let Some(source_path) = project.path_for_entry(entry_to_move, cx) else {
3602 return (None, None);
3603 };
3604 let Some(destination_path) = project.path_for_entry(destination_entry, cx) else {
3605 return (None, None);
3606 };
3607 let destination_worktree_id = destination_path.worktree_id;
3608
3609 let destination_dir = if destination_is_file {
3610 destination_path.path.parent().unwrap_or(RelPath::empty())
3611 } else {
3612 destination_path.path.as_ref()
3613 };
3614
3615 let Some(source_name) = source_path.path.file_name() else {
3616 return (None, None);
3617 };
3618 let Ok(source_name) = RelPath::unix(source_name) else {
3619 return (None, None);
3620 };
3621
3622 let mut new_path = destination_dir.to_rel_path_buf();
3623 new_path.push(source_name);
3624 let rename_task = (new_path.as_rel_path() != source_path.path.as_ref()).then(|| {
3625 project.rename_entry(
3626 entry_to_move,
3627 (destination_worktree_id, new_path).into(),
3628 cx,
3629 )
3630 });
3631
3632 (
3633 project.worktree_id_for_entry(destination_entry, cx),
3634 rename_task,
3635 )
3636 });
3637
3638 if let Some(destination_worktree) = destination_worktree {
3639 self.expand_entry(destination_worktree, destination_entry, cx);
3640 }
3641 rename_task
3642 }
3643
3644 fn index_for_selection(&self, selection: SelectedEntry) -> Option<(usize, usize, usize)> {
3645 self.index_for_entry(selection.entry_id, selection.worktree_id)
3646 }
3647
3648 fn disjoint_effective_entries(&self, cx: &App) -> BTreeSet<SelectedEntry> {
3649 self.disjoint_entries(self.effective_entries(), cx)
3650 }
3651
3652 fn disjoint_entries(
3653 &self,
3654 entries: BTreeSet<SelectedEntry>,
3655 cx: &App,
3656 ) -> BTreeSet<SelectedEntry> {
3657 let mut sanitized_entries = BTreeSet::new();
3658 if entries.is_empty() {
3659 return sanitized_entries;
3660 }
3661
3662 let project = self.project.read(cx);
3663 let entries_by_worktree: HashMap<WorktreeId, Vec<SelectedEntry>> = entries
3664 .into_iter()
3665 .filter(|entry| !project.entry_is_worktree_root(entry.entry_id, cx))
3666 .fold(HashMap::default(), |mut map, entry| {
3667 map.entry(entry.worktree_id).or_default().push(entry);
3668 map
3669 });
3670
3671 for (worktree_id, worktree_entries) in entries_by_worktree {
3672 if let Some(worktree) = project.worktree_for_id(worktree_id, cx) {
3673 let worktree = worktree.read(cx);
3674 let dir_paths = worktree_entries
3675 .iter()
3676 .filter_map(|entry| {
3677 worktree.entry_for_id(entry.entry_id).and_then(|entry| {
3678 if entry.is_dir() {
3679 Some(entry.path.as_ref())
3680 } else {
3681 None
3682 }
3683 })
3684 })
3685 .collect::<BTreeSet<_>>();
3686
3687 sanitized_entries.extend(worktree_entries.into_iter().filter(|entry| {
3688 let Some(entry_info) = worktree.entry_for_id(entry.entry_id) else {
3689 return false;
3690 };
3691 let entry_path = entry_info.path.as_ref();
3692 let inside_selected_dir = dir_paths.iter().any(|&dir_path| {
3693 entry_path != dir_path && entry_path.starts_with(dir_path)
3694 });
3695 !inside_selected_dir
3696 }));
3697 }
3698 }
3699
3700 sanitized_entries
3701 }
3702
3703 fn effective_entries(&self) -> BTreeSet<SelectedEntry> {
3704 if let Some(selection) = self.selection {
3705 let selection = SelectedEntry {
3706 entry_id: self.resolve_entry(selection.entry_id),
3707 worktree_id: selection.worktree_id,
3708 };
3709
3710 // Default to using just the selected item when nothing is marked.
3711 if self.marked_entries.is_empty() {
3712 return BTreeSet::from([selection]);
3713 }
3714
3715 // Allow operating on the selected item even when something else is marked,
3716 // making it easier to perform one-off actions without clearing a mark.
3717 if self.marked_entries.len() == 1 && !self.marked_entries.contains(&selection) {
3718 return BTreeSet::from([selection]);
3719 }
3720 }
3721
3722 // Return only marked entries since we've already handled special cases where
3723 // only selection should take precedence. At this point, marked entries may or
3724 // may not include the current selection, which is intentional.
3725 self.marked_entries
3726 .iter()
3727 .map(|entry| SelectedEntry {
3728 entry_id: self.resolve_entry(entry.entry_id),
3729 worktree_id: entry.worktree_id,
3730 })
3731 .collect::<BTreeSet<_>>()
3732 }
3733
3734 /// Finds the currently selected subentry for a given leaf entry id. If a given entry
3735 /// has no ancestors, the project entry ID that's passed in is returned as-is.
3736 fn resolve_entry(&self, id: ProjectEntryId) -> ProjectEntryId {
3737 self.state
3738 .ancestors
3739 .get(&id)
3740 .and_then(|ancestors| ancestors.active_ancestor())
3741 .unwrap_or(id)
3742 }
3743
3744 pub fn selected_entry<'a>(&self, cx: &'a App) -> Option<(&'a Worktree, &'a project::Entry)> {
3745 let (worktree, entry) = self.selected_entry_handle(cx)?;
3746 Some((worktree.read(cx), entry))
3747 }
3748
3749 /// Compared to selected_entry, this function resolves to the currently
3750 /// selected subentry if dir auto-folding is enabled.
3751 fn selected_sub_entry<'a>(
3752 &self,
3753 cx: &'a App,
3754 ) -> Option<(Entity<Worktree>, &'a project::Entry)> {
3755 let (worktree, mut entry) = self.selected_entry_handle(cx)?;
3756
3757 let resolved_id = self.resolve_entry(entry.id);
3758 if resolved_id != entry.id {
3759 let worktree = worktree.read(cx);
3760 entry = worktree.entry_for_id(resolved_id)?;
3761 }
3762 Some((worktree, entry))
3763 }
3764 fn selected_entry_handle<'a>(
3765 &self,
3766 cx: &'a App,
3767 ) -> Option<(Entity<Worktree>, &'a project::Entry)> {
3768 let selection = self.selection?;
3769 let project = self.project.read(cx);
3770 let worktree = project.worktree_for_id(selection.worktree_id, cx)?;
3771 let entry = worktree.read(cx).entry_for_id(selection.entry_id)?;
3772 Some((worktree, entry))
3773 }
3774
3775 fn expand_to_selection(&mut self, cx: &mut Context<Self>) -> Option<()> {
3776 let (worktree, entry) = self.selected_entry(cx)?;
3777 let expanded_dir_ids = self
3778 .state
3779 .expanded_dir_ids
3780 .entry(worktree.id())
3781 .or_default();
3782
3783 for path in entry.path.ancestors() {
3784 let Some(entry) = worktree.entry_for_path(path) else {
3785 continue;
3786 };
3787 if entry.is_dir()
3788 && let Err(idx) = expanded_dir_ids.binary_search(&entry.id)
3789 {
3790 expanded_dir_ids.insert(idx, entry.id);
3791 }
3792 }
3793
3794 Some(())
3795 }
3796
3797 fn create_new_git_entry(
3798 parent_entry: &Entry,
3799 git_summary: GitSummary,
3800 new_entry_kind: EntryKind,
3801 ) -> GitEntry {
3802 GitEntry {
3803 entry: Entry {
3804 id: NEW_ENTRY_ID,
3805 kind: new_entry_kind,
3806 path: parent_entry.path.join(RelPath::unix("\0").unwrap()),
3807 inode: 0,
3808 mtime: parent_entry.mtime,
3809 size: parent_entry.size,
3810 is_ignored: parent_entry.is_ignored,
3811 is_hidden: parent_entry.is_hidden,
3812 is_external: false,
3813 is_private: false,
3814 is_always_included: parent_entry.is_always_included,
3815 canonical_path: parent_entry.canonical_path.clone(),
3816 char_bag: parent_entry.char_bag,
3817 is_fifo: parent_entry.is_fifo,
3818 },
3819 git_summary,
3820 }
3821 }
3822
3823 fn update_visible_entries(
3824 &mut self,
3825 new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
3826 focus_filename_editor: bool,
3827 autoscroll: bool,
3828 window: &mut Window,
3829 cx: &mut Context<Self>,
3830 ) {
3831 let now = Instant::now();
3832 let settings = ProjectPanelSettings::get_global(cx);
3833 let auto_collapse_dirs = settings.auto_fold_dirs;
3834 let hide_gitignore = settings.hide_gitignore;
3835 let sort_mode = settings.sort_mode;
3836 let project = self.project.read(cx);
3837 let repo_snapshots = project.git_store().read(cx).repo_snapshots(cx);
3838
3839 let old_ancestors = self.state.ancestors.clone();
3840 let temporary_unfolded_pending_state = self.state.temporarily_unfolded_pending_state.take();
3841 let mut new_state = State::derive(&self.state);
3842 new_state.last_worktree_root_id = project
3843 .visible_worktrees(cx)
3844 .next_back()
3845 .and_then(|worktree| worktree.read(cx).root_entry())
3846 .map(|entry| entry.id);
3847 let mut max_width_item = None;
3848
3849 let visible_worktrees: Vec<_> = project
3850 .visible_worktrees(cx)
3851 .map(|worktree| worktree.read(cx).snapshot())
3852 .collect();
3853 let hide_root = settings.hide_root && visible_worktrees.len() == 1;
3854 let hide_hidden = settings.hide_hidden;
3855
3856 let visible_entries_task = cx.spawn_in(window, async move |this, cx| {
3857 let new_state = cx
3858 .background_spawn(async move {
3859 for worktree_snapshot in visible_worktrees {
3860 let worktree_id = worktree_snapshot.id();
3861
3862 let mut new_entry_parent_id = None;
3863 let mut new_entry_kind = EntryKind::Dir;
3864 if let Some(edit_state) = &new_state.edit_state
3865 && edit_state.worktree_id == worktree_id
3866 && edit_state.is_new_entry()
3867 {
3868 new_entry_parent_id = Some(edit_state.entry_id);
3869 new_entry_kind = if edit_state.is_dir {
3870 EntryKind::Dir
3871 } else {
3872 EntryKind::File
3873 };
3874 }
3875
3876 let mut visible_worktree_entries = Vec::new();
3877 let mut entry_iter =
3878 GitTraversal::new(&repo_snapshots, worktree_snapshot.entries(true, 0));
3879 let mut auto_folded_ancestors = vec![];
3880 let worktree_abs_path = worktree_snapshot.abs_path();
3881 while let Some(entry) = entry_iter.entry() {
3882 if hide_root && Some(entry.entry) == worktree_snapshot.root_entry() {
3883 if new_entry_parent_id == Some(entry.id) {
3884 visible_worktree_entries.push(Self::create_new_git_entry(
3885 entry.entry,
3886 entry.git_summary,
3887 new_entry_kind,
3888 ));
3889 new_entry_parent_id = None;
3890 }
3891 entry_iter.advance();
3892 continue;
3893 }
3894 if auto_collapse_dirs && entry.kind.is_dir() {
3895 auto_folded_ancestors.push(entry.id);
3896 if !new_state.is_unfolded(&entry.id)
3897 && let Some(root_path) = worktree_snapshot.root_entry()
3898 {
3899 let mut child_entries =
3900 worktree_snapshot.child_entries(&entry.path);
3901 if let Some(child) = child_entries.next()
3902 && entry.path != root_path.path
3903 && child_entries.next().is_none()
3904 && child.kind.is_dir()
3905 {
3906 entry_iter.advance();
3907
3908 continue;
3909 }
3910 }
3911 let depth = temporary_unfolded_pending_state
3912 .as_ref()
3913 .and_then(|state| {
3914 if state.previously_focused_leaf_entry.worktree_id
3915 == worktree_id
3916 && state.previously_focused_leaf_entry.entry_id
3917 == entry.id
3918 {
3919 auto_folded_ancestors.iter().rev().position(|id| {
3920 *id == state.temporarily_unfolded_active_entry_id
3921 })
3922 } else {
3923 None
3924 }
3925 })
3926 .unwrap_or_else(|| {
3927 old_ancestors
3928 .get(&entry.id)
3929 .map(|ancestor| ancestor.current_ancestor_depth)
3930 .unwrap_or_default()
3931 })
3932 .min(auto_folded_ancestors.len());
3933 if let Some(edit_state) = &mut new_state.edit_state
3934 && edit_state.entry_id == entry.id
3935 {
3936 edit_state.depth = depth;
3937 }
3938 let mut ancestors = std::mem::take(&mut auto_folded_ancestors);
3939 if ancestors.len() > 1 {
3940 ancestors.reverse();
3941 new_state.ancestors.insert(
3942 entry.id,
3943 FoldedAncestors {
3944 current_ancestor_depth: depth,
3945 ancestors,
3946 },
3947 );
3948 }
3949 }
3950 auto_folded_ancestors.clear();
3951 if (!hide_gitignore || !entry.is_ignored)
3952 && (!hide_hidden || !entry.is_hidden)
3953 {
3954 visible_worktree_entries.push(entry.to_owned());
3955 }
3956 let precedes_new_entry = if let Some(new_entry_id) = new_entry_parent_id
3957 {
3958 entry.id == new_entry_id || {
3959 new_state.ancestors.get(&entry.id).is_some_and(|entries| {
3960 entries.ancestors.contains(&new_entry_id)
3961 })
3962 }
3963 } else {
3964 false
3965 };
3966 if precedes_new_entry
3967 && (!hide_gitignore || !entry.is_ignored)
3968 && (!hide_hidden || !entry.is_hidden)
3969 {
3970 visible_worktree_entries.push(Self::create_new_git_entry(
3971 entry.entry,
3972 entry.git_summary,
3973 new_entry_kind,
3974 ));
3975 }
3976
3977 let (depth, chars) = if Some(entry.entry)
3978 == worktree_snapshot.root_entry()
3979 {
3980 let Some(path_name) = worktree_abs_path.file_name() else {
3981 entry_iter.advance();
3982 continue;
3983 };
3984 let depth = 0;
3985 (depth, path_name.to_string_lossy().chars().count())
3986 } else if entry.is_file() {
3987 let Some(path_name) = entry
3988 .path
3989 .file_name()
3990 .with_context(|| {
3991 format!("Non-root entry has no file name: {entry:?}")
3992 })
3993 .log_err()
3994 else {
3995 continue;
3996 };
3997 let depth = entry.path.ancestors().count() - 1;
3998 (depth, path_name.chars().count())
3999 } else {
4000 let path = new_state
4001 .ancestors
4002 .get(&entry.id)
4003 .and_then(|ancestors| {
4004 let outermost_ancestor = ancestors.ancestors.last()?;
4005 let root_folded_entry = worktree_snapshot
4006 .entry_for_id(*outermost_ancestor)?
4007 .path
4008 .as_ref();
4009 entry.path.strip_prefix(root_folded_entry).ok().and_then(
4010 |suffix| {
4011 Some(
4012 RelPath::unix(root_folded_entry.file_name()?)
4013 .unwrap()
4014 .join(suffix),
4015 )
4016 },
4017 )
4018 })
4019 .or_else(|| {
4020 entry.path.file_name().map(|file_name| {
4021 RelPath::unix(file_name).unwrap().into()
4022 })
4023 })
4024 .unwrap_or_else(|| entry.path.clone());
4025 let depth = path.components().count();
4026 (depth, path.as_unix_str().chars().count())
4027 };
4028 let width_estimate =
4029 item_width_estimate(depth, chars, entry.canonical_path.is_some());
4030
4031 match max_width_item.as_mut() {
4032 Some((id, worktree_id, width)) => {
4033 if *width < width_estimate {
4034 *id = entry.id;
4035 *worktree_id = worktree_snapshot.id();
4036 *width = width_estimate;
4037 }
4038 }
4039 None => {
4040 max_width_item =
4041 Some((entry.id, worktree_snapshot.id(), width_estimate))
4042 }
4043 }
4044
4045 let expanded_dir_ids =
4046 match new_state.expanded_dir_ids.entry(worktree_id) {
4047 hash_map::Entry::Occupied(e) => e.into_mut(),
4048 hash_map::Entry::Vacant(e) => {
4049 // The first time a worktree's root entry becomes available,
4050 // mark that root entry as expanded.
4051 if let Some(entry) = worktree_snapshot.root_entry() {
4052 e.insert(vec![entry.id]).as_slice()
4053 } else {
4054 &[]
4055 }
4056 }
4057 };
4058
4059 if expanded_dir_ids.binary_search(&entry.id).is_err()
4060 && entry_iter.advance_to_sibling()
4061 {
4062 continue;
4063 }
4064 entry_iter.advance();
4065 }
4066
4067 par_sort_worktree_entries_with_mode(
4068 &mut visible_worktree_entries,
4069 sort_mode,
4070 );
4071 new_state.visible_entries.push(VisibleEntriesForWorktree {
4072 worktree_id,
4073 entries: visible_worktree_entries,
4074 index: OnceCell::new(),
4075 })
4076 }
4077 if let Some((project_entry_id, worktree_id, _)) = max_width_item {
4078 let mut visited_worktrees_length = 0;
4079 let index = new_state
4080 .visible_entries
4081 .iter()
4082 .find_map(|visible_entries| {
4083 if worktree_id == visible_entries.worktree_id {
4084 visible_entries
4085 .entries
4086 .iter()
4087 .position(|entry| entry.id == project_entry_id)
4088 } else {
4089 visited_worktrees_length += visible_entries.entries.len();
4090 None
4091 }
4092 });
4093 if let Some(index) = index {
4094 new_state.max_width_item_index = Some(visited_worktrees_length + index);
4095 }
4096 }
4097 new_state
4098 })
4099 .await;
4100 this.update_in(cx, |this, window, cx| {
4101 this.state = new_state;
4102 if let Some((worktree_id, entry_id)) = new_selected_entry {
4103 this.selection = Some(SelectedEntry {
4104 worktree_id,
4105 entry_id,
4106 });
4107 }
4108 let elapsed = now.elapsed();
4109 if this.last_reported_update.elapsed() > Duration::from_secs(3600) {
4110 telemetry::event!(
4111 "Project Panel Updated",
4112 elapsed_ms = elapsed.as_millis() as u64,
4113 worktree_entries = this
4114 .state
4115 .visible_entries
4116 .iter()
4117 .map(|worktree| worktree.entries.len())
4118 .sum::<usize>(),
4119 )
4120 }
4121 if this.update_visible_entries_task.focus_filename_editor {
4122 this.update_visible_entries_task.focus_filename_editor = false;
4123 this.filename_editor.update(cx, |editor, cx| {
4124 window.focus(&editor.focus_handle(cx), cx);
4125 });
4126 }
4127 if this.update_visible_entries_task.autoscroll {
4128 this.update_visible_entries_task.autoscroll = false;
4129 this.autoscroll(cx);
4130 }
4131 cx.notify();
4132 })
4133 .ok();
4134 });
4135
4136 self.update_visible_entries_task = UpdateVisibleEntriesTask {
4137 _visible_entries_task: visible_entries_task,
4138 focus_filename_editor: focus_filename_editor
4139 || self.update_visible_entries_task.focus_filename_editor,
4140 autoscroll: autoscroll || self.update_visible_entries_task.autoscroll,
4141 };
4142 }
4143
4144 fn expand_entry(
4145 &mut self,
4146 worktree_id: WorktreeId,
4147 entry_id: ProjectEntryId,
4148 cx: &mut Context<Self>,
4149 ) {
4150 self.project.update(cx, |project, cx| {
4151 if let Some((worktree, expanded_dir_ids)) = project
4152 .worktree_for_id(worktree_id, cx)
4153 .zip(self.state.expanded_dir_ids.get_mut(&worktree_id))
4154 {
4155 project.expand_entry(worktree_id, entry_id, cx);
4156 let worktree = worktree.read(cx);
4157
4158 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
4159 loop {
4160 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
4161 expanded_dir_ids.insert(ix, entry.id);
4162 }
4163
4164 if let Some(parent_entry) =
4165 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
4166 {
4167 entry = parent_entry;
4168 } else {
4169 break;
4170 }
4171 }
4172 }
4173 }
4174 });
4175 }
4176
4177 fn drop_external_files(
4178 &mut self,
4179 paths: &[PathBuf],
4180 entry_id: ProjectEntryId,
4181 window: &mut Window,
4182 cx: &mut Context<Self>,
4183 ) {
4184 let mut paths: Vec<Arc<Path>> = paths.iter().map(|path| Arc::from(path.clone())).collect();
4185
4186 let open_file_after_drop = paths.len() == 1 && paths[0].is_file();
4187
4188 let Some((target_directory, worktree, fs)) = maybe!({
4189 let project = self.project.read(cx);
4190 let fs = project.fs().clone();
4191 let worktree = project.worktree_for_entry(entry_id, cx)?;
4192 let entry = worktree.read(cx).entry_for_id(entry_id)?;
4193 let path = entry.path.clone();
4194 let target_directory = if entry.is_dir() {
4195 path
4196 } else {
4197 path.parent()?.into()
4198 };
4199 Some((target_directory, worktree, fs))
4200 }) else {
4201 return;
4202 };
4203
4204 let mut paths_to_replace = Vec::new();
4205 for path in &paths {
4206 if let Some(name) = path.file_name()
4207 && let Some(name) = name.to_str()
4208 {
4209 let target_path = target_directory.join(RelPath::unix(name).unwrap());
4210 if worktree.read(cx).entry_for_path(&target_path).is_some() {
4211 paths_to_replace.push((name.to_string(), path.clone()));
4212 }
4213 }
4214 }
4215
4216 cx.spawn_in(window, async move |this, cx| {
4217 async move {
4218 for (filename, original_path) in &paths_to_replace {
4219 let prompt_message = format!(
4220 concat!(
4221 "A file or folder with name {} ",
4222 "already exists in the destination folder. ",
4223 "Do you want to replace it?"
4224 ),
4225 filename
4226 );
4227 let answer = cx
4228 .update(|window, cx| {
4229 window.prompt(
4230 PromptLevel::Info,
4231 &prompt_message,
4232 None,
4233 &["Replace", "Cancel"],
4234 cx,
4235 )
4236 })?
4237 .await?;
4238
4239 if answer == 1
4240 && let Some(item_idx) = paths.iter().position(|p| p == original_path)
4241 {
4242 paths.remove(item_idx);
4243 }
4244 }
4245
4246 if paths.is_empty() {
4247 return Ok(());
4248 }
4249
4250 let task = worktree.update(cx, |worktree, cx| {
4251 worktree.copy_external_entries(target_directory, paths, fs, cx)
4252 });
4253
4254 let opened_entries: Vec<_> = task
4255 .await
4256 .with_context(|| "failed to copy external paths")?;
4257 this.update(cx, |this, cx| {
4258 if open_file_after_drop && !opened_entries.is_empty() {
4259 let settings = ProjectPanelSettings::get_global(cx);
4260 if settings.auto_open.should_open_on_drop() {
4261 this.open_entry(opened_entries[0], true, false, cx);
4262 }
4263 }
4264 })
4265 }
4266 .log_err()
4267 .await
4268 })
4269 .detach();
4270 }
4271
4272 fn refresh_drag_cursor_style(
4273 &self,
4274 modifiers: &Modifiers,
4275 window: &mut Window,
4276 cx: &mut Context<Self>,
4277 ) {
4278 if let Some(existing_cursor) = cx.active_drag_cursor_style() {
4279 let new_cursor = if Self::is_copy_modifier_set(modifiers) {
4280 CursorStyle::DragCopy
4281 } else {
4282 CursorStyle::PointingHand
4283 };
4284 if existing_cursor != new_cursor {
4285 cx.set_active_drag_cursor_style(new_cursor, window);
4286 }
4287 }
4288 }
4289
4290 fn is_copy_modifier_set(modifiers: &Modifiers) -> bool {
4291 cfg!(target_os = "macos") && modifiers.alt
4292 || cfg!(not(target_os = "macos")) && modifiers.control
4293 }
4294
4295 fn drag_onto(
4296 &mut self,
4297 selections: &DraggedSelection,
4298 target_entry_id: ProjectEntryId,
4299 is_file: bool,
4300 window: &mut Window,
4301 cx: &mut Context<Self>,
4302 ) {
4303 let resolved_selections = selections
4304 .items()
4305 .map(|entry| SelectedEntry {
4306 entry_id: self.resolve_entry(entry.entry_id),
4307 worktree_id: entry.worktree_id,
4308 })
4309 .collect::<BTreeSet<SelectedEntry>>();
4310 let entries = self.disjoint_entries(resolved_selections, cx);
4311
4312 if Self::is_copy_modifier_set(&window.modifiers()) {
4313 let _ = maybe!({
4314 let project = self.project.read(cx);
4315 let target_worktree = project.worktree_for_entry(target_entry_id, cx)?;
4316 let worktree_id = target_worktree.read(cx).id();
4317 let target_entry = target_worktree
4318 .read(cx)
4319 .entry_for_id(target_entry_id)?
4320 .clone();
4321
4322 let mut copy_tasks = Vec::new();
4323 let mut disambiguation_range = None;
4324 for selection in &entries {
4325 let (new_path, new_disambiguation_range) = self.create_paste_path(
4326 selection,
4327 (target_worktree.clone(), &target_entry),
4328 cx,
4329 )?;
4330
4331 let task = self.project.update(cx, |project, cx| {
4332 project.copy_entry(selection.entry_id, (worktree_id, new_path).into(), cx)
4333 });
4334 copy_tasks.push(task);
4335 disambiguation_range = new_disambiguation_range.or(disambiguation_range);
4336 }
4337
4338 let item_count = copy_tasks.len();
4339
4340 cx.spawn_in(window, async move |project_panel, cx| {
4341 let mut last_succeed = None;
4342 for task in copy_tasks.into_iter() {
4343 if let Some(Some(entry)) = task.await.log_err() {
4344 last_succeed = Some(entry.id);
4345 }
4346 }
4347 // update selection
4348 if let Some(entry_id) = last_succeed {
4349 project_panel
4350 .update_in(cx, |project_panel, window, cx| {
4351 project_panel.selection = Some(SelectedEntry {
4352 worktree_id,
4353 entry_id,
4354 });
4355
4356 // if only one entry was dragged and it was disambiguated, open the rename editor
4357 if item_count == 1 && disambiguation_range.is_some() {
4358 project_panel.rename_impl(disambiguation_range, window, cx);
4359 }
4360 })
4361 .ok();
4362 }
4363 })
4364 .detach();
4365 Some(())
4366 });
4367 } else {
4368 let update_marks = !self.marked_entries.is_empty();
4369 let active_selection = selections.active_selection;
4370
4371 // For folded selections, track the leaf suffix relative to the resolved
4372 // entry so we can refresh it after the move completes.
4373 let (folded_selection_info, folded_selection_entries): (
4374 Vec<(ProjectEntryId, RelPathBuf)>,
4375 HashSet<SelectedEntry>,
4376 ) = {
4377 let project = self.project.read(cx);
4378 let mut info = Vec::new();
4379 let mut folded_entries = HashSet::default();
4380
4381 for selection in selections.items() {
4382 let resolved_id = self.resolve_entry(selection.entry_id);
4383 if resolved_id == selection.entry_id {
4384 continue;
4385 }
4386 folded_entries.insert(*selection);
4387 let Some(source_path) = project.path_for_entry(resolved_id, cx) else {
4388 continue;
4389 };
4390 let Some(leaf_path) = project.path_for_entry(selection.entry_id, cx) else {
4391 continue;
4392 };
4393 let Ok(suffix) = leaf_path.path.strip_prefix(source_path.path.as_ref()) else {
4394 continue;
4395 };
4396 if suffix.as_unix_str().is_empty() {
4397 continue;
4398 }
4399
4400 info.push((resolved_id, suffix.to_rel_path_buf()));
4401 }
4402 (info, folded_entries)
4403 };
4404
4405 // Collect move tasks paired with their source entry ID so we can correlate
4406 // results with folded selections that need refreshing.
4407 let mut move_tasks: Vec<(ProjectEntryId, Task<Result<CreatedEntry>>)> = Vec::new();
4408 for entry in entries {
4409 if let Some(task) = self.move_entry(entry.entry_id, target_entry_id, is_file, cx) {
4410 move_tasks.push((entry.entry_id, task));
4411 }
4412 }
4413
4414 if move_tasks.is_empty() {
4415 return;
4416 }
4417
4418 if folded_selection_info.is_empty() {
4419 for (_, task) in move_tasks {
4420 task.detach_and_log_err(cx);
4421 }
4422 } else {
4423 cx.spawn_in(window, async move |project_panel, cx| {
4424 // Await all move tasks and collect successful results
4425 let mut move_results: Vec<(ProjectEntryId, Entry)> = Vec::new();
4426 for (entry_id, task) in move_tasks {
4427 if let Some(CreatedEntry::Included(new_entry)) = task.await.log_err() {
4428 move_results.push((entry_id, new_entry));
4429 }
4430 }
4431
4432 if move_results.is_empty() {
4433 return;
4434 }
4435
4436 // For folded selections, we need to refresh the leaf paths (with suffixes)
4437 // because they may not be indexed yet after the parent directory was moved.
4438 // First collect the paths to refresh, then refresh them.
4439 let paths_to_refresh: Vec<(Entity<Worktree>, Arc<RelPath>)> = project_panel
4440 .update(cx, |project_panel, cx| {
4441 let project = project_panel.project.read(cx);
4442 folded_selection_info
4443 .iter()
4444 .filter_map(|(resolved_id, suffix)| {
4445 let (_, new_entry) =
4446 move_results.iter().find(|(id, _)| id == resolved_id)?;
4447 let worktree = project.worktree_for_entry(new_entry.id, cx)?;
4448 let leaf_path = new_entry.path.join(suffix);
4449 Some((worktree, leaf_path))
4450 })
4451 .collect()
4452 })
4453 .ok()
4454 .unwrap_or_default();
4455
4456 let refresh_tasks: Vec<_> = paths_to_refresh
4457 .into_iter()
4458 .filter_map(|(worktree, leaf_path)| {
4459 worktree.update(cx, |worktree, cx| {
4460 worktree
4461 .as_local_mut()
4462 .map(|local| local.refresh_entry(leaf_path, None, cx))
4463 })
4464 })
4465 .collect();
4466
4467 for task in refresh_tasks {
4468 task.await.log_err();
4469 }
4470
4471 if update_marks && !folded_selection_entries.is_empty() {
4472 project_panel
4473 .update(cx, |project_panel, cx| {
4474 project_panel.marked_entries.retain(|entry| {
4475 !folded_selection_entries.contains(entry)
4476 || *entry == active_selection
4477 });
4478 cx.notify();
4479 })
4480 .ok();
4481 }
4482 })
4483 .detach();
4484 }
4485 }
4486 }
4487
4488 fn index_for_entry(
4489 &self,
4490 entry_id: ProjectEntryId,
4491 worktree_id: WorktreeId,
4492 ) -> Option<(usize, usize, usize)> {
4493 let mut total_ix = 0;
4494 for (worktree_ix, visible) in self.state.visible_entries.iter().enumerate() {
4495 if worktree_id != visible.worktree_id {
4496 total_ix += visible.entries.len();
4497 continue;
4498 }
4499
4500 return visible
4501 .entries
4502 .iter()
4503 .enumerate()
4504 .find(|(_, entry)| entry.id == entry_id)
4505 .map(|(ix, _)| (worktree_ix, ix, total_ix + ix));
4506 }
4507 None
4508 }
4509
4510 fn entry_at_index(&self, index: usize) -> Option<(WorktreeId, GitEntryRef<'_>)> {
4511 let mut offset = 0;
4512 for worktree in &self.state.visible_entries {
4513 let current_len = worktree.entries.len();
4514 if index < offset + current_len {
4515 return worktree
4516 .entries
4517 .get(index - offset)
4518 .map(|entry| (worktree.worktree_id, entry.to_ref()));
4519 }
4520 offset += current_len;
4521 }
4522 None
4523 }
4524
4525 fn iter_visible_entries(
4526 &self,
4527 range: Range<usize>,
4528 window: &mut Window,
4529 cx: &mut Context<ProjectPanel>,
4530 callback: &mut dyn FnMut(
4531 &Entry,
4532 usize,
4533 &HashSet<Arc<RelPath>>,
4534 &mut Window,
4535 &mut Context<ProjectPanel>,
4536 ),
4537 ) {
4538 let mut ix = 0;
4539 for visible in &self.state.visible_entries {
4540 if ix >= range.end {
4541 return;
4542 }
4543
4544 if ix + visible.entries.len() <= range.start {
4545 ix += visible.entries.len();
4546 continue;
4547 }
4548
4549 let end_ix = range.end.min(ix + visible.entries.len());
4550 let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
4551 let entries = visible
4552 .index
4553 .get_or_init(|| visible.entries.iter().map(|e| e.path.clone()).collect());
4554 let base_index = ix + entry_range.start;
4555 for (i, entry) in visible.entries[entry_range].iter().enumerate() {
4556 let global_index = base_index + i;
4557 callback(entry, global_index, entries, window, cx);
4558 }
4559 ix = end_ix;
4560 }
4561 }
4562
4563 fn for_each_visible_entry(
4564 &self,
4565 range: Range<usize>,
4566 window: &mut Window,
4567 cx: &mut Context<ProjectPanel>,
4568 callback: &mut dyn FnMut(
4569 ProjectEntryId,
4570 EntryDetails,
4571 &mut Window,
4572 &mut Context<ProjectPanel>,
4573 ),
4574 ) {
4575 let mut ix = 0;
4576 for visible in &self.state.visible_entries {
4577 if ix >= range.end {
4578 return;
4579 }
4580
4581 if ix + visible.entries.len() <= range.start {
4582 ix += visible.entries.len();
4583 continue;
4584 }
4585
4586 let end_ix = range.end.min(ix + visible.entries.len());
4587 let git_status_setting = {
4588 let settings = ProjectPanelSettings::get_global(cx);
4589 settings.git_status
4590 };
4591 if let Some(worktree) = self
4592 .project
4593 .read(cx)
4594 .worktree_for_id(visible.worktree_id, cx)
4595 {
4596 let snapshot = worktree.read(cx).snapshot();
4597 let root_name = snapshot.root_name();
4598
4599 let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
4600 let entries = visible
4601 .index
4602 .get_or_init(|| visible.entries.iter().map(|e| e.path.clone()).collect());
4603 for entry in visible.entries[entry_range].iter() {
4604 let status = git_status_setting
4605 .then_some(entry.git_summary)
4606 .unwrap_or_default();
4607
4608 let mut details = self.details_for_entry(
4609 entry,
4610 visible.worktree_id,
4611 root_name,
4612 entries,
4613 status,
4614 None,
4615 window,
4616 cx,
4617 );
4618
4619 if let Some(edit_state) = &self.state.edit_state {
4620 let is_edited_entry = if edit_state.is_new_entry() {
4621 entry.id == NEW_ENTRY_ID
4622 } else {
4623 entry.id == edit_state.entry_id
4624 || self.state.ancestors.get(&entry.id).is_some_and(
4625 |auto_folded_dirs| {
4626 auto_folded_dirs.ancestors.contains(&edit_state.entry_id)
4627 },
4628 )
4629 };
4630
4631 if is_edited_entry {
4632 if let Some(processing_filename) = &edit_state.processing_filename {
4633 details.is_processing = true;
4634 if let Some(ancestors) = edit_state
4635 .leaf_entry_id
4636 .and_then(|entry| self.state.ancestors.get(&entry))
4637 {
4638 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;
4639 let all_components = ancestors.ancestors.len();
4640
4641 let prefix_components = all_components - position;
4642 let suffix_components = position.checked_sub(1);
4643 let mut previous_components =
4644 Path::new(&details.filename).components();
4645 let mut new_path = previous_components
4646 .by_ref()
4647 .take(prefix_components)
4648 .collect::<PathBuf>();
4649 if let Some(last_component) =
4650 processing_filename.components().next_back()
4651 {
4652 new_path.push(last_component);
4653 previous_components.next();
4654 }
4655
4656 if suffix_components.is_some() {
4657 new_path.push(previous_components);
4658 }
4659 if let Some(str) = new_path.to_str() {
4660 details.filename.clear();
4661 details.filename.push_str(str);
4662 }
4663 } else {
4664 details.filename.clear();
4665 details.filename.push_str(processing_filename.as_unix_str());
4666 }
4667 } else {
4668 if edit_state.is_new_entry() {
4669 details.filename.clear();
4670 }
4671 details.is_editing = true;
4672 }
4673 }
4674 }
4675
4676 callback(entry.id, details, window, cx);
4677 }
4678 }
4679 ix = end_ix;
4680 }
4681 }
4682
4683 fn find_entry_in_worktree(
4684 &self,
4685 worktree_id: WorktreeId,
4686 reverse_search: bool,
4687 only_visible_entries: bool,
4688 predicate: &dyn Fn(GitEntryRef, WorktreeId) -> bool,
4689 cx: &mut Context<Self>,
4690 ) -> Option<GitEntry> {
4691 if only_visible_entries {
4692 let entries = self
4693 .state
4694 .visible_entries
4695 .iter()
4696 .find_map(|visible| {
4697 if worktree_id == visible.worktree_id {
4698 Some(&visible.entries)
4699 } else {
4700 None
4701 }
4702 })?
4703 .clone();
4704
4705 return utils::ReversibleIterable::new(entries.iter(), reverse_search)
4706 .find(|ele| predicate(ele.to_ref(), worktree_id))
4707 .cloned();
4708 }
4709
4710 let repo_snapshots = self
4711 .project
4712 .read(cx)
4713 .git_store()
4714 .read(cx)
4715 .repo_snapshots(cx);
4716 let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
4717 worktree.read_with(cx, |tree, _| {
4718 utils::ReversibleIterable::new(
4719 GitTraversal::new(&repo_snapshots, tree.entries(true, 0usize)),
4720 reverse_search,
4721 )
4722 .find_single_ended(|ele| predicate(*ele, worktree_id))
4723 .map(|ele| ele.to_owned())
4724 })
4725 }
4726
4727 fn find_entry(
4728 &self,
4729 start: Option<&SelectedEntry>,
4730 reverse_search: bool,
4731 predicate: &dyn Fn(GitEntryRef, WorktreeId) -> bool,
4732 cx: &mut Context<Self>,
4733 ) -> Option<SelectedEntry> {
4734 let mut worktree_ids: Vec<_> = self
4735 .state
4736 .visible_entries
4737 .iter()
4738 .map(|worktree| worktree.worktree_id)
4739 .collect();
4740 let repo_snapshots = self
4741 .project
4742 .read(cx)
4743 .git_store()
4744 .read(cx)
4745 .repo_snapshots(cx);
4746
4747 let mut last_found: Option<SelectedEntry> = None;
4748
4749 if let Some(start) = start {
4750 let worktree = self
4751 .project
4752 .read(cx)
4753 .worktree_for_id(start.worktree_id, cx)?
4754 .read(cx);
4755
4756 let search = {
4757 let entry = worktree.entry_for_id(start.entry_id)?;
4758 let root_entry = worktree.root_entry()?;
4759 let tree_id = worktree.id();
4760
4761 let mut first_iter = GitTraversal::new(
4762 &repo_snapshots,
4763 worktree.traverse_from_path(true, true, true, entry.path.as_ref()),
4764 );
4765
4766 if reverse_search {
4767 first_iter.next();
4768 }
4769
4770 let first = first_iter
4771 .enumerate()
4772 .take_until(|(count, entry)| entry.entry == root_entry && *count != 0usize)
4773 .map(|(_, entry)| entry)
4774 .find(|ele| predicate(*ele, tree_id))
4775 .map(|ele| ele.to_owned());
4776
4777 let second_iter =
4778 GitTraversal::new(&repo_snapshots, worktree.entries(true, 0usize));
4779
4780 let second = if reverse_search {
4781 second_iter
4782 .take_until(|ele| ele.id == start.entry_id)
4783 .filter(|ele| predicate(*ele, tree_id))
4784 .last()
4785 .map(|ele| ele.to_owned())
4786 } else {
4787 second_iter
4788 .take_while(|ele| ele.id != start.entry_id)
4789 .filter(|ele| predicate(*ele, tree_id))
4790 .last()
4791 .map(|ele| ele.to_owned())
4792 };
4793
4794 if reverse_search {
4795 Some((second, first))
4796 } else {
4797 Some((first, second))
4798 }
4799 };
4800
4801 if let Some((first, second)) = search {
4802 let first = first.map(|entry| SelectedEntry {
4803 worktree_id: start.worktree_id,
4804 entry_id: entry.id,
4805 });
4806
4807 let second = second.map(|entry| SelectedEntry {
4808 worktree_id: start.worktree_id,
4809 entry_id: entry.id,
4810 });
4811
4812 if first.is_some() {
4813 return first;
4814 }
4815 last_found = second;
4816
4817 let idx = worktree_ids
4818 .iter()
4819 .enumerate()
4820 .find(|(_, ele)| **ele == start.worktree_id)
4821 .map(|(idx, _)| idx);
4822
4823 if let Some(idx) = idx {
4824 worktree_ids.rotate_left(idx + 1usize);
4825 worktree_ids.pop();
4826 }
4827 }
4828 }
4829
4830 for tree_id in worktree_ids.into_iter() {
4831 if let Some(found) =
4832 self.find_entry_in_worktree(tree_id, reverse_search, false, &predicate, cx)
4833 {
4834 return Some(SelectedEntry {
4835 worktree_id: tree_id,
4836 entry_id: found.id,
4837 });
4838 }
4839 }
4840
4841 last_found
4842 }
4843
4844 fn find_visible_entry(
4845 &self,
4846 start: Option<&SelectedEntry>,
4847 reverse_search: bool,
4848 predicate: &dyn Fn(GitEntryRef, WorktreeId) -> bool,
4849 cx: &mut Context<Self>,
4850 ) -> Option<SelectedEntry> {
4851 let mut worktree_ids: Vec<_> = self
4852 .state
4853 .visible_entries
4854 .iter()
4855 .map(|worktree| worktree.worktree_id)
4856 .collect();
4857
4858 let mut last_found: Option<SelectedEntry> = None;
4859
4860 if let Some(start) = start {
4861 let entries = self
4862 .state
4863 .visible_entries
4864 .iter()
4865 .find(|worktree| worktree.worktree_id == start.worktree_id)
4866 .map(|worktree| &worktree.entries)?;
4867
4868 let mut start_idx = entries
4869 .iter()
4870 .enumerate()
4871 .find(|(_, ele)| ele.id == start.entry_id)
4872 .map(|(idx, _)| idx)?;
4873
4874 if reverse_search {
4875 start_idx = start_idx.saturating_add(1usize);
4876 }
4877
4878 let (left, right) = entries.split_at_checked(start_idx)?;
4879
4880 let (first_iter, second_iter) = if reverse_search {
4881 (
4882 utils::ReversibleIterable::new(left.iter(), reverse_search),
4883 utils::ReversibleIterable::new(right.iter(), reverse_search),
4884 )
4885 } else {
4886 (
4887 utils::ReversibleIterable::new(right.iter(), reverse_search),
4888 utils::ReversibleIterable::new(left.iter(), reverse_search),
4889 )
4890 };
4891
4892 let first_search = first_iter.find(|ele| predicate(ele.to_ref(), start.worktree_id));
4893 let second_search = second_iter.find(|ele| predicate(ele.to_ref(), start.worktree_id));
4894
4895 if first_search.is_some() {
4896 return first_search.map(|entry| SelectedEntry {
4897 worktree_id: start.worktree_id,
4898 entry_id: entry.id,
4899 });
4900 }
4901
4902 last_found = second_search.map(|entry| SelectedEntry {
4903 worktree_id: start.worktree_id,
4904 entry_id: entry.id,
4905 });
4906
4907 let idx = worktree_ids
4908 .iter()
4909 .enumerate()
4910 .find(|(_, ele)| **ele == start.worktree_id)
4911 .map(|(idx, _)| idx);
4912
4913 if let Some(idx) = idx {
4914 worktree_ids.rotate_left(idx + 1usize);
4915 worktree_ids.pop();
4916 }
4917 }
4918
4919 for tree_id in worktree_ids.into_iter() {
4920 if let Some(found) =
4921 self.find_entry_in_worktree(tree_id, reverse_search, true, &predicate, cx)
4922 {
4923 return Some(SelectedEntry {
4924 worktree_id: tree_id,
4925 entry_id: found.id,
4926 });
4927 }
4928 }
4929
4930 last_found
4931 }
4932
4933 fn calculate_depth_and_difference(
4934 entry: &Entry,
4935 visible_worktree_entries: &HashSet<Arc<RelPath>>,
4936 ) -> (usize, usize) {
4937 let (depth, difference) = entry
4938 .path
4939 .ancestors()
4940 .skip(1) // Skip the entry itself
4941 .find_map(|ancestor| {
4942 if let Some(parent_entry) = visible_worktree_entries.get(ancestor) {
4943 let entry_path_components_count = entry.path.components().count();
4944 let parent_path_components_count = parent_entry.components().count();
4945 let difference = entry_path_components_count - parent_path_components_count;
4946 let depth = parent_entry
4947 .ancestors()
4948 .skip(1)
4949 .filter(|ancestor| visible_worktree_entries.contains(*ancestor))
4950 .count();
4951 Some((depth + 1, difference))
4952 } else {
4953 None
4954 }
4955 })
4956 .unwrap_or_else(|| (0, entry.path.components().count()));
4957
4958 (depth, difference)
4959 }
4960
4961 fn highlight_entry_for_external_drag(
4962 &self,
4963 target_entry: &Entry,
4964 target_worktree: &Worktree,
4965 ) -> Option<ProjectEntryId> {
4966 // Always highlight directory or parent directory if it's file
4967 if target_entry.is_dir() {
4968 Some(target_entry.id)
4969 } else {
4970 target_entry
4971 .path
4972 .parent()
4973 .and_then(|parent_path| target_worktree.entry_for_path(parent_path))
4974 .map(|parent_entry| parent_entry.id)
4975 }
4976 }
4977
4978 fn highlight_entry_for_selection_drag(
4979 &self,
4980 target_entry: &Entry,
4981 target_worktree: &Worktree,
4982 drag_state: &DraggedSelection,
4983 cx: &Context<Self>,
4984 ) -> Option<ProjectEntryId> {
4985 let target_parent_path = target_entry.path.parent();
4986
4987 // In case of single item drag, we do not highlight existing
4988 // directory which item belongs too
4989 if drag_state.items().count() == 1
4990 && drag_state.active_selection.worktree_id == target_worktree.id()
4991 {
4992 let active_entry_path = self
4993 .project
4994 .read(cx)
4995 .path_for_entry(drag_state.active_selection.entry_id, cx)?;
4996
4997 if let Some(active_parent_path) = active_entry_path.path.parent() {
4998 // Do not highlight active entry parent
4999 if active_parent_path == target_entry.path.as_ref() {
5000 return None;
5001 }
5002
5003 // Do not highlight active entry sibling files
5004 if Some(active_parent_path) == target_parent_path && target_entry.is_file() {
5005 return None;
5006 }
5007 }
5008 }
5009
5010 // Always highlight directory or parent directory if it's file
5011 if target_entry.is_dir() {
5012 Some(target_entry.id)
5013 } else {
5014 target_parent_path
5015 .and_then(|parent_path| target_worktree.entry_for_path(parent_path))
5016 .map(|parent_entry| parent_entry.id)
5017 }
5018 }
5019
5020 fn should_highlight_background_for_selection_drag(
5021 &self,
5022 drag_state: &DraggedSelection,
5023 last_root_id: ProjectEntryId,
5024 cx: &App,
5025 ) -> bool {
5026 // Always highlight for multiple entries
5027 if drag_state.items().count() > 1 {
5028 return true;
5029 }
5030
5031 // Since root will always have empty relative path
5032 if let Some(entry_path) = self
5033 .project
5034 .read(cx)
5035 .path_for_entry(drag_state.active_selection.entry_id, cx)
5036 {
5037 if let Some(parent_path) = entry_path.path.parent() {
5038 if !parent_path.is_empty() {
5039 return true;
5040 }
5041 }
5042 }
5043
5044 // If parent is empty, check if different worktree
5045 if let Some(last_root_worktree_id) = self
5046 .project
5047 .read(cx)
5048 .worktree_id_for_entry(last_root_id, cx)
5049 {
5050 if drag_state.active_selection.worktree_id != last_root_worktree_id {
5051 return true;
5052 }
5053 }
5054
5055 false
5056 }
5057
5058 fn render_entry(
5059 &self,
5060 entry_id: ProjectEntryId,
5061 details: EntryDetails,
5062 window: &mut Window,
5063 cx: &mut Context<Self>,
5064 ) -> Stateful<Div> {
5065 const GROUP_NAME: &str = "project_entry";
5066
5067 let kind = details.kind;
5068 let is_sticky = details.sticky.is_some();
5069 let sticky_index = details.sticky.as_ref().map(|this| this.sticky_index);
5070 let settings = ProjectPanelSettings::get_global(cx);
5071 let show_editor = details.is_editing && !details.is_processing;
5072
5073 let selection = SelectedEntry {
5074 worktree_id: details.worktree_id,
5075 entry_id,
5076 };
5077
5078 let is_marked = self.marked_entries.contains(&selection);
5079 let is_active = self
5080 .selection
5081 .is_some_and(|selection| selection.entry_id == entry_id);
5082
5083 let file_name = details.filename.clone();
5084
5085 let mut icon = details.icon.clone();
5086 if settings.file_icons && show_editor && details.kind.is_file() {
5087 let filename = self.filename_editor.read(cx).text(cx);
5088 if filename.len() > 2 {
5089 icon = FileIcons::get_icon(Path::new(&filename), cx);
5090 }
5091 }
5092
5093 let filename_text_color = details.filename_text_color;
5094 let diagnostic_severity = details.diagnostic_severity;
5095 let diagnostic_count = details.diagnostic_count;
5096 let item_colors = get_item_color(is_sticky, cx);
5097
5098 let canonical_path = details
5099 .canonical_path
5100 .as_ref()
5101 .map(|f| f.to_string_lossy().into_owned());
5102 let path_style = self.project.read(cx).path_style(cx);
5103 let path = details.path.clone();
5104 let path_for_external_paths = path.clone();
5105 let path_for_dragged_selection = path.clone();
5106
5107 let depth = details.depth;
5108 let worktree_id = details.worktree_id;
5109 let dragged_selection = DraggedSelection {
5110 active_selection: SelectedEntry {
5111 worktree_id: selection.worktree_id,
5112 entry_id: selection.entry_id,
5113 },
5114 marked_selections: Arc::from(self.marked_entries.clone()),
5115 };
5116
5117 let bg_color = if is_marked {
5118 item_colors.marked
5119 } else {
5120 item_colors.default
5121 };
5122
5123 let bg_hover_color = if is_marked {
5124 item_colors.marked
5125 } else {
5126 item_colors.hover
5127 };
5128
5129 let validation_color_and_message = if show_editor {
5130 match self
5131 .state
5132 .edit_state
5133 .as_ref()
5134 .map_or(ValidationState::None, |e| e.validation_state.clone())
5135 {
5136 ValidationState::Error(msg) => Some((Color::Error.color(cx), msg)),
5137 ValidationState::Warning(msg) => Some((Color::Warning.color(cx), msg)),
5138 ValidationState::None => None,
5139 }
5140 } else {
5141 None
5142 };
5143
5144 let border_color =
5145 if !self.mouse_down && is_active && self.focus_handle.contains_focused(window, cx) {
5146 match validation_color_and_message {
5147 Some((color, _)) => color,
5148 None => item_colors.focused,
5149 }
5150 } else {
5151 bg_color
5152 };
5153
5154 let border_hover_color =
5155 if !self.mouse_down && is_active && self.focus_handle.contains_focused(window, cx) {
5156 match validation_color_and_message {
5157 Some((color, _)) => color,
5158 None => item_colors.focused,
5159 }
5160 } else {
5161 bg_hover_color
5162 };
5163
5164 let folded_directory_drag_target = self.folded_directory_drag_target;
5165 let is_highlighted = {
5166 if let Some(highlight_entry_id) =
5167 self.drag_target_entry
5168 .as_ref()
5169 .and_then(|drag_target| match drag_target {
5170 DragTarget::Entry {
5171 highlight_entry_id, ..
5172 } => Some(*highlight_entry_id),
5173 DragTarget::Background => self.state.last_worktree_root_id,
5174 })
5175 {
5176 // Highlight if same entry or it's children
5177 if entry_id == highlight_entry_id {
5178 true
5179 } else {
5180 maybe!({
5181 let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
5182 let highlight_entry = worktree.read(cx).entry_for_id(highlight_entry_id)?;
5183 Some(path.starts_with(&highlight_entry.path))
5184 })
5185 .unwrap_or(false)
5186 }
5187 } else {
5188 false
5189 }
5190 };
5191
5192 let id: ElementId = if is_sticky {
5193 SharedString::from(format!("project_panel_sticky_item_{}", entry_id.to_usize())).into()
5194 } else {
5195 (entry_id.to_proto() as usize).into()
5196 };
5197
5198 div()
5199 .id(id.clone())
5200 .relative()
5201 .group(GROUP_NAME)
5202 .cursor_pointer()
5203 .rounded_none()
5204 .bg(bg_color)
5205 .border_1()
5206 .border_r_2()
5207 .border_color(border_color)
5208 .hover(|style| style.bg(bg_hover_color).border_color(border_hover_color))
5209 .when(is_sticky, |this| this.block_mouse_except_scroll())
5210 .when(!is_sticky, |this| {
5211 this.when(
5212 is_highlighted && folded_directory_drag_target.is_none(),
5213 |this| {
5214 this.border_color(transparent_white())
5215 .bg(item_colors.drag_over)
5216 },
5217 )
5218 .when(settings.drag_and_drop, |this| {
5219 this.on_drag_move::<ExternalPaths>(cx.listener(
5220 move |this, event: &DragMoveEvent<ExternalPaths>, _, cx| {
5221 let is_current_target =
5222 this.drag_target_entry
5223 .as_ref()
5224 .and_then(|entry| match entry {
5225 DragTarget::Entry {
5226 entry_id: target_id,
5227 ..
5228 } => Some(*target_id),
5229 DragTarget::Background { .. } => None,
5230 })
5231 == Some(entry_id);
5232
5233 if !event.bounds.contains(&event.event.position) {
5234 // Entry responsible for setting drag target is also responsible to
5235 // clear it up after drag is out of bounds
5236 if is_current_target {
5237 this.drag_target_entry = None;
5238 }
5239 return;
5240 }
5241
5242 if is_current_target {
5243 return;
5244 }
5245
5246 this.marked_entries.clear();
5247
5248 let Some((entry_id, highlight_entry_id)) = maybe!({
5249 let target_worktree = this
5250 .project
5251 .read(cx)
5252 .worktree_for_id(selection.worktree_id, cx)?
5253 .read(cx);
5254 let target_entry =
5255 target_worktree.entry_for_path(&path_for_external_paths)?;
5256 let highlight_entry_id = this.highlight_entry_for_external_drag(
5257 target_entry,
5258 target_worktree,
5259 )?;
5260 Some((target_entry.id, highlight_entry_id))
5261 }) else {
5262 return;
5263 };
5264
5265 this.drag_target_entry = Some(DragTarget::Entry {
5266 entry_id,
5267 highlight_entry_id,
5268 });
5269 },
5270 ))
5271 .on_drop(cx.listener(
5272 move |this, external_paths: &ExternalPaths, window, cx| {
5273 this.drag_target_entry = None;
5274 this.hover_scroll_task.take();
5275 this.drop_external_files(external_paths.paths(), entry_id, window, cx);
5276 cx.stop_propagation();
5277 },
5278 ))
5279 .on_drag_move::<DraggedSelection>(cx.listener(
5280 move |this, event: &DragMoveEvent<DraggedSelection>, window, cx| {
5281 let is_current_target =
5282 this.drag_target_entry
5283 .as_ref()
5284 .and_then(|entry| match entry {
5285 DragTarget::Entry {
5286 entry_id: target_id,
5287 ..
5288 } => Some(*target_id),
5289 DragTarget::Background { .. } => None,
5290 })
5291 == Some(entry_id);
5292
5293 if !event.bounds.contains(&event.event.position) {
5294 // Entry responsible for setting drag target is also responsible to
5295 // clear it up after drag is out of bounds
5296 if is_current_target {
5297 this.drag_target_entry = None;
5298 }
5299 return;
5300 }
5301
5302 if is_current_target {
5303 return;
5304 }
5305
5306 let drag_state = event.drag(cx);
5307
5308 if drag_state.items().count() == 1 {
5309 this.marked_entries.clear();
5310 this.marked_entries.push(drag_state.active_selection);
5311 }
5312
5313 let Some((entry_id, highlight_entry_id)) = maybe!({
5314 let target_worktree = this
5315 .project
5316 .read(cx)
5317 .worktree_for_id(selection.worktree_id, cx)?
5318 .read(cx);
5319 let target_entry =
5320 target_worktree.entry_for_path(&path_for_dragged_selection)?;
5321 let highlight_entry_id = this.highlight_entry_for_selection_drag(
5322 target_entry,
5323 target_worktree,
5324 drag_state,
5325 cx,
5326 )?;
5327 Some((target_entry.id, highlight_entry_id))
5328 }) else {
5329 return;
5330 };
5331
5332 this.drag_target_entry = Some(DragTarget::Entry {
5333 entry_id,
5334 highlight_entry_id,
5335 });
5336
5337 this.hover_expand_task.take();
5338
5339 if !kind.is_dir()
5340 || this
5341 .state
5342 .expanded_dir_ids
5343 .get(&details.worktree_id)
5344 .is_some_and(|ids| ids.binary_search(&entry_id).is_ok())
5345 {
5346 return;
5347 }
5348
5349 let bounds = event.bounds;
5350 this.hover_expand_task =
5351 Some(cx.spawn_in(window, async move |this, cx| {
5352 cx.background_executor()
5353 .timer(Duration::from_millis(500))
5354 .await;
5355 this.update_in(cx, |this, window, cx| {
5356 this.hover_expand_task.take();
5357 if this.drag_target_entry.as_ref().and_then(|entry| {
5358 match entry {
5359 DragTarget::Entry {
5360 entry_id: target_id,
5361 ..
5362 } => Some(*target_id),
5363 DragTarget::Background { .. } => None,
5364 }
5365 }) == Some(entry_id)
5366 && bounds.contains(&window.mouse_position())
5367 {
5368 this.expand_entry(worktree_id, entry_id, cx);
5369 this.update_visible_entries(
5370 Some((worktree_id, entry_id)),
5371 false,
5372 false,
5373 window,
5374 cx,
5375 );
5376 cx.notify();
5377 }
5378 })
5379 .ok();
5380 }));
5381 },
5382 ))
5383 .on_drag(dragged_selection, {
5384 let active_component =
5385 self.state.ancestors.get(&entry_id).and_then(|ancestors| {
5386 ancestors.active_component(&details.filename)
5387 });
5388 move |selection, click_offset, _window, cx| {
5389 let filename = active_component
5390 .as_ref()
5391 .unwrap_or_else(|| &details.filename);
5392 cx.new(|_| DraggedProjectEntryView {
5393 icon: details.icon.clone(),
5394 filename: filename.clone(),
5395 click_offset,
5396 selection: selection.active_selection,
5397 selections: selection.marked_selections.clone(),
5398 })
5399 }
5400 })
5401 .on_drop(cx.listener(
5402 move |this, selections: &DraggedSelection, window, cx| {
5403 this.drag_target_entry = None;
5404 this.hover_scroll_task.take();
5405 this.hover_expand_task.take();
5406 if folded_directory_drag_target.is_some() {
5407 return;
5408 }
5409 this.drag_onto(selections, entry_id, kind.is_file(), window, cx);
5410 },
5411 ))
5412 })
5413 })
5414 .on_mouse_down(
5415 MouseButton::Left,
5416 cx.listener(move |this, _, _, cx| {
5417 this.mouse_down = true;
5418 cx.propagate();
5419 }),
5420 )
5421 .on_click(
5422 cx.listener(move |project_panel, event: &gpui::ClickEvent, window, cx| {
5423 if event.is_right_click() || event.first_focus() || show_editor {
5424 return;
5425 }
5426 if event.standard_click() {
5427 project_panel.mouse_down = false;
5428 }
5429 cx.stop_propagation();
5430
5431 if let Some(selection) =
5432 project_panel.selection.filter(|_| event.modifiers().shift)
5433 {
5434 let current_selection = project_panel.index_for_selection(selection);
5435 let clicked_entry = SelectedEntry {
5436 entry_id,
5437 worktree_id,
5438 };
5439 let target_selection = project_panel.index_for_selection(clicked_entry);
5440 if let Some(((_, _, source_index), (_, _, target_index))) =
5441 current_selection.zip(target_selection)
5442 {
5443 let range_start = source_index.min(target_index);
5444 let range_end = source_index.max(target_index) + 1;
5445 let mut new_selections = Vec::new();
5446 project_panel.for_each_visible_entry(
5447 range_start..range_end,
5448 window,
5449 cx,
5450 &mut |entry_id, details, _, _| {
5451 new_selections.push(SelectedEntry {
5452 entry_id,
5453 worktree_id: details.worktree_id,
5454 });
5455 },
5456 );
5457
5458 for selection in &new_selections {
5459 if !project_panel.marked_entries.contains(selection) {
5460 project_panel.marked_entries.push(*selection);
5461 }
5462 }
5463
5464 project_panel.selection = Some(clicked_entry);
5465 if !project_panel.marked_entries.contains(&clicked_entry) {
5466 project_panel.marked_entries.push(clicked_entry);
5467 }
5468 }
5469 } else if event.modifiers().secondary() {
5470 if event.click_count() > 1 {
5471 project_panel.split_entry(entry_id, false, None, cx);
5472 } else {
5473 project_panel.selection = Some(selection);
5474 if let Some(position) = project_panel
5475 .marked_entries
5476 .iter()
5477 .position(|e| *e == selection)
5478 {
5479 project_panel.marked_entries.remove(position);
5480 } else {
5481 project_panel.marked_entries.push(selection);
5482 }
5483 }
5484 } else if kind.is_dir() {
5485 project_panel.marked_entries.clear();
5486 if is_sticky
5487 && let Some((_, _, index)) =
5488 project_panel.index_for_entry(entry_id, worktree_id)
5489 {
5490 project_panel
5491 .scroll_handle
5492 .scroll_to_item_strict_with_offset(
5493 index,
5494 ScrollStrategy::Top,
5495 sticky_index.unwrap_or(0),
5496 );
5497 cx.notify();
5498 // move down by 1px so that clicked item
5499 // don't count as sticky anymore
5500 cx.on_next_frame(window, |_, window, cx| {
5501 cx.on_next_frame(window, |this, _, cx| {
5502 let mut offset = this.scroll_handle.offset();
5503 offset.y += px(1.);
5504 this.scroll_handle.set_offset(offset);
5505 cx.notify();
5506 });
5507 });
5508 return;
5509 }
5510 if event.modifiers().alt {
5511 project_panel.toggle_expand_all(entry_id, window, cx);
5512 } else {
5513 project_panel.toggle_expanded(entry_id, window, cx);
5514 }
5515 } else {
5516 let preview_tabs_enabled =
5517 PreviewTabsSettings::get_global(cx).enable_preview_from_project_panel;
5518 let click_count = event.click_count();
5519 let focus_opened_item = click_count > 1;
5520 let allow_preview = preview_tabs_enabled && click_count == 1;
5521 project_panel.open_entry(entry_id, focus_opened_item, allow_preview, cx);
5522 }
5523 }),
5524 )
5525 .child(
5526 ListItem::new(id)
5527 .indent_level(depth)
5528 .indent_step_size(px(settings.indent_size))
5529 .spacing(match settings.entry_spacing {
5530 ProjectPanelEntrySpacing::Comfortable => ListItemSpacing::Dense,
5531 ProjectPanelEntrySpacing::Standard => ListItemSpacing::ExtraDense,
5532 })
5533 .selectable(false)
5534 .when(
5535 canonical_path.is_some() || diagnostic_count.is_some(),
5536 |this| {
5537 let symlink_element = canonical_path.map(|path| {
5538 div()
5539 .id("symlink_icon")
5540 .tooltip(move |_window, cx| {
5541 Tooltip::with_meta(
5542 path.to_string(),
5543 None,
5544 "Symbolic Link",
5545 cx,
5546 )
5547 })
5548 .child(
5549 Icon::new(IconName::ArrowUpRight)
5550 .size(IconSize::Indicator)
5551 .color(filename_text_color),
5552 )
5553 });
5554 this.end_slot::<AnyElement>(
5555 h_flex()
5556 .gap_1()
5557 .flex_none()
5558 .pr_3()
5559 .when_some(diagnostic_count, |this, count| {
5560 this.when(count.error_count > 0, |this| {
5561 this.child(
5562 Label::new(count.capped_error_count())
5563 .size(LabelSize::Small)
5564 .color(Color::Error),
5565 )
5566 })
5567 .when(
5568 count.warning_count > 0,
5569 |this| {
5570 this.child(
5571 Label::new(count.capped_warning_count())
5572 .size(LabelSize::Small)
5573 .color(Color::Warning),
5574 )
5575 },
5576 )
5577 })
5578 .when_some(symlink_element, |this, el| this.child(el))
5579 .into_any_element(),
5580 )
5581 },
5582 )
5583 .child(if let Some(icon) = &icon {
5584 if let Some((_, decoration_color)) =
5585 entry_diagnostic_aware_icon_decoration_and_color(diagnostic_severity)
5586 {
5587 let is_warning = diagnostic_severity
5588 .map(|severity| matches!(severity, DiagnosticSeverity::WARNING))
5589 .unwrap_or(false);
5590 div().child(
5591 DecoratedIcon::new(
5592 Icon::from_path(icon.clone()).color(Color::Muted),
5593 Some(
5594 IconDecoration::new(
5595 if kind.is_file() {
5596 if is_warning {
5597 IconDecorationKind::Triangle
5598 } else {
5599 IconDecorationKind::X
5600 }
5601 } else {
5602 IconDecorationKind::Dot
5603 },
5604 bg_color,
5605 cx,
5606 )
5607 .group_name(Some(GROUP_NAME.into()))
5608 .knockout_hover_color(bg_hover_color)
5609 .color(decoration_color.color(cx))
5610 .position(Point {
5611 x: px(-2.),
5612 y: px(-2.),
5613 }),
5614 ),
5615 )
5616 .into_any_element(),
5617 )
5618 } else {
5619 h_flex().child(Icon::from_path(icon.to_string()).color(Color::Muted))
5620 }
5621 } else if let Some((icon_name, color)) =
5622 entry_diagnostic_aware_icon_name_and_color(diagnostic_severity)
5623 {
5624 h_flex()
5625 .size(IconSize::default().rems())
5626 .child(Icon::new(icon_name).color(color).size(IconSize::Small))
5627 } else {
5628 h_flex()
5629 .size(IconSize::default().rems())
5630 .invisible()
5631 .flex_none()
5632 })
5633 .child(if show_editor {
5634 h_flex().h_6().w_full().child(self.filename_editor.clone())
5635 } else {
5636 h_flex()
5637 .h_6()
5638 .map(|this| match self.state.ancestors.get(&entry_id) {
5639 Some(folded_ancestors) => {
5640 this.children(self.render_folder_elements(
5641 folded_ancestors,
5642 entry_id,
5643 file_name,
5644 path_style,
5645 is_sticky,
5646 kind.is_file(),
5647 is_active || is_marked,
5648 settings.drag_and_drop,
5649 settings.bold_folder_labels,
5650 item_colors.drag_over,
5651 folded_directory_drag_target,
5652 filename_text_color,
5653 cx,
5654 ))
5655 }
5656
5657 None => this.child(
5658 Label::new(file_name)
5659 .single_line()
5660 .color(filename_text_color)
5661 .when(
5662 settings.bold_folder_labels && kind.is_dir(),
5663 |this| this.weight(FontWeight::SEMIBOLD),
5664 )
5665 .into_any_element(),
5666 ),
5667 })
5668 })
5669 .on_secondary_mouse_down(cx.listener(
5670 move |this, event: &MouseDownEvent, window, cx| {
5671 // Stop propagation to prevent the catch-all context menu for the project
5672 // panel from being deployed.
5673 cx.stop_propagation();
5674 // Some context menu actions apply to all marked entries. If the user
5675 // right-clicks on an entry that is not marked, they may not realize the
5676 // action applies to multiple entries. To avoid inadvertent changes, all
5677 // entries are unmarked.
5678 if !this.marked_entries.contains(&selection) {
5679 this.marked_entries.clear();
5680 }
5681 this.deploy_context_menu(event.position, entry_id, window, cx);
5682 },
5683 ))
5684 .overflow_x(),
5685 )
5686 .when_some(validation_color_and_message, |this, (color, message)| {
5687 this.relative().child(deferred(
5688 div()
5689 .occlude()
5690 .absolute()
5691 .top_full()
5692 .left(px(-1.)) // Used px over rem so that it doesn't change with font size
5693 .right(px(-0.5))
5694 .py_1()
5695 .px_2()
5696 .border_1()
5697 .border_color(color)
5698 .bg(cx.theme().colors().background)
5699 .child(
5700 Label::new(message)
5701 .color(Color::from(color))
5702 .size(LabelSize::Small),
5703 ),
5704 ))
5705 })
5706 }
5707
5708 fn render_folder_elements(
5709 &self,
5710 folded_ancestors: &FoldedAncestors,
5711 entry_id: ProjectEntryId,
5712 file_name: String,
5713 path_style: PathStyle,
5714 is_sticky: bool,
5715 is_file: bool,
5716 is_active_or_marked: bool,
5717 drag_and_drop_enabled: bool,
5718 bold_folder_labels: bool,
5719 drag_over_color: Hsla,
5720 folded_directory_drag_target: Option<FoldedDirectoryDragTarget>,
5721 filename_text_color: Color,
5722 cx: &Context<Self>,
5723 ) -> impl Iterator<Item = AnyElement> {
5724 let components = Path::new(&file_name)
5725 .components()
5726 .map(|comp| comp.as_os_str().to_string_lossy().into_owned())
5727 .collect::<Vec<_>>();
5728 let active_index = folded_ancestors.active_index();
5729 let components_len = components.len();
5730 let delimiter = SharedString::new(path_style.primary_separator());
5731
5732 let path_component_elements =
5733 components
5734 .into_iter()
5735 .enumerate()
5736 .map(move |(index, component)| {
5737 div()
5738 .id(SharedString::from(format!(
5739 "project_panel_path_component_{}_{index}",
5740 entry_id.to_usize()
5741 )))
5742 .when(index == 0, |this| this.ml_neg_0p5())
5743 .px_0p5()
5744 .rounded_xs()
5745 .hover(|style| style.bg(cx.theme().colors().element_active))
5746 .when(!is_sticky, |div| {
5747 div.when(index != components_len - 1, |div| {
5748 let target_entry_id = folded_ancestors
5749 .ancestors
5750 .get(components_len - 1 - index)
5751 .cloned();
5752 div.when(drag_and_drop_enabled, |div| {
5753 div.on_drag_move(cx.listener(
5754 move |this,
5755 event: &DragMoveEvent<DraggedSelection>,
5756 _,
5757 _| {
5758 if event.bounds.contains(&event.event.position) {
5759 this.folded_directory_drag_target =
5760 Some(FoldedDirectoryDragTarget {
5761 entry_id,
5762 index,
5763 is_delimiter_target: false,
5764 });
5765 } else {
5766 let is_current_target = this
5767 .folded_directory_drag_target
5768 .as_ref()
5769 .is_some_and(|target| {
5770 target.entry_id == entry_id
5771 && target.index == index
5772 && !target.is_delimiter_target
5773 });
5774 if is_current_target {
5775 this.folded_directory_drag_target = None;
5776 }
5777 }
5778 },
5779 ))
5780 .on_drop(cx.listener(
5781 move |this, selections: &DraggedSelection, window, cx| {
5782 this.hover_scroll_task.take();
5783 this.drag_target_entry = None;
5784 this.folded_directory_drag_target = None;
5785 if let Some(target_entry_id) = target_entry_id {
5786 this.drag_onto(
5787 selections,
5788 target_entry_id,
5789 is_file,
5790 window,
5791 cx,
5792 );
5793 }
5794 },
5795 ))
5796 .when(
5797 folded_directory_drag_target.is_some_and(|target| {
5798 target.entry_id == entry_id && target.index == index
5799 }),
5800 |this| this.bg(drag_over_color),
5801 )
5802 })
5803 })
5804 })
5805 .on_mouse_down(
5806 MouseButton::Left,
5807 cx.listener(move |this, _, _, cx| {
5808 if let Some(folds) = this.state.ancestors.get_mut(&entry_id) {
5809 if folds.set_active_index(index) {
5810 cx.notify();
5811 }
5812 }
5813 }),
5814 )
5815 .on_mouse_down(
5816 MouseButton::Right,
5817 cx.listener(move |this, _, _, cx| {
5818 if let Some(folds) = this.state.ancestors.get_mut(&entry_id) {
5819 if folds.set_active_index(index) {
5820 cx.notify();
5821 }
5822 }
5823 }),
5824 )
5825 .child(
5826 Label::new(component)
5827 .single_line()
5828 .color(filename_text_color)
5829 .when(bold_folder_labels && !is_file, |this| {
5830 this.weight(FontWeight::SEMIBOLD)
5831 })
5832 .when(index == active_index && is_active_or_marked, |this| {
5833 this.underline()
5834 }),
5835 )
5836 .into_any()
5837 });
5838
5839 let mut separator_index = 0;
5840 itertools::intersperse_with(path_component_elements, move || {
5841 separator_index += 1;
5842 self.render_entry_path_separator(
5843 entry_id,
5844 separator_index,
5845 components_len,
5846 is_sticky,
5847 is_file,
5848 drag_and_drop_enabled,
5849 filename_text_color,
5850 &delimiter,
5851 folded_ancestors,
5852 cx,
5853 )
5854 .into_any()
5855 })
5856 }
5857
5858 fn render_entry_path_separator(
5859 &self,
5860 entry_id: ProjectEntryId,
5861 index: usize,
5862 components_len: usize,
5863 is_sticky: bool,
5864 is_file: bool,
5865 drag_and_drop_enabled: bool,
5866 filename_text_color: Color,
5867 delimiter: &SharedString,
5868 folded_ancestors: &FoldedAncestors,
5869 cx: &Context<Self>,
5870 ) -> Div {
5871 let delimiter_target_index = index - 1;
5872 let target_entry_id = folded_ancestors
5873 .ancestors
5874 .get(components_len - 1 - delimiter_target_index)
5875 .cloned();
5876 div()
5877 .when(!is_sticky, |div| {
5878 div.when(drag_and_drop_enabled, |div| {
5879 div.on_drop(cx.listener(
5880 move |this, selections: &DraggedSelection, window, cx| {
5881 this.hover_scroll_task.take();
5882 this.drag_target_entry = None;
5883 this.folded_directory_drag_target = None;
5884 if let Some(target_entry_id) = target_entry_id {
5885 this.drag_onto(selections, target_entry_id, is_file, window, cx);
5886 }
5887 },
5888 ))
5889 .on_drag_move(cx.listener(
5890 move |this, event: &DragMoveEvent<DraggedSelection>, _, _| {
5891 if event.bounds.contains(&event.event.position) {
5892 this.folded_directory_drag_target =
5893 Some(FoldedDirectoryDragTarget {
5894 entry_id,
5895 index: delimiter_target_index,
5896 is_delimiter_target: true,
5897 });
5898 } else {
5899 let is_current_target =
5900 this.folded_directory_drag_target.is_some_and(|target| {
5901 target.entry_id == entry_id
5902 && target.index == delimiter_target_index
5903 && target.is_delimiter_target
5904 });
5905 if is_current_target {
5906 this.folded_directory_drag_target = None;
5907 }
5908 }
5909 },
5910 ))
5911 })
5912 })
5913 .child(
5914 Label::new(delimiter.clone())
5915 .single_line()
5916 .color(filename_text_color),
5917 )
5918 }
5919
5920 fn details_for_entry(
5921 &self,
5922 entry: &Entry,
5923 worktree_id: WorktreeId,
5924 root_name: &RelPath,
5925 entries_paths: &HashSet<Arc<RelPath>>,
5926 git_status: GitSummary,
5927 sticky: Option<StickyDetails>,
5928 _window: &mut Window,
5929 cx: &mut Context<Self>,
5930 ) -> EntryDetails {
5931 let (show_file_icons, show_folder_icons) = {
5932 let settings = ProjectPanelSettings::get_global(cx);
5933 (settings.file_icons, settings.folder_icons)
5934 };
5935
5936 let expanded_entry_ids = self
5937 .state
5938 .expanded_dir_ids
5939 .get(&worktree_id)
5940 .map(Vec::as_slice)
5941 .unwrap_or(&[]);
5942 let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok();
5943
5944 let icon = match entry.kind {
5945 EntryKind::File => {
5946 if show_file_icons {
5947 FileIcons::get_icon(entry.path.as_std_path(), cx)
5948 } else {
5949 None
5950 }
5951 }
5952 _ => {
5953 if show_folder_icons {
5954 FileIcons::get_folder_icon(is_expanded, entry.path.as_std_path(), cx)
5955 } else {
5956 FileIcons::get_chevron_icon(is_expanded, cx)
5957 }
5958 }
5959 };
5960
5961 let path_style = self.project.read(cx).path_style(cx);
5962 let (depth, difference) =
5963 ProjectPanel::calculate_depth_and_difference(entry, entries_paths);
5964
5965 let filename = if difference > 1 {
5966 entry
5967 .path
5968 .last_n_components(difference)
5969 .map_or(String::new(), |suffix| {
5970 suffix.display(path_style).to_string()
5971 })
5972 } else {
5973 entry
5974 .path
5975 .file_name()
5976 .map(|name| name.to_string())
5977 .unwrap_or_else(|| root_name.as_unix_str().to_string())
5978 };
5979
5980 let selection = SelectedEntry {
5981 worktree_id,
5982 entry_id: entry.id,
5983 };
5984 let is_marked = self.marked_entries.contains(&selection);
5985 let is_selected = self.selection == Some(selection);
5986
5987 let diagnostic_severity = self
5988 .diagnostics
5989 .get(&(worktree_id, entry.path.clone()))
5990 .cloned();
5991
5992 let diagnostic_count = self
5993 .diagnostic_counts
5994 .get(&(worktree_id, entry.path.clone()))
5995 .copied();
5996
5997 let filename_text_color =
5998 entry_git_aware_label_color(git_status, entry.is_ignored, is_marked);
5999
6000 let is_cut = self
6001 .clipboard
6002 .as_ref()
6003 .is_some_and(|e| e.is_cut() && e.items().contains(&selection));
6004
6005 EntryDetails {
6006 filename,
6007 icon,
6008 path: entry.path.clone(),
6009 depth,
6010 kind: entry.kind,
6011 is_ignored: entry.is_ignored,
6012 is_expanded,
6013 is_selected,
6014 is_marked,
6015 is_editing: false,
6016 is_processing: false,
6017 is_cut,
6018 sticky,
6019 filename_text_color,
6020 diagnostic_severity,
6021 diagnostic_count,
6022 git_status,
6023 is_private: entry.is_private,
6024 worktree_id,
6025 canonical_path: entry.canonical_path.clone(),
6026 }
6027 }
6028
6029 fn dispatch_context(&self, window: &Window, cx: &Context<Self>) -> KeyContext {
6030 let mut dispatch_context = KeyContext::new_with_defaults();
6031 dispatch_context.add("ProjectPanel");
6032 dispatch_context.add("menu");
6033
6034 let identifier = if self.filename_editor.focus_handle(cx).is_focused(window) {
6035 "editing"
6036 } else {
6037 "not_editing"
6038 };
6039
6040 dispatch_context.add(identifier);
6041 dispatch_context
6042 }
6043
6044 fn reveal_entry(
6045 &mut self,
6046 project: Entity<Project>,
6047 entry_id: ProjectEntryId,
6048 skip_ignored: bool,
6049 window: &mut Window,
6050 cx: &mut Context<Self>,
6051 ) -> Result<()> {
6052 let worktree = project
6053 .read(cx)
6054 .worktree_for_entry(entry_id, cx)
6055 .context("can't reveal a non-existent entry in the project panel")?;
6056 let worktree = worktree.read(cx);
6057 let worktree_id = worktree.id();
6058 let is_ignored = worktree
6059 .entry_for_id(entry_id)
6060 .is_none_or(|entry| entry.is_ignored && !entry.is_always_included);
6061 if skip_ignored && is_ignored {
6062 if self.index_for_entry(entry_id, worktree_id).is_none() {
6063 anyhow::bail!("can't reveal an ignored entry in the project panel");
6064 }
6065
6066 self.selection = Some(SelectedEntry {
6067 worktree_id,
6068 entry_id,
6069 });
6070 self.marked_entries.clear();
6071 self.marked_entries.push(SelectedEntry {
6072 worktree_id,
6073 entry_id,
6074 });
6075 self.autoscroll(cx);
6076 cx.notify();
6077 return Ok(());
6078 }
6079 let is_active_item_file_diff_view = self
6080 .workspace
6081 .upgrade()
6082 .and_then(|ws| ws.read(cx).active_item(cx))
6083 .map(|item| item.act_as_type(TypeId::of::<FileDiffView>(), cx).is_some())
6084 .unwrap_or(false);
6085 if is_active_item_file_diff_view {
6086 return Ok(());
6087 }
6088
6089 self.expand_entry(worktree_id, entry_id, cx);
6090 self.update_visible_entries(Some((worktree_id, entry_id)), false, true, window, cx);
6091 self.marked_entries.clear();
6092 self.marked_entries.push(SelectedEntry {
6093 worktree_id,
6094 entry_id,
6095 });
6096 cx.notify();
6097 Ok(())
6098 }
6099
6100 fn find_active_indent_guide(
6101 &self,
6102 indent_guides: &[IndentGuideLayout],
6103 cx: &App,
6104 ) -> Option<usize> {
6105 let (worktree, entry) = self.selected_entry(cx)?;
6106
6107 // Find the parent entry of the indent guide, this will either be the
6108 // expanded folder we have selected, or the parent of the currently
6109 // selected file/collapsed directory
6110 let mut entry = entry;
6111 loop {
6112 let is_expanded_dir = entry.is_dir()
6113 && self
6114 .state
6115 .expanded_dir_ids
6116 .get(&worktree.id())
6117 .map(|ids| ids.binary_search(&entry.id).is_ok())
6118 .unwrap_or(false);
6119 if is_expanded_dir {
6120 break;
6121 }
6122 entry = worktree.entry_for_path(&entry.path.parent()?)?;
6123 }
6124
6125 let (active_indent_range, depth) = {
6126 let (worktree_ix, child_offset, ix) = self.index_for_entry(entry.id, worktree.id())?;
6127 let child_paths = &self.state.visible_entries[worktree_ix].entries;
6128 let mut child_count = 0;
6129 let depth = entry.path.ancestors().count();
6130 while let Some(entry) = child_paths.get(child_offset + child_count + 1) {
6131 if entry.path.ancestors().count() <= depth {
6132 break;
6133 }
6134 child_count += 1;
6135 }
6136
6137 let start = ix + 1;
6138 let end = start + child_count;
6139
6140 let visible_worktree = &self.state.visible_entries[worktree_ix];
6141 let visible_worktree_entries = visible_worktree.index.get_or_init(|| {
6142 visible_worktree
6143 .entries
6144 .iter()
6145 .map(|e| e.path.clone())
6146 .collect()
6147 });
6148
6149 // Calculate the actual depth of the entry, taking into account that directories can be auto-folded.
6150 let (depth, _) = Self::calculate_depth_and_difference(entry, visible_worktree_entries);
6151 (start..end, depth)
6152 };
6153
6154 let candidates = indent_guides
6155 .iter()
6156 .enumerate()
6157 .filter(|(_, indent_guide)| indent_guide.offset.x == depth);
6158
6159 for (i, indent) in candidates {
6160 // Find matches that are either an exact match, partially on screen, or inside the enclosing indent
6161 if active_indent_range.start <= indent.offset.y + indent.length
6162 && indent.offset.y <= active_indent_range.end
6163 {
6164 return Some(i);
6165 }
6166 }
6167 None
6168 }
6169
6170 fn render_sticky_entries(
6171 &self,
6172 child: StickyProjectPanelCandidate,
6173 window: &mut Window,
6174 cx: &mut Context<Self>,
6175 ) -> SmallVec<[AnyElement; 8]> {
6176 let project = self.project.read(cx);
6177
6178 let Some((worktree_id, entry_ref)) = self.entry_at_index(child.index) else {
6179 return SmallVec::new();
6180 };
6181
6182 let Some(visible) = self
6183 .state
6184 .visible_entries
6185 .iter()
6186 .find(|worktree| worktree.worktree_id == worktree_id)
6187 else {
6188 return SmallVec::new();
6189 };
6190
6191 let Some(worktree) = project.worktree_for_id(worktree_id, cx) else {
6192 return SmallVec::new();
6193 };
6194 let worktree = worktree.read(cx).snapshot();
6195
6196 let paths = visible
6197 .index
6198 .get_or_init(|| visible.entries.iter().map(|e| e.path.clone()).collect());
6199
6200 let mut sticky_parents = Vec::new();
6201 let mut current_path = entry_ref.path.clone();
6202
6203 'outer: loop {
6204 if let Some(parent_path) = current_path.parent() {
6205 for ancestor_path in parent_path.ancestors() {
6206 if paths.contains(ancestor_path)
6207 && let Some(parent_entry) = worktree.entry_for_path(ancestor_path)
6208 {
6209 sticky_parents.push(parent_entry.clone());
6210 current_path = parent_entry.path.clone();
6211 continue 'outer;
6212 }
6213 }
6214 }
6215 break 'outer;
6216 }
6217
6218 if sticky_parents.is_empty() {
6219 return SmallVec::new();
6220 }
6221
6222 sticky_parents.reverse();
6223
6224 let panel_settings = ProjectPanelSettings::get_global(cx);
6225 let git_status_enabled = panel_settings.git_status;
6226 let root_name = worktree.root_name();
6227
6228 let git_summaries_by_id = if git_status_enabled {
6229 visible
6230 .entries
6231 .iter()
6232 .map(|e| (e.id, e.git_summary))
6233 .collect::<HashMap<_, _>>()
6234 } else {
6235 Default::default()
6236 };
6237
6238 // already checked if non empty above
6239 let last_item_index = sticky_parents.len() - 1;
6240 sticky_parents
6241 .iter()
6242 .enumerate()
6243 .map(|(index, entry)| {
6244 let git_status = git_summaries_by_id
6245 .get(&entry.id)
6246 .copied()
6247 .unwrap_or_default();
6248 let sticky_details = Some(StickyDetails {
6249 sticky_index: index,
6250 });
6251 let details = self.details_for_entry(
6252 entry,
6253 worktree_id,
6254 root_name,
6255 paths,
6256 git_status,
6257 sticky_details,
6258 window,
6259 cx,
6260 );
6261 self.render_entry(entry.id, details, window, cx)
6262 .when(index == last_item_index, |this| {
6263 let shadow_color_top = hsla(0.0, 0.0, 0.0, 0.1);
6264 let shadow_color_bottom = hsla(0.0, 0.0, 0.0, 0.);
6265 let sticky_shadow = div()
6266 .absolute()
6267 .left_0()
6268 .bottom_neg_1p5()
6269 .h_1p5()
6270 .w_full()
6271 .bg(linear_gradient(
6272 0.,
6273 linear_color_stop(shadow_color_top, 1.),
6274 linear_color_stop(shadow_color_bottom, 0.),
6275 ));
6276 this.child(sticky_shadow)
6277 })
6278 .into_any()
6279 })
6280 .collect()
6281 }
6282}
6283
6284#[derive(Clone)]
6285struct StickyProjectPanelCandidate {
6286 index: usize,
6287 depth: usize,
6288}
6289
6290impl StickyCandidate for StickyProjectPanelCandidate {
6291 fn depth(&self) -> usize {
6292 self.depth
6293 }
6294}
6295
6296fn item_width_estimate(depth: usize, item_text_chars: usize, is_symlink: bool) -> usize {
6297 const ICON_SIZE_FACTOR: usize = 2;
6298 let mut item_width = depth * ICON_SIZE_FACTOR + item_text_chars;
6299 if is_symlink {
6300 item_width += ICON_SIZE_FACTOR;
6301 }
6302 item_width
6303}
6304
6305impl Render for ProjectPanel {
6306 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
6307 let has_worktree = !self.state.visible_entries.is_empty();
6308 let project = self.project.read(cx);
6309 let panel_settings = ProjectPanelSettings::get_global(cx);
6310 let indent_size = panel_settings.indent_size;
6311 let show_indent_guides = panel_settings.indent_guides.show == ShowIndentGuides::Always;
6312 let show_sticky_entries = {
6313 if panel_settings.sticky_scroll {
6314 let is_scrollable = self.scroll_handle.is_scrollable();
6315 let is_scrolled = self.scroll_handle.offset().y < px(0.);
6316 is_scrollable && is_scrolled
6317 } else {
6318 false
6319 }
6320 };
6321
6322 let is_local = project.is_local();
6323
6324 if has_worktree {
6325 let item_count = self
6326 .state
6327 .visible_entries
6328 .iter()
6329 .map(|worktree| worktree.entries.len())
6330 .sum();
6331
6332 fn handle_drag_move<T: 'static>(
6333 this: &mut ProjectPanel,
6334 e: &DragMoveEvent<T>,
6335 window: &mut Window,
6336 cx: &mut Context<ProjectPanel>,
6337 ) {
6338 if let Some(previous_position) = this.previous_drag_position {
6339 // Refresh cursor only when an actual drag happens,
6340 // because modifiers are not updated when the cursor is not moved.
6341 if e.event.position != previous_position {
6342 this.refresh_drag_cursor_style(&e.event.modifiers, window, cx);
6343 }
6344 }
6345 this.previous_drag_position = Some(e.event.position);
6346
6347 if !e.bounds.contains(&e.event.position) {
6348 this.drag_target_entry = None;
6349 return;
6350 }
6351 this.hover_scroll_task.take();
6352 let panel_height = e.bounds.size.height;
6353 if panel_height <= px(0.) {
6354 return;
6355 }
6356
6357 let event_offset = e.event.position.y - e.bounds.origin.y;
6358 // How far along in the project panel is our cursor? (0. is the top of a list, 1. is the bottom)
6359 let hovered_region_offset = event_offset / panel_height;
6360
6361 // We want the scrolling to be a bit faster when the cursor is closer to the edge of a list.
6362 // These pixels offsets were picked arbitrarily.
6363 let vertical_scroll_offset = if hovered_region_offset <= 0.05 {
6364 8.
6365 } else if hovered_region_offset <= 0.15 {
6366 5.
6367 } else if hovered_region_offset >= 0.95 {
6368 -8.
6369 } else if hovered_region_offset >= 0.85 {
6370 -5.
6371 } else {
6372 return;
6373 };
6374 let adjustment = point(px(0.), px(vertical_scroll_offset));
6375 this.hover_scroll_task = Some(cx.spawn_in(window, async move |this, cx| {
6376 loop {
6377 let should_stop_scrolling = this
6378 .update(cx, |this, cx| {
6379 this.hover_scroll_task.as_ref()?;
6380 let handle = this.scroll_handle.0.borrow_mut();
6381 let offset = handle.base_handle.offset();
6382
6383 handle.base_handle.set_offset(offset + adjustment);
6384 cx.notify();
6385 Some(())
6386 })
6387 .ok()
6388 .flatten()
6389 .is_some();
6390 if should_stop_scrolling {
6391 return;
6392 }
6393 cx.background_executor()
6394 .timer(Duration::from_millis(16))
6395 .await;
6396 }
6397 }));
6398 }
6399 h_flex()
6400 .id("project-panel")
6401 .group("project-panel")
6402 .when(panel_settings.drag_and_drop, |this| {
6403 this.on_drag_move(cx.listener(handle_drag_move::<ExternalPaths>))
6404 .on_drag_move(cx.listener(handle_drag_move::<DraggedSelection>))
6405 })
6406 .size_full()
6407 .relative()
6408 .on_modifiers_changed(cx.listener(
6409 |this, event: &ModifiersChangedEvent, window, cx| {
6410 this.refresh_drag_cursor_style(&event.modifiers, window, cx);
6411 },
6412 ))
6413 .key_context(self.dispatch_context(window, cx))
6414 .on_action(cx.listener(Self::scroll_up))
6415 .on_action(cx.listener(Self::scroll_down))
6416 .on_action(cx.listener(Self::scroll_cursor_center))
6417 .on_action(cx.listener(Self::scroll_cursor_top))
6418 .on_action(cx.listener(Self::scroll_cursor_bottom))
6419 .on_action(cx.listener(Self::select_next))
6420 .on_action(cx.listener(Self::select_previous))
6421 .on_action(cx.listener(Self::select_first))
6422 .on_action(cx.listener(Self::select_last))
6423 .on_action(cx.listener(Self::select_parent))
6424 .on_action(cx.listener(Self::select_next_git_entry))
6425 .on_action(cx.listener(Self::select_prev_git_entry))
6426 .on_action(cx.listener(Self::select_next_diagnostic))
6427 .on_action(cx.listener(Self::select_prev_diagnostic))
6428 .on_action(cx.listener(Self::select_next_directory))
6429 .on_action(cx.listener(Self::select_prev_directory))
6430 .on_action(cx.listener(Self::expand_selected_entry))
6431 .on_action(cx.listener(Self::collapse_selected_entry))
6432 .on_action(cx.listener(Self::collapse_all_entries))
6433 .on_action(cx.listener(Self::collapse_selected_entry_and_children))
6434 .on_action(cx.listener(Self::open))
6435 .on_action(cx.listener(Self::open_permanent))
6436 .on_action(cx.listener(Self::open_split_vertical))
6437 .on_action(cx.listener(Self::open_split_horizontal))
6438 .on_action(cx.listener(Self::confirm))
6439 .on_action(cx.listener(Self::cancel))
6440 .on_action(cx.listener(Self::copy_path))
6441 .on_action(cx.listener(Self::copy_relative_path))
6442 .on_action(cx.listener(Self::new_search_in_directory))
6443 .on_action(cx.listener(Self::unfold_directory))
6444 .on_action(cx.listener(Self::fold_directory))
6445 .on_action(cx.listener(Self::remove_from_project))
6446 .on_action(cx.listener(Self::compare_marked_files))
6447 .when(!project.is_read_only(cx), |el| {
6448 el.on_action(cx.listener(Self::new_file))
6449 .on_action(cx.listener(Self::new_directory))
6450 .on_action(cx.listener(Self::rename))
6451 .on_action(cx.listener(Self::delete))
6452 .on_action(cx.listener(Self::cut))
6453 .on_action(cx.listener(Self::copy))
6454 .on_action(cx.listener(Self::paste))
6455 .on_action(cx.listener(Self::duplicate))
6456 .on_action(cx.listener(Self::restore_file))
6457 .when(!project.is_remote(), |el| {
6458 el.on_action(cx.listener(Self::trash))
6459 })
6460 })
6461 .when(
6462 project.is_local() || project.is_via_wsl_with_host_interop(cx),
6463 |el| {
6464 el.on_action(cx.listener(Self::reveal_in_finder))
6465 .on_action(cx.listener(Self::open_system))
6466 .on_action(cx.listener(Self::open_in_terminal))
6467 },
6468 )
6469 .when(project.is_via_remote_server(), |el| {
6470 el.on_action(cx.listener(Self::open_in_terminal))
6471 .on_action(cx.listener(Self::download_from_remote))
6472 })
6473 .track_focus(&self.focus_handle(cx))
6474 .child(
6475 v_flex()
6476 .child(
6477 uniform_list("entries", item_count, {
6478 cx.processor(|this, range: Range<usize>, window, cx| {
6479 this.rendered_entries_len = range.end - range.start;
6480 let mut items = Vec::with_capacity(this.rendered_entries_len);
6481 this.for_each_visible_entry(
6482 range,
6483 window,
6484 cx,
6485 &mut |id, details, window, cx| {
6486 items.push(this.render_entry(id, details, window, cx));
6487 },
6488 );
6489 items
6490 })
6491 })
6492 .when(show_indent_guides, |list| {
6493 list.with_decoration(
6494 ui::indent_guides(
6495 px(indent_size),
6496 IndentGuideColors::panel(cx),
6497 )
6498 .with_compute_indents_fn(
6499 cx.entity(),
6500 |this, range, window, cx| {
6501 let mut items =
6502 SmallVec::with_capacity(range.end - range.start);
6503 this.iter_visible_entries(
6504 range,
6505 window,
6506 cx,
6507 &mut |entry, _, entries, _, _| {
6508 let (depth, _) =
6509 Self::calculate_depth_and_difference(
6510 entry, entries,
6511 );
6512 items.push(depth);
6513 },
6514 );
6515 items
6516 },
6517 )
6518 .on_click(cx.listener(
6519 |this,
6520 active_indent_guide: &IndentGuideLayout,
6521 window,
6522 cx| {
6523 if window.modifiers().secondary() {
6524 let ix = active_indent_guide.offset.y;
6525 let Some((target_entry, worktree)) = maybe!({
6526 let (worktree_id, entry) =
6527 this.entry_at_index(ix)?;
6528 let worktree = this
6529 .project
6530 .read(cx)
6531 .worktree_for_id(worktree_id, cx)?;
6532 let target_entry = worktree
6533 .read(cx)
6534 .entry_for_path(&entry.path.parent()?)?;
6535 Some((target_entry, worktree))
6536 }) else {
6537 return;
6538 };
6539
6540 this.collapse_entry(
6541 target_entry.clone(),
6542 worktree,
6543 window,
6544 cx,
6545 );
6546 }
6547 },
6548 ))
6549 .with_render_fn(
6550 cx.entity(),
6551 move |this, params, _, cx| {
6552 const LEFT_OFFSET: Pixels = px(14.);
6553 const PADDING_Y: Pixels = px(4.);
6554 const HITBOX_OVERDRAW: Pixels = px(3.);
6555
6556 let active_indent_guide_index = this
6557 .find_active_indent_guide(
6558 ¶ms.indent_guides,
6559 cx,
6560 );
6561
6562 let indent_size = params.indent_size;
6563 let item_height = params.item_height;
6564
6565 params
6566 .indent_guides
6567 .into_iter()
6568 .enumerate()
6569 .map(|(idx, layout)| {
6570 let offset = if layout.continues_offscreen {
6571 px(0.)
6572 } else {
6573 PADDING_Y
6574 };
6575 let bounds = Bounds::new(
6576 point(
6577 layout.offset.x * indent_size
6578 + LEFT_OFFSET,
6579 layout.offset.y * item_height + offset,
6580 ),
6581 size(
6582 px(1.),
6583 layout.length * item_height
6584 - offset * 2.,
6585 ),
6586 );
6587 ui::RenderedIndentGuide {
6588 bounds,
6589 layout,
6590 is_active: Some(idx)
6591 == active_indent_guide_index,
6592 hitbox: Some(Bounds::new(
6593 point(
6594 bounds.origin.x - HITBOX_OVERDRAW,
6595 bounds.origin.y,
6596 ),
6597 size(
6598 bounds.size.width
6599 + HITBOX_OVERDRAW * 2.,
6600 bounds.size.height,
6601 ),
6602 )),
6603 }
6604 })
6605 .collect()
6606 },
6607 ),
6608 )
6609 })
6610 .when(show_sticky_entries, |list| {
6611 let sticky_items = ui::sticky_items(
6612 cx.entity(),
6613 |this, range, window, cx| {
6614 let mut items =
6615 SmallVec::with_capacity(range.end - range.start);
6616 this.iter_visible_entries(
6617 range,
6618 window,
6619 cx,
6620 &mut |entry, index, entries, _, _| {
6621 let (depth, _) =
6622 Self::calculate_depth_and_difference(
6623 entry, entries,
6624 );
6625 let candidate =
6626 StickyProjectPanelCandidate { index, depth };
6627 items.push(candidate);
6628 },
6629 );
6630 items
6631 },
6632 |this, marker_entry, window, cx| {
6633 let sticky_entries =
6634 this.render_sticky_entries(marker_entry, window, cx);
6635 this.sticky_items_count = sticky_entries.len();
6636 sticky_entries
6637 },
6638 );
6639 list.with_decoration(if show_indent_guides {
6640 sticky_items.with_decoration(
6641 ui::indent_guides(
6642 px(indent_size),
6643 IndentGuideColors::panel(cx),
6644 )
6645 .with_render_fn(
6646 cx.entity(),
6647 move |_, params, _, _| {
6648 const LEFT_OFFSET: Pixels = px(14.);
6649
6650 let indent_size = params.indent_size;
6651 let item_height = params.item_height;
6652
6653 params
6654 .indent_guides
6655 .into_iter()
6656 .map(|layout| {
6657 let bounds = Bounds::new(
6658 point(
6659 layout.offset.x * indent_size
6660 + LEFT_OFFSET,
6661 layout.offset.y * item_height,
6662 ),
6663 size(
6664 px(1.),
6665 layout.length * item_height,
6666 ),
6667 );
6668 ui::RenderedIndentGuide {
6669 bounds,
6670 layout,
6671 is_active: false,
6672 hitbox: None,
6673 }
6674 })
6675 .collect()
6676 },
6677 ),
6678 )
6679 } else {
6680 sticky_items
6681 })
6682 })
6683 .with_sizing_behavior(ListSizingBehavior::Infer)
6684 .with_horizontal_sizing_behavior(
6685 ListHorizontalSizingBehavior::Unconstrained,
6686 )
6687 .with_width_from_item(self.state.max_width_item_index)
6688 .track_scroll(&self.scroll_handle),
6689 )
6690 .child(
6691 div()
6692 .id("project-panel-blank-area")
6693 .block_mouse_except_scroll()
6694 .flex_grow()
6695 .on_scroll_wheel({
6696 let scroll_handle = self.scroll_handle.clone();
6697 let entity_id = cx.entity().entity_id();
6698 move |event, window, cx| {
6699 let state = scroll_handle.0.borrow();
6700 let base_handle = &state.base_handle;
6701 let current_offset = base_handle.offset();
6702 let max_offset = base_handle.max_offset();
6703 let delta = event.delta.pixel_delta(window.line_height());
6704 let new_offset = (current_offset + delta)
6705 .clamp(&max_offset.neg(), &Point::default());
6706
6707 if new_offset != current_offset {
6708 base_handle.set_offset(new_offset);
6709 cx.notify(entity_id);
6710 }
6711 }
6712 })
6713 .when(
6714 self.drag_target_entry.as_ref().is_some_and(
6715 |entry| match entry {
6716 DragTarget::Background => true,
6717 DragTarget::Entry {
6718 highlight_entry_id, ..
6719 } => self.state.last_worktree_root_id.is_some_and(
6720 |root_id| *highlight_entry_id == root_id,
6721 ),
6722 },
6723 ),
6724 |div| div.bg(cx.theme().colors().drop_target_background),
6725 )
6726 .on_drag_move::<ExternalPaths>(cx.listener(
6727 move |this, event: &DragMoveEvent<ExternalPaths>, _, _| {
6728 let Some(_last_root_id) = this.state.last_worktree_root_id
6729 else {
6730 return;
6731 };
6732 if event.bounds.contains(&event.event.position) {
6733 this.drag_target_entry = Some(DragTarget::Background);
6734 } else {
6735 if this.drag_target_entry.as_ref().is_some_and(|e| {
6736 matches!(e, DragTarget::Background)
6737 }) {
6738 this.drag_target_entry = None;
6739 }
6740 }
6741 },
6742 ))
6743 .on_drag_move::<DraggedSelection>(cx.listener(
6744 move |this, event: &DragMoveEvent<DraggedSelection>, _, cx| {
6745 let Some(last_root_id) = this.state.last_worktree_root_id
6746 else {
6747 return;
6748 };
6749 if event.bounds.contains(&event.event.position) {
6750 let drag_state = event.drag(cx);
6751 if this.should_highlight_background_for_selection_drag(
6752 &drag_state,
6753 last_root_id,
6754 cx,
6755 ) {
6756 this.drag_target_entry =
6757 Some(DragTarget::Background);
6758 }
6759 } else {
6760 if this.drag_target_entry.as_ref().is_some_and(|e| {
6761 matches!(e, DragTarget::Background)
6762 }) {
6763 this.drag_target_entry = None;
6764 }
6765 }
6766 },
6767 ))
6768 .on_drop(cx.listener(
6769 move |this, external_paths: &ExternalPaths, window, cx| {
6770 this.drag_target_entry = None;
6771 this.hover_scroll_task.take();
6772 if let Some(entry_id) = this.state.last_worktree_root_id {
6773 this.drop_external_files(
6774 external_paths.paths(),
6775 entry_id,
6776 window,
6777 cx,
6778 );
6779 }
6780 cx.stop_propagation();
6781 },
6782 ))
6783 .on_drop(cx.listener(
6784 move |this, selections: &DraggedSelection, window, cx| {
6785 this.drag_target_entry = None;
6786 this.hover_scroll_task.take();
6787 if let Some(entry_id) = this.state.last_worktree_root_id {
6788 this.drag_onto(selections, entry_id, false, window, cx);
6789 }
6790 cx.stop_propagation();
6791 },
6792 ))
6793 .on_click(cx.listener(|this, event, window, cx| {
6794 if matches!(event, gpui::ClickEvent::Keyboard(_)) {
6795 return;
6796 }
6797 cx.stop_propagation();
6798 this.selection = None;
6799 this.marked_entries.clear();
6800 this.focus_handle(cx).focus(window, cx);
6801 }))
6802 .on_mouse_down(
6803 MouseButton::Right,
6804 cx.listener(move |this, event: &MouseDownEvent, window, cx| {
6805 // When deploying the context menu anywhere below the last project entry,
6806 // act as if the user clicked the root of the last worktree.
6807 if let Some(entry_id) = this.state.last_worktree_root_id {
6808 this.deploy_context_menu(
6809 event.position,
6810 entry_id,
6811 window,
6812 cx,
6813 );
6814 }
6815 }),
6816 )
6817 .when(!project.is_read_only(cx), |el| {
6818 el.on_click(cx.listener(
6819 |this, event: &gpui::ClickEvent, window, cx| {
6820 if event.click_count() > 1
6821 && let Some(entry_id) =
6822 this.state.last_worktree_root_id
6823 {
6824 let project = this.project.read(cx);
6825
6826 let worktree_id = if let Some(worktree) =
6827 project.worktree_for_entry(entry_id, cx)
6828 {
6829 worktree.read(cx).id()
6830 } else {
6831 return;
6832 };
6833
6834 this.selection = Some(SelectedEntry {
6835 worktree_id,
6836 entry_id,
6837 });
6838
6839 this.new_file(&NewFile, window, cx);
6840 }
6841 },
6842 ))
6843 }),
6844 )
6845 .size_full(),
6846 )
6847 .custom_scrollbars(
6848 Scrollbars::for_settings::<ProjectPanelSettings>()
6849 .tracked_scroll_handle(&self.scroll_handle)
6850 .with_track_along(
6851 ScrollAxes::Horizontal,
6852 cx.theme().colors().panel_background,
6853 )
6854 .notify_content(),
6855 window,
6856 cx,
6857 )
6858 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
6859 deferred(
6860 anchored()
6861 .position(*position)
6862 .anchor(gpui::Corner::TopLeft)
6863 .child(menu.clone()),
6864 )
6865 .with_priority(3)
6866 }))
6867 } else {
6868 let focus_handle = self.focus_handle(cx);
6869
6870 v_flex()
6871 .id("empty-project_panel")
6872 .p_4()
6873 .size_full()
6874 .items_center()
6875 .justify_center()
6876 .gap_1()
6877 .track_focus(&self.focus_handle(cx))
6878 .child(
6879 Button::new("open_project", "Open Project")
6880 .full_width()
6881 .key_binding(KeyBinding::for_action_in(
6882 &workspace::Open::default(),
6883 &focus_handle,
6884 cx,
6885 ))
6886 .on_click(cx.listener(|this, _, window, cx| {
6887 this.workspace
6888 .update(cx, |_, cx| {
6889 window.dispatch_action(
6890 workspace::Open::default().boxed_clone(),
6891 cx,
6892 );
6893 })
6894 .log_err();
6895 })),
6896 )
6897 .child(
6898 h_flex()
6899 .w_1_2()
6900 .gap_2()
6901 .child(Divider::horizontal())
6902 .child(Label::new("or").size(LabelSize::XSmall).color(Color::Muted))
6903 .child(Divider::horizontal()),
6904 )
6905 .child(
6906 Button::new("clone_repo", "Clone Repository")
6907 .full_width()
6908 .on_click(cx.listener(|this, _, window, cx| {
6909 this.workspace
6910 .update(cx, |_, cx| {
6911 window.dispatch_action(git::Clone.boxed_clone(), cx);
6912 })
6913 .log_err();
6914 })),
6915 )
6916 .when(is_local, |div| {
6917 div.when(panel_settings.drag_and_drop, |div| {
6918 div.drag_over::<ExternalPaths>(|style, _, _, cx| {
6919 style.bg(cx.theme().colors().drop_target_background)
6920 })
6921 .on_drop(cx.listener(
6922 move |this, external_paths: &ExternalPaths, window, cx| {
6923 this.drag_target_entry = None;
6924 this.hover_scroll_task.take();
6925 if let Some(task) = this
6926 .workspace
6927 .update(cx, |workspace, cx| {
6928 workspace.open_workspace_for_paths(
6929 true,
6930 external_paths.paths().to_owned(),
6931 window,
6932 cx,
6933 )
6934 })
6935 .log_err()
6936 {
6937 task.detach_and_log_err(cx);
6938 }
6939 cx.stop_propagation();
6940 },
6941 ))
6942 })
6943 })
6944 }
6945 }
6946}
6947
6948impl Render for DraggedProjectEntryView {
6949 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
6950 let ui_font = ThemeSettings::get_global(cx).ui_font.clone();
6951 h_flex()
6952 .font(ui_font)
6953 .pl(self.click_offset.x + px(12.))
6954 .pt(self.click_offset.y + px(12.))
6955 .child(
6956 div()
6957 .flex()
6958 .gap_1()
6959 .items_center()
6960 .py_1()
6961 .px_2()
6962 .rounded_lg()
6963 .bg(cx.theme().colors().background)
6964 .map(|this| {
6965 if self.selections.len() > 1 && self.selections.contains(&self.selection) {
6966 this.child(Label::new(format!("{} entries", self.selections.len())))
6967 } else {
6968 this.child(if let Some(icon) = &self.icon {
6969 div().child(Icon::from_path(icon.clone()))
6970 } else {
6971 div()
6972 })
6973 .child(Label::new(self.filename.clone()))
6974 }
6975 }),
6976 )
6977 }
6978}
6979
6980impl EventEmitter<Event> for ProjectPanel {}
6981
6982impl EventEmitter<PanelEvent> for ProjectPanel {}
6983
6984impl Panel for ProjectPanel {
6985 fn position(&self, _: &Window, cx: &App) -> DockPosition {
6986 match ProjectPanelSettings::get_global(cx).dock {
6987 DockSide::Left => DockPosition::Left,
6988 DockSide::Right => DockPosition::Right,
6989 }
6990 }
6991
6992 fn position_is_valid(&self, position: DockPosition) -> bool {
6993 matches!(position, DockPosition::Left | DockPosition::Right)
6994 }
6995
6996 fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
6997 settings::update_settings_file(self.fs.clone(), cx, move |settings, _| {
6998 let dock = match position {
6999 DockPosition::Left | DockPosition::Bottom => DockSide::Left,
7000 DockPosition::Right => DockSide::Right,
7001 };
7002 settings.project_panel.get_or_insert_default().dock = Some(dock);
7003 });
7004 }
7005
7006 fn size(&self, _: &Window, cx: &App) -> Pixels {
7007 self.width
7008 .unwrap_or_else(|| ProjectPanelSettings::get_global(cx).default_width)
7009 }
7010
7011 fn set_size(&mut self, size: Option<Pixels>, window: &mut Window, cx: &mut Context<Self>) {
7012 self.width = size;
7013 cx.notify();
7014 cx.defer_in(window, |this, _, cx| {
7015 this.serialize(cx);
7016 });
7017 }
7018
7019 fn icon(&self, _: &Window, cx: &App) -> Option<IconName> {
7020 ProjectPanelSettings::get_global(cx)
7021 .button
7022 .then_some(IconName::FileTree)
7023 }
7024
7025 fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
7026 Some("Project Panel")
7027 }
7028
7029 fn toggle_action(&self) -> Box<dyn Action> {
7030 Box::new(ToggleFocus)
7031 }
7032
7033 fn persistent_name() -> &'static str {
7034 "Project Panel"
7035 }
7036
7037 fn panel_key() -> &'static str {
7038 PROJECT_PANEL_KEY
7039 }
7040
7041 fn starts_open(&self, _: &Window, cx: &App) -> bool {
7042 if !ProjectPanelSettings::get_global(cx).starts_open {
7043 return false;
7044 }
7045
7046 let project = &self.project.read(cx);
7047 project.visible_worktrees(cx).any(|tree| {
7048 tree.read(cx)
7049 .root_entry()
7050 .is_some_and(|entry| entry.is_dir())
7051 })
7052 }
7053
7054 fn activation_priority(&self) -> u32 {
7055 0
7056 }
7057}
7058
7059impl Focusable for ProjectPanel {
7060 fn focus_handle(&self, _cx: &App) -> FocusHandle {
7061 self.focus_handle.clone()
7062 }
7063}
7064
7065impl ClipboardEntry {
7066 fn is_cut(&self) -> bool {
7067 matches!(self, Self::Cut { .. })
7068 }
7069
7070 fn items(&self) -> &BTreeSet<SelectedEntry> {
7071 match self {
7072 ClipboardEntry::Copied(entries) | ClipboardEntry::Cut(entries) => entries,
7073 }
7074 }
7075
7076 fn into_copy_entry(self) -> Self {
7077 match self {
7078 ClipboardEntry::Copied(_) => self,
7079 ClipboardEntry::Cut(entries) => ClipboardEntry::Copied(entries),
7080 }
7081 }
7082}
7083
7084#[inline]
7085fn cmp_directories_first(a: &Entry, b: &Entry) -> cmp::Ordering {
7086 util::paths::compare_rel_paths((&a.path, a.is_file()), (&b.path, b.is_file()))
7087}
7088
7089#[inline]
7090fn cmp_mixed(a: &Entry, b: &Entry) -> cmp::Ordering {
7091 util::paths::compare_rel_paths_mixed((&a.path, a.is_file()), (&b.path, b.is_file()))
7092}
7093
7094#[inline]
7095fn cmp_files_first(a: &Entry, b: &Entry) -> cmp::Ordering {
7096 util::paths::compare_rel_paths_files_first((&a.path, a.is_file()), (&b.path, b.is_file()))
7097}
7098
7099#[inline]
7100fn cmp_with_mode(a: &Entry, b: &Entry, mode: &settings::ProjectPanelSortMode) -> cmp::Ordering {
7101 match mode {
7102 settings::ProjectPanelSortMode::DirectoriesFirst => cmp_directories_first(a, b),
7103 settings::ProjectPanelSortMode::Mixed => cmp_mixed(a, b),
7104 settings::ProjectPanelSortMode::FilesFirst => cmp_files_first(a, b),
7105 }
7106}
7107
7108pub fn sort_worktree_entries_with_mode(
7109 entries: &mut [impl AsRef<Entry>],
7110 mode: settings::ProjectPanelSortMode,
7111) {
7112 entries.sort_by(|lhs, rhs| cmp_with_mode(lhs.as_ref(), rhs.as_ref(), &mode));
7113}
7114
7115pub fn par_sort_worktree_entries_with_mode(
7116 entries: &mut Vec<GitEntry>,
7117 mode: settings::ProjectPanelSortMode,
7118) {
7119 entries.par_sort_by(|lhs, rhs| cmp_with_mode(lhs, rhs, &mode));
7120}
7121
7122#[cfg(test)]
7123mod project_panel_tests;