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