0dc32e0aabeb5f175204e290bbe15bdace7a9cdd
[idea/community.git] / platform / platform-impl / src / com / intellij / openapi / fileEditor / impl / EditorHistoryManager.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.openapi.fileEditor.impl;
3
4 import com.google.common.annotations.VisibleForTesting;
5 import com.intellij.ide.ui.UISettings;
6 import com.intellij.ide.ui.UISettingsListener;
7 import com.intellij.openapi.Disposable;
8 import com.intellij.openapi.application.ApplicationManager;
9 import com.intellij.openapi.components.*;
10 import com.intellij.openapi.diagnostic.Logger;
11 import com.intellij.openapi.fileEditor.*;
12 import com.intellij.openapi.fileEditor.ex.FileEditorManagerEx;
13 import com.intellij.openapi.fileEditor.ex.FileEditorWithProvider;
14 import com.intellij.openapi.progress.ProcessCanceledException;
15 import com.intellij.openapi.project.DumbAware;
16 import com.intellij.openapi.project.Project;
17 import com.intellij.openapi.startup.StartupActivity;
18 import com.intellij.openapi.util.Pair;
19 import com.intellij.openapi.vfs.VfsUtilCore;
20 import com.intellij.openapi.vfs.VirtualFile;
21 import com.intellij.openapi.vfs.VirtualFileManager;
22 import com.intellij.psi.PsiDocumentManager;
23 import com.intellij.util.ArrayUtilRt;
24 import com.intellij.util.messages.MessageBusConnection;
25 import org.jdom.Element;
26 import org.jetbrains.annotations.NotNull;
27 import org.jetbrains.annotations.Nullable;
28
29 import java.util.*;
30
31 @State(name = "editorHistoryManager", storages = {
32   @Storage(StoragePathMacros.PRODUCT_WORKSPACE_FILE),
33   @Storage(value = StoragePathMacros.WORKSPACE_FILE, deprecated = true)
34 })
35 public final class EditorHistoryManager implements PersistentStateComponent<Element>, Disposable {
36   private static final Logger LOG = Logger.getInstance(EditorHistoryManager.class);
37
38   private final Project myProject;
39
40   public static EditorHistoryManager getInstance(@NotNull Project project){
41     return ServiceManager.getService(project, EditorHistoryManager.class);
42   }
43
44   /**
45    * State corresponding to the most recent file is the last
46    */
47   private final List<HistoryEntry> myEntriesList = new ArrayList<>();
48
49   EditorHistoryManager(@NotNull Project project) {
50     myProject = project;
51
52     MessageBusConnection connection = project.getMessageBus().connect();
53
54     connection.subscribe(UISettingsListener.TOPIC, uiSettings -> trimToSize());
55
56     connection.subscribe(FileEditorManagerListener.Before.FILE_EDITOR_MANAGER, new FileEditorManagerListener.Before() {
57       @Override
58       public void beforeFileClosed(@NotNull FileEditorManager source, @NotNull VirtualFile file) {
59         updateHistoryEntry(file, false);
60       }
61     });
62     connection.subscribe(FileEditorManagerListener.FILE_EDITOR_MANAGER, new MyEditorManagerListener());
63   }
64
65   static class EditorHistoryManagerStartUpActivity implements DumbAware, StartupActivity {
66     @Override
67     public void runActivity(@NotNull Project project) {
68       getInstance(project);
69     }
70   }
71
72   private synchronized void removeEntry(@NotNull HistoryEntry entry) {
73     if (myEntriesList.remove(entry)) {
74       entry.destroy();
75     }
76   }
77
78   private synchronized void moveOnTop(@NotNull HistoryEntry entry) {
79     myEntriesList.remove(entry);
80     myEntriesList.add(entry);
81   }
82
83   /**
84    * Makes file most recent one
85    */
86   private void fileOpenedImpl(@NotNull VirtualFile file, @Nullable FileEditor fallbackEditor, @Nullable FileEditorProvider fallbackProvider) {
87     ApplicationManager.getApplication().assertIsDispatchThread();
88     // don't add files that cannot be found via VFM (light & etc.)
89     if (VirtualFileManager.getInstance().findFileByUrl(file.getUrl()) == null) {
90       return;
91     }
92
93     FileEditorManagerEx editorManager = FileEditorManagerEx.getInstanceEx(myProject);
94
95     Pair<FileEditor[], FileEditorProvider[]> editorsWithProviders = editorManager.getEditorsWithProviders(file);
96     FileEditor[] editors = editorsWithProviders.getFirst();
97     FileEditorProvider[] oldProviders = editorsWithProviders.getSecond();
98     LOG.assertTrue(editors.length == oldProviders.length, "Different number of editors and providers");
99     if (editors.length <= 0 && fallbackEditor != null && fallbackProvider != null) {
100       editors = new FileEditor[] { fallbackEditor };
101       oldProviders = new FileEditorProvider[] { fallbackProvider };
102     }
103     if (editors.length <= 0) {
104       // fileOpened notification is asynchronous, file could have been closed by now due to some reason
105       return;
106     }
107     FileEditor selectedEditor = editorManager.getSelectedEditor(file);
108     if (selectedEditor == null) {
109       selectedEditor = fallbackEditor;
110     }
111     LOG.assertTrue(selectedEditor != null);
112     int selectedProviderIndex = ArrayUtilRt.find(editors, selectedEditor);
113     LOG.assertTrue(selectedProviderIndex != -1, "Can't find " + selectedEditor + " among " + Arrays.asList(editors));
114
115     HistoryEntry entry = getEntry(file);
116     if (entry != null) {
117       moveOnTop(entry);
118     }
119     else {
120       FileEditorState[] states = new FileEditorState[editors.length];
121       FileEditorProvider[] providers = new FileEditorProvider[editors.length];
122       for (int i = states.length - 1; i >= 0; i--) {
123         FileEditorProvider provider = oldProviders[i];
124         LOG.assertTrue(provider != null);
125         providers[i] = provider;
126         FileEditor editor = editors[i];
127         if (editor.isValid()) {
128           states[i] = editor.getState(FileEditorStateLevel.FULL);
129         }
130       }
131       //noinspection SynchronizeOnThis
132       synchronized (this) {
133         myEntriesList.add(HistoryEntry.createHeavy(myProject, file, providers, states, providers[selectedProviderIndex]));
134       }
135       trimToSize();
136     }
137   }
138
139   public void updateHistoryEntry(@NotNull VirtualFile file, boolean changeEntryOrderOnly) {
140     updateHistoryEntry(file, null, null, changeEntryOrderOnly);
141   }
142
143   private void updateHistoryEntry(@NotNull VirtualFile file,
144                                   @Nullable FileEditor fileEditor,
145                                   @Nullable FileEditorProvider fileEditorProvider,
146                                   boolean changeEntryOrderOnly) {
147     FileEditorManagerEx editorManager = FileEditorManagerEx.getInstanceEx(myProject);
148     FileEditor[] editors;
149     FileEditorProvider[] providers;
150     if (fileEditor == null || fileEditorProvider == null) {
151       Pair<FileEditor[], FileEditorProvider[]> editorsWithProviders = editorManager.getEditorsWithProviders(file);
152       editors = editorsWithProviders.getFirst();
153       providers = editorsWithProviders.getSecond();
154     }
155     else {
156       editors = new FileEditor[] {fileEditor};
157       providers = new FileEditorProvider[] {fileEditorProvider};
158     }
159
160     if (editors.length == 0) {
161       // obviously not opened in any editor at the moment,
162       // makes no sense to put the file in the history
163       return;
164     }
165     HistoryEntry entry = getEntry(file);
166     if(entry == null){
167       // Size of entry list can be less than number of opened editors (some entries can be removed)
168       if (file.isValid()) {
169         // the file could have been deleted, so the isValid() check is essential
170         fileOpenedImpl(file, fileEditor, fileEditorProvider);
171       }
172       return;
173     }
174
175     if (!changeEntryOrderOnly) { // update entry state
176       //LOG.assertTrue(editors.length > 0);
177       for (int i = editors.length - 1; i >= 0; i--) {
178         FileEditor           editor = editors   [i];
179         FileEditorProvider provider = providers [i];
180         if (provider == null) continue; // can happen if fileEditorProvider is null
181         if (!editor.isValid()) {
182           // this can happen for example if file extension was changed
183           // and this method was called during corresponding myEditor close up
184           continue;
185         }
186
187         FileEditorState oldState = entry.getState(provider);
188         FileEditorState newState = editor.getState(FileEditorStateLevel.FULL);
189         if (!newState.equals(oldState)) {
190           entry.putState(provider, newState);
191         }
192       }
193     }
194     FileEditorWithProvider selectedEditorWithProvider = editorManager.getSelectedEditorWithProvider(file);
195     if (selectedEditorWithProvider != null) {
196       //LOG.assertTrue(selectedEditorWithProvider != null);
197       entry.setSelectedProvider(selectedEditorWithProvider.getProvider());
198       LOG.assertTrue(entry.getSelectedProvider() != null);
199
200       if (changeEntryOrderOnly) {
201         moveOnTop(entry);
202       }
203     }
204   }
205
206   /**
207    * @return array of valid files that are in the history, oldest first.
208    */
209   public synchronized VirtualFile @NotNull [] getFiles() {
210     List<VirtualFile> result = new ArrayList<>(myEntriesList.size());
211     for (HistoryEntry entry : myEntriesList) {
212       VirtualFile file = entry.getFile();
213       if (file != null) result.add(file);
214     }
215     return VfsUtilCore.toVirtualFileArray(result);
216   }
217
218   /**
219    * For internal or test-only usage.
220    */
221   @VisibleForTesting
222   public synchronized void removeAllFiles() {
223     for (HistoryEntry entry : myEntriesList) {
224       entry.destroy();
225     }
226     myEntriesList.clear();
227   }
228
229   /**
230    * @return a set of valid files that are in the history, oldest first.
231    */
232   @NotNull
233   public synchronized List<VirtualFile> getFileList() {
234     List<VirtualFile> result = new ArrayList<>();
235     for (HistoryEntry entry : myEntriesList) {
236       VirtualFile file = entry.getFile();
237       if (file != null) {
238         result.add(file);
239       }
240     }
241     return result;
242   }
243
244   /**
245    * @deprecated use {@link #getFileList()}
246    */
247   @NotNull
248   @Deprecated
249   public synchronized LinkedHashSet<VirtualFile> getFileSet() {
250     return new LinkedHashSet<>(getFileList());
251   }
252
253   public synchronized boolean hasBeenOpen(@NotNull VirtualFile f) {
254     for (HistoryEntry each : myEntriesList) {
255       if (f.equals(each.getFile())) {
256         return true;
257       }
258     }
259     return false;
260   }
261
262   /**
263    * Removes specified {@code file} from history. The method does
264    * nothing if {@code file} is not in the history.
265    *
266    * @exception IllegalArgumentException if {@code file}
267    * is {@code null}
268    */
269   public synchronized void removeFile(@NotNull VirtualFile file){
270     HistoryEntry entry = getEntry(file);
271     if(entry != null){
272       removeEntry(entry);
273     }
274   }
275
276   public FileEditorState getState(@NotNull VirtualFile file, @NotNull FileEditorProvider provider) {
277     HistoryEntry entry = getEntry(file);
278     return entry != null ? entry.getState(provider) : null;
279   }
280
281   /**
282    * @return may be null
283    */
284   FileEditorProvider getSelectedProvider(@NotNull VirtualFile file) {
285     HistoryEntry entry = getEntry(file);
286     return entry != null ? entry.getSelectedProvider() : null;
287   }
288
289   private synchronized HistoryEntry getEntry(@NotNull VirtualFile file) {
290     for (int i = myEntriesList.size() - 1; i >= 0; i--) {
291       HistoryEntry entry = myEntriesList.get(i);
292       VirtualFile entryFile = entry.getFile();
293       if (file.equals(entryFile)) {
294         return entry;
295       }
296     }
297     return null;
298   }
299
300   /**
301    * If total number of files in history more then {@code UISettings.RECENT_FILES_LIMIT}
302    * then removes the oldest ones to fit the history to new size.
303    */
304   private synchronized void trimToSize() {
305     int limit = UISettings.getInstance().getRecentFilesLimit() + 1;
306     while (myEntriesList.size() > limit) {
307       HistoryEntry removed = myEntriesList.remove(0);
308       removed.destroy();
309     }
310   }
311
312   @Override
313   public synchronized void loadState(@NotNull Element state) {
314     // each HistoryEntry contains myDisposable that must be disposed to dispose corresponding virtual file pointer
315     removeAllFiles();
316
317     // backward compatibility - previously entry maybe duplicated
318     Map<String, Element> fileToElement = new LinkedHashMap<>();
319     for (Element e : state.getChildren(HistoryEntry.TAG)) {
320       String file = e.getAttributeValue(HistoryEntry.FILE_ATTR);
321       fileToElement.remove(file);
322       // last is the winner
323       fileToElement.put(file, e);
324     }
325
326     for (Element e : fileToElement.values()) {
327       try {
328         myEntriesList.add(HistoryEntry.createHeavy(myProject, e));
329       }
330       catch (ProcessCanceledException ignored) {
331       }
332       catch (Exception anyException) {
333         LOG.error(anyException);
334       }
335     }
336   }
337
338   @Override
339   public synchronized Element getState() {
340     Element element = new Element("state");
341     // update history before saving
342     VirtualFile[] openFiles = FileEditorManager.getInstance(myProject).getOpenFiles();
343     for (int i = openFiles.length - 1; i >= 0; i--) {
344       VirtualFile file = openFiles[i];
345       // we have to update only files that are in history
346       if (getEntry(file) != null) {
347         updateHistoryEntry(file, false);
348       }
349     }
350
351     for (HistoryEntry entry : myEntriesList) {
352       entry.writeExternal(element, myProject);
353     }
354     return element;
355   }
356
357   @Override
358   public synchronized void dispose() {
359     removeAllFiles();
360   }
361
362   /**
363    * Updates history
364    */
365   private final class MyEditorManagerListener implements FileEditorManagerListener {
366     @Override
367     public void fileOpened(@NotNull FileEditorManager source, @NotNull VirtualFile file){
368       fileOpenedImpl(file, null, null);
369     }
370
371     @Override
372     public void selectionChanged(@NotNull FileEditorManagerEvent event){
373       // updateHistoryEntry does commitDocument which is 1) very expensive and 2) cannot be performed from within PSI change listener
374       // so defer updating history entry until documents committed to improve responsiveness
375       PsiDocumentManager.getInstance(myProject).performWhenAllCommitted(() -> {
376         FileEditor newEditor = event.getNewEditor();
377         if(newEditor != null && !newEditor.isValid())
378           return;
379
380         VirtualFile oldFile = event.getOldFile();
381         if (oldFile != null) {
382           updateHistoryEntry(oldFile, event.getOldEditor(), event.getOldProvider(), false);
383         }
384         VirtualFile newFile = event.getNewFile();
385         if (newFile != null) {
386           updateHistoryEntry(newFile, true);
387         }
388       });
389     }
390   }
391 }