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