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