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