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