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