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