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