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