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