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