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