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