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