add ProjectFileIndex#getSourceFolder to simplify clients (IDEA-CR-57371)
[idea/community.git] / platform / lang-impl / src / com / intellij / ide / actions / CreateDirectoryOrPackageAction.java
1 // Copyright 2000-2019 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
2 package com.intellij.ide.actions;
3
4 import com.intellij.icons.AllIcons;
5 import com.intellij.ide.IdeBundle;
6 import com.intellij.ide.IdeView;
7 import com.intellij.ide.projectView.actions.MarkRootActionBase;
8 import com.intellij.ide.ui.newItemPopup.NewItemPopupUtil;
9 import com.intellij.ide.ui.newItemPopup.NewItemWithTemplatesPopupPanel;
10 import com.intellij.ide.util.DirectoryChooserUtil;
11 import com.intellij.internal.statistic.eventLog.FeatureUsageData;
12 import com.intellij.internal.statistic.service.fus.collectors.FUCounterUsageLogger;
13 import com.intellij.internal.statistic.utils.StatisticsUtilKt;
14 import com.intellij.openapi.actionSystem.*;
15 import com.intellij.openapi.application.ApplicationManager;
16 import com.intellij.openapi.application.Experiments;
17 import com.intellij.openapi.application.WriteAction;
18 import com.intellij.openapi.extensions.ExtensionPointName;
19 import com.intellij.openapi.module.Module;
20 import com.intellij.openapi.project.DumbAware;
21 import com.intellij.openapi.project.Project;
22 import com.intellij.openapi.roots.ContentEntry;
23 import com.intellij.openapi.roots.ModifiableRootModel;
24 import com.intellij.openapi.roots.ModuleRootManager;
25 import com.intellij.openapi.roots.ProjectFileIndex;
26 import com.intellij.openapi.roots.ex.ProjectRootManagerEx;
27 import com.intellij.openapi.roots.ui.configuration.ModuleSourceRootEditHandler;
28 import com.intellij.openapi.ui.Messages;
29 import com.intellij.openapi.ui.popup.JBPopup;
30 import com.intellij.openapi.util.Pair;
31 import com.intellij.openapi.util.TextRange;
32 import com.intellij.openapi.util.io.FileUtil;
33 import com.intellij.openapi.util.text.StringUtil;
34 import com.intellij.openapi.vfs.VirtualFile;
35 import com.intellij.psi.PsiDirectory;
36 import com.intellij.psi.PsiElement;
37 import com.intellij.psi.PsiFileSystemItem;
38 import com.intellij.psi.codeStyle.MinusculeMatcher;
39 import com.intellij.psi.codeStyle.NameUtil;
40 import com.intellij.psi.impl.file.PsiDirectoryFactory;
41 import com.intellij.ui.*;
42 import com.intellij.ui.speedSearch.SpeedSearchUtil;
43 import com.intellij.util.PlatformIcons;
44 import com.intellij.util.containers.ContainerUtil;
45 import com.intellij.util.containers.FList;
46 import com.intellij.util.ui.UIUtil;
47 import org.jetbrains.annotations.NotNull;
48 import org.jetbrains.annotations.Nullable;
49 import org.jetbrains.annotations.TestOnly;
50 import org.jetbrains.jps.model.module.JpsModuleSourceRootType;
51
52 import javax.swing.*;
53 import javax.swing.event.DocumentEvent;
54 import javax.swing.event.ListDataEvent;
55 import javax.swing.event.ListDataListener;
56 import java.awt.*;
57 import java.util.ArrayList;
58 import java.util.Collections;
59 import java.util.List;
60 import java.util.function.Consumer;
61
62 public class CreateDirectoryOrPackageAction extends AnAction implements DumbAware {
63   private static final ExtensionPointName<CreateDirectoryCompletionContributorEP>
64     EP = ExtensionPointName.create("com.intellij.createDirectoryCompletionContributor");
65
66   @TestOnly
67   public static final DataKey<String> TEST_DIRECTORY_NAME_KEY = DataKey.create("CreateDirectoryOrPackageAction.testName");
68
69   public CreateDirectoryOrPackageAction() {
70     super(IdeBundle.lazyMessage("action.create.new.directory.or.package"),
71           IdeBundle.lazyMessage("action.create.new.directory.or.package"), null);
72   }
73
74   @Override
75   public void actionPerformed(@NotNull AnActionEvent event) {
76     final IdeView view = event.getData(LangDataKeys.IDE_VIEW);
77     final Project project = event.getData(CommonDataKeys.PROJECT);
78     if (view == null || project == null) return;
79
80     final PsiDirectory directory = DirectoryChooserUtil.getOrChooseDirectory(view);
81     if (directory == null) return;
82
83     final CreateGroupHandler validator;
84     final String message, title;
85
86     if (PsiDirectoryFactory.getInstance(project).isPackage(directory)) {
87       validator = new CreatePackageHandler(project, directory);
88       message = IdeBundle.message("prompt.enter.new.package.name");
89       title = IdeBundle.message("title.new.package");
90     }
91     else {
92       validator = new CreateDirectoryHandler(project, directory);
93       message = IdeBundle.message("prompt.enter.new.directory.name");
94       title = IdeBundle.message("title.new.directory");
95     }
96
97     String initialText = validator.getInitialText();
98     Consumer<List<PsiElement>> consumer = elements -> {
99       // we don't have API for multi-selection in the views,
100       // so let's at least make sure the created elements are visible, and the first one is selected
101       for (PsiElement element : ContainerUtil.iterateBackward(elements)) {
102         view.selectElement(element);
103       }
104     };
105
106     if (ApplicationManager.getApplication().isUnitTestMode()) {
107       @SuppressWarnings("TestOnlyProblems")
108       String testDirectoryName = event.getData(TEST_DIRECTORY_NAME_KEY);
109       if (testDirectoryName != null && validator.checkInput(testDirectoryName) && validator.canClose(testDirectoryName)) {
110         consumer.accept(Collections.singletonList(validator.getCreatedElement()));
111         return;
112       }
113     }
114
115     if (Experiments.getInstance().isFeatureEnabled("show.create.new.element.in.popup")) {
116       createLightWeightPopup(title, initialText, directory, validator, consumer).showCenteredInCurrentWindow(project);
117     }
118     else {
119       Messages.showInputDialog(project, message, title, null, initialText, validator, TextRange.from(initialText.length(), 0));
120       consumer.accept(Collections.singletonList(validator.getCreatedElement()));
121     }
122   }
123
124   @Override
125   public void update(@NotNull AnActionEvent event) {
126     Presentation presentation = event.getPresentation();
127
128     Project project = event.getData(CommonDataKeys.PROJECT);
129     if (project == null) {
130       presentation.setEnabledAndVisible(false);
131       return;
132     }
133
134     IdeView view = event.getData(LangDataKeys.IDE_VIEW);
135     if (view == null) {
136       presentation.setEnabledAndVisible(false);
137       return;
138     }
139
140     final PsiDirectory[] directories = view.getDirectories();
141     if (directories.length == 0) {
142       presentation.setEnabledAndVisible(false);
143       return;
144     }
145
146     presentation.setEnabledAndVisible(true);
147
148     boolean isPackage = false;
149     final PsiDirectoryFactory factory = PsiDirectoryFactory.getInstance(project);
150     for (PsiDirectory directory : directories) {
151       if (factory.isPackage(directory)) {
152         isPackage = true;
153         break;
154       }
155     }
156
157     if (isPackage) {
158       presentation.setText(IdeBundle.lazyMessage("action.package"));
159       presentation.setIcon(PlatformIcons.PACKAGE_ICON);
160     }
161     else {
162       presentation.setText(IdeBundle.lazyMessage("action.directory"));
163       presentation.setIcon(PlatformIcons.FOLDER_ICON);
164     }
165   }
166
167   private static JBPopup createLightWeightPopup(String title,
168                                                 String initialText,
169                                                 @NotNull PsiDirectory directory,
170                                                 CreateGroupHandler validator,
171                                                 Consumer<List<PsiElement>> consumer) {
172     List<CompletionItem> variants = collectSuggestedDirectories(directory);
173     DirectoriesWithCompletionPopupPanel contentPanel = new DirectoriesWithCompletionPopupPanel(variants);
174
175     JTextField nameField = contentPanel.getTextField();
176     nameField.setText(initialText);
177     JBPopup popup = NewItemPopupUtil.createNewItemPopup(title, contentPanel, nameField);
178
179     contentPanel.setApplyAction(event -> {
180       for (CompletionItem it : contentPanel.getSelectedItems()) {
181         it.reportToStatistics();
182       }
183
184       // if there are selected suggestions, we need to create the selected folders (not the path in the text field)
185       List<Pair<String, JpsModuleSourceRootType<?>>> toCreate
186         = ContainerUtil.map(contentPanel.getSelectedItems(), item -> Pair.create(item.relativePath, item.rootType));
187
188       // when there are no selected suggestions, simply create the directory with the path from the text field
189       if (toCreate.isEmpty()) toCreate = Collections.singletonList(Pair.create(nameField.getText(), null));
190
191       List<PsiElement> created = createDirectories(toCreate, validator);
192       if (created != null) {
193         popup.closeOk(event);
194         consumer.accept(created);
195       }
196       else {
197         for (Pair<String, JpsModuleSourceRootType<?>> dir : toCreate) {
198           String errorText = validator.getErrorText(dir.first);
199           if (errorText != null) {
200             String errorMessage = validator.getErrorText(errorText);
201             contentPanel.setError(errorMessage);
202             break;
203           }
204         }
205       }
206     });
207
208     contentPanel.addTemplatesVisibilityListener(visible -> {
209       // The re-layout should be delayed since we are in the middle of model changes processing and not all components updated their states
210       //noinspection SSBasedInspection
211       SwingUtilities.invokeLater(() -> popup.pack(false, true));
212     });
213
214     return popup;
215   }
216
217   @NotNull
218   private static List<CompletionItem> collectSuggestedDirectories(@NotNull PsiDirectory directory) {
219     List<CompletionItem> variants = new ArrayList<>();
220
221     VirtualFile vDir = directory.getVirtualFile();
222     for (CreateDirectoryCompletionContributorEP ep : EP.getExtensionList()) {
223       CreateDirectoryCompletionContributor contributor = ep.getInstance();
224       for (CreateDirectoryCompletionContributor.Variant variant : contributor.getVariants(directory)) {
225         String relativePath = FileUtil.toSystemIndependentName(variant.path);
226
227         if (FileUtil.isAbsolutePlatformIndependent(relativePath)) {
228           // only suggest sub-folders
229           if (!FileUtil.isAncestor(vDir.getPath(), relativePath, true)) continue;
230
231           // convert absolute paths to the relative paths
232           relativePath = FileUtil.getRelativePath(vDir.getPath(), relativePath, '/');
233           if (relativePath == null) continue;
234         }
235
236         // only suggested non-existent folders
237         if (vDir.findFileByRelativePath(relativePath) != null) continue;
238
239         ModuleSourceRootEditHandler<?> handler =
240           variant.rootType != null ? ModuleSourceRootEditHandler.getEditHandler(variant.rootType) : null;
241
242         Icon icon = handler == null ? null : handler.getRootIcon();
243         if (icon == null) icon = AllIcons.Nodes.Folder;
244
245         variants.add(new CompletionItem(contributor, relativePath, icon, variant.rootType));
246       }
247     }
248
249     variants.sort((o1, o2) -> {
250       int result = StringUtil.naturalCompare(o1.contributor.getDescription(), o2.contributor.getDescription());
251       if (result != 0) return result;
252       return StringUtil.naturalCompare(o1.relativePath, o2.relativePath);
253     });
254
255     return variants;
256   }
257
258   @Nullable
259   private static List<PsiElement> createDirectories(List<Pair<String, JpsModuleSourceRootType<?>>> toCreate,
260                                                     CreateGroupHandler validator) {
261     List<PsiElement> createdDirectories = new ArrayList<>(toCreate.size());
262
263     // first, check that we can create all requested directories
264     if (!ContainerUtil.all(toCreate, dir -> validator.checkInput(dir.first))) return null;
265
266     List<Pair<PsiFileSystemItem, JpsModuleSourceRootType<?>>> toMarkAsRoots = new ArrayList<>(toCreate.size());
267
268     // now create directories one by one
269     for (Pair<String, JpsModuleSourceRootType<?>> dir : toCreate) {
270       // this call creates a directory
271       if (!validator.canClose(dir.first)) continue;
272       PsiFileSystemItem element = validator.getCreatedElement();
273       if (element == null) continue;
274
275       createdDirectories.add(element);
276
277       // collect folders to mark as source folders later
278       JpsModuleSourceRootType<?> rootType = dir.second;
279       if (rootType != null) {
280         toMarkAsRoots.add(Pair.create(element, rootType));
281       }
282     }
283
284     if (!toMarkAsRoots.isEmpty()) {
285       Project project = toMarkAsRoots.get(0).first.getProject();
286       ProjectFileIndex index = ProjectFileIndex.getInstance(project);
287
288       WriteAction.run(() -> ProjectRootManagerEx.getInstanceEx(project).mergeRootsChangesDuring(() -> {
289         for (Pair<PsiFileSystemItem, JpsModuleSourceRootType<?>> each : toMarkAsRoots) {
290           VirtualFile file = each.first.getVirtualFile();
291           JpsModuleSourceRootType<?> rootType = each.second;
292
293           // make sure we have a content root for this directory and it's not yet registered as source folder
294           Module module = index.getModuleForFile(file);
295           if (module == null || index.getContentRootForFile(file) == null || index.getSourceFolder(file) != null) {
296             continue;
297           }
298
299           ModifiableRootModel model = ModuleRootManager.getInstance(module).getModifiableModel();
300           ContentEntry entry = MarkRootActionBase.findContentEntry(model, file);
301           if (entry != null) {
302             entry.addSourceFolder(file, rootType);
303             model.commit();
304           }
305           else {
306             model.dispose();
307           }
308         }
309       }));
310     }
311
312     return createdDirectories;
313   }
314
315   private static class CompletionItem {
316     @NotNull final CreateDirectoryCompletionContributor contributor;
317
318     @NotNull final String relativePath;
319     @Nullable final JpsModuleSourceRootType<?> rootType;
320
321     @NotNull final String displayText;
322     @Nullable final Icon icon;
323
324     private CompletionItem(@NotNull CreateDirectoryCompletionContributor contributor,
325                            @NotNull String relativePath,
326                            @Nullable Icon icon,
327                            @Nullable JpsModuleSourceRootType<?> rootType) {
328       this.contributor = contributor;
329
330       this.relativePath = relativePath;
331       this.rootType = rootType;
332
333       this.displayText = FileUtil.toSystemDependentName(relativePath);
334       this.icon = icon;
335     }
336
337     public void reportToStatistics() {
338       Class contributorClass = contributor.getClass();
339       String nameToReport = StatisticsUtilKt.getPluginType(contributorClass).isSafeToReport()
340                             ? contributorClass.getSimpleName() : "third.party";
341
342       FUCounterUsageLogger.getInstance().logEvent("create.directory.dialog",
343                                                   "completion.variant.chosen",
344                                                   new FeatureUsageData().addData("contributor", nameToReport));
345     }
346   }
347
348   private static class DirectoriesWithCompletionPopupPanel extends NewItemWithTemplatesPopupPanel<CompletionItem> {
349     final static private SimpleTextAttributes MATCHED = new SimpleTextAttributes(UIUtil.getListBackground(),
350                                                                                  UIUtil.getListForeground(),
351                                                                                  null,
352                                                                                  SimpleTextAttributes.STYLE_SEARCH_MATCH);
353     private MinusculeMatcher currentMatcher = null;
354     private boolean locked = false;
355
356     protected DirectoriesWithCompletionPopupPanel(@NotNull List<CompletionItem> items) {
357       super(items, SimpleListCellRenderer.create("", item -> item.displayText));
358       setupRenderers();
359
360       // allow multi selection with Shift+Up/Down
361       ScrollingUtil.redirectExpandSelection(myTemplatesList, myTextField);
362
363       myTemplatesList.getSelectionModel().setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);
364       myTemplatesList.addListSelectionListener(e -> {
365         CompletionItem selected = myTemplatesList.getSelectedValue();
366         if (selected != null) {
367           locked = true;
368           try {
369             myTextField.setText(selected.displayText);
370           }
371           finally {
372             locked = false;
373           }
374         }
375       });
376       myTextField.getDocument().addDocumentListener(new DocumentAdapter() {
377         @Override
378         protected void textChanged(@NotNull DocumentEvent e) {
379           if (!locked) {
380             String input = myTextField.getText();
381             currentMatcher = NameUtil.buildMatcher("*" + input).build();
382
383             List<CompletionItem> filtered =
384               ContainerUtil.filter(items, item -> currentMatcher.matches(item.displayText));
385
386             updateTemplatesList(filtered);
387           }
388         }
389       });
390
391       ListModel<CompletionItem> model = myTemplatesList.getModel();
392       model.addListDataListener(new ListDataListener() {
393         @Override
394         public void intervalAdded(ListDataEvent e) {
395           setTemplatesListVisible(model.getSize() > 0);
396         }
397
398         @Override
399         public void intervalRemoved(ListDataEvent e) {
400           setTemplatesListVisible(model.getSize() > 0);
401         }
402
403         @Override
404         public void contentsChanged(ListDataEvent e) {
405           setTemplatesListVisible(model.getSize() > 0);
406         }
407       });
408       setTemplatesListVisible(model.getSize() > 0);
409     }
410
411     @NotNull
412     List<CompletionItem> getSelectedItems() {
413       return myTemplatesList.getSelectedValuesList();
414     }
415
416     private void setupRenderers() {
417       ColoredListCellRenderer<CompletionItem> itemRenderer =
418         new ColoredListCellRenderer<CompletionItem>() {
419           @Override
420           protected void customizeCellRenderer(@NotNull JList<? extends CompletionItem> list,
421                                                @Nullable CompletionItem value,
422                                                int index,
423                                                boolean selected,
424                                                boolean hasFocus) {
425             if (!selected) {
426               setBackground(UIUtil.getListBackground());
427             }
428
429             String text = value == null ? "" : value.displayText;
430             FList<TextRange> ranges = currentMatcher == null ? FList.emptyList() : currentMatcher.matchingFragments(text);
431             SpeedSearchUtil.appendColoredFragments(this, text, ranges, SimpleTextAttributes.REGULAR_ATTRIBUTES, MATCHED);
432             setIcon(value == null ? null : value.icon);
433           }
434         };
435       myTemplatesList.setCellRenderer(new ListCellRenderer<CompletionItem>() {
436         @Override
437         public Component getListCellRendererComponent(JList<? extends CompletionItem> list,
438                                                       CompletionItem value,
439                                                       int index,
440                                                       boolean isSelected,
441                                                       boolean cellHasFocus) {
442           Component item = itemRenderer.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus);
443           JPanel wrapperPanel = new JPanel(new BorderLayout());
444           wrapperPanel.setBackground(UIUtil.getListBackground());
445
446           if (index == 0 || value.contributor != list.getModel().getElementAt(index - 1).contributor) {
447             SeparatorWithText separator = new SeparatorWithText() {
448               @Override
449               protected void paintLinePart(Graphics g, int xMin, int xMax, int hGap, int y) {
450               }
451             };
452
453             separator.setFont(UIUtil.getLabelFont(UIUtil.FontSize.SMALL));
454             int vGap = UIUtil.DEFAULT_VGAP / 2;
455             separator.setBorder(BorderFactory.createEmptyBorder(vGap * (index == 0 ? 1 : 2), 0, vGap, 0));
456
457             separator.setCaption(value.contributor.getDescription());
458             separator.setCaptionCentered(false);
459
460             wrapperPanel.add(separator, BorderLayout.NORTH);
461           }
462           wrapperPanel.add(item, BorderLayout.CENTER);
463           return wrapperPanel;
464         }
465       });
466     }
467   }
468 }