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