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