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