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