diff --git a/platform/vcs-api/src/com/intellij/vcs/commit/CommitWorkflowUi.kt b/platform/vcs-api/src/com/intellij/vcs/commit/CommitWorkflowUi.kt index 2aa7a3767749..c59d271cbd20 100644 --- a/platform/vcs-api/src/com/intellij/vcs/commit/CommitWorkflowUi.kt +++ b/platform/vcs-api/src/com/intellij/vcs/commit/CommitWorkflowUi.kt @@ -3,16 +3,19 @@ package com.intellij.vcs.commit import com.intellij.openapi.Disposable import com.intellij.openapi.actionSystem.DataProvider +import com.intellij.openapi.application.ModalityState import com.intellij.openapi.util.NlsContexts import com.intellij.openapi.vcs.FilePath import com.intellij.openapi.vcs.changes.Change import com.intellij.openapi.vcs.changes.CommitExecutor import com.intellij.openapi.vcs.changes.InclusionListener import com.intellij.ui.TextAccessor +import org.jetbrains.concurrency.Promise import java.util.* interface CommitWorkflowUi : DataProvider, Disposable { val commitMessageUi: CommitMessageUi + val modalityState: ModalityState // FIXME: make `refreshData` fullfil on EDT? var defaultCommitActionName: @NlsContexts.Button String @@ -22,7 +25,7 @@ interface CommitWorkflowUi : DataProvider, Disposable { fun addExecutorListener(listener: CommitExecutorListener, parent: Disposable) - fun refreshData() + fun refreshData(): Promise<*> fun getDisplayedChanges(): List fun getIncludedChanges(): List diff --git a/platform/vcs-impl/src/com/intellij/openapi/vcs/changes/ChangeListManagerImpl.java b/platform/vcs-impl/src/com/intellij/openapi/vcs/changes/ChangeListManagerImpl.java index 62080c0b96cd..4df81507c162 100644 --- a/platform/vcs-impl/src/com/intellij/openapi/vcs/changes/ChangeListManagerImpl.java +++ b/platform/vcs-impl/src/com/intellij/openapi/vcs/changes/ChangeListManagerImpl.java @@ -88,7 +88,7 @@ public class ChangeListManagerImpl extends ChangeListManagerEx implements Persis new Topic<>(LocalChangeListsLoadedListener.class, Topic.BroadcastDirection.NONE); private final Project myProject; - private final ChangesViewI myChangesViewManager; + private final ChangesViewEx myChangesViewManager; private final ChangelistConflictTracker myConflictTracker; private final Scheduler myScheduler = new Scheduler(); // update thread @@ -116,6 +116,8 @@ public class ChangeListManagerImpl extends ChangeListManagerEx implements Persis @NotNull private final Set myListsToBeDeletedSilently = new HashSet<>(); @NotNull private final Set myListsToBeDeleted = new HashSet<>(); private boolean myEmptyListDeletionScheduled; + + @SuppressWarnings("FieldAccessedSynchronizedAndUnsynchronized") private boolean myModalNotificationsBlocked; private final List myRegisteredCommitExecutors = new ArrayList<>(); @@ -126,7 +128,7 @@ public class ChangeListManagerImpl extends ChangeListManagerEx implements Persis public ChangeListManagerImpl(@NotNull Project project) { myProject = project; - myChangesViewManager = myProject.isDefault() ? new DummyChangesView(myProject) : ChangesViewManager.getInstance(myProject); + myChangesViewManager = myProject.isDefault() ? new DummyChangesView() : ChangesViewManager.getInstanceEx(myProject); myConflictTracker = new ChangelistConflictTracker(project, this); myComposite = FileHolderComposite.create(project); @@ -439,12 +441,12 @@ public class ChangeListManagerImpl extends ChangeListManagerEx implements Persis myUpdateException = null; myAdditionalInfo = null; - - myDelayedNotificator.changedFileStatusChanged(true); - myDelayedNotificator.unchangedFileStatusChanged(true); - myDelayedNotificator.changeListUpdateDone(); - ((ChangesViewEx)myChangesViewManager).refreshImmediately(); } + + myDelayedNotificator.changedFileStatusChanged(true); + myDelayedNotificator.unchangedFileStatusChanged(true); + myDelayedNotificator.changeListUpdateDone(); + myChangesViewManager.resetViewImmediatelyAndRefreshLater(); } catch (Exception | AssertionError ex) { LOG.error(ex); @@ -1237,6 +1239,7 @@ public class ChangeListManagerImpl extends ChangeListManagerEx implements Persis myListeners.removeListener(listener); } + @SuppressWarnings("removal") @Override public void registerCommitExecutor(@NotNull CommitExecutor executor) { myRegisteredCommitExecutors.add(executor); @@ -1301,6 +1304,7 @@ public class ChangeListManagerImpl extends ChangeListManagerEx implements Persis } // used in TeamCity + @SuppressWarnings("removal") @Override public void reopenFiles(@NotNull List paths) { final ReadonlyStatusHandlerImpl readonlyStatusHandler = (ReadonlyStatusHandlerImpl)ReadonlyStatusHandler.getInstance(myProject); @@ -1320,14 +1324,17 @@ public class ChangeListManagerImpl extends ChangeListManagerEx implements Persis return Collections.unmodifiableList(myRegisteredCommitExecutors); } + @SuppressWarnings("removal") @Override public void addDirectoryToIgnoreImplicitly(@NotNull String path) { } + @SuppressWarnings("removal") @Override public void setFilesToIgnore(IgnoredFileBean @NotNull ... filesToIgnore) { } + @SuppressWarnings("removal") @Override public IgnoredFileBean @NotNull [] getFilesToIgnore() { return EMPTY_ARRAY; diff --git a/platform/vcs-impl/src/com/intellij/openapi/vcs/changes/ChangesViewEx.java b/platform/vcs-impl/src/com/intellij/openapi/vcs/changes/ChangesViewEx.java index fe627dded710..6577fb2fd485 100644 --- a/platform/vcs-impl/src/com/intellij/openapi/vcs/changes/ChangesViewEx.java +++ b/platform/vcs-impl/src/com/intellij/openapi/vcs/changes/ChangesViewEx.java @@ -1,12 +1,26 @@ // 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. package com.intellij.openapi.vcs.changes; +import com.intellij.util.concurrency.annotations.RequiresEdt; import com.intellij.vcs.commit.ChangesViewCommitWorkflowHandler; import org.jetbrains.annotations.Nullable; +import org.jetbrains.concurrency.Promise; public interface ChangesViewEx extends ChangesViewI { + /** + * @deprecated Changes no longer could be refreshed immediately, use {@link #promiseRefresh()} or {@link #scheduleRefresh()} + */ + @Deprecated void refreshImmediately(); + /** + * Immediately reset changes view and request refresh when NON_MODAL modality allows (i.e. after a plugin was unloaded or a dialog closed) + */ + @RequiresEdt + void resetViewImmediatelyAndRefreshLater(); + + Promise promiseRefresh(); + boolean isAllowExcludeFromCommit(); /** diff --git a/platform/vcs-impl/src/com/intellij/openapi/vcs/changes/ChangesViewManager.java b/platform/vcs-impl/src/com/intellij/openapi/vcs/changes/ChangesViewManager.java index 9378eac71ee2..d33464e5c055 100644 --- a/platform/vcs-impl/src/com/intellij/openapi/vcs/changes/ChangesViewManager.java +++ b/platform/vcs-impl/src/com/intellij/openapi/vcs/changes/ChangesViewManager.java @@ -5,6 +5,7 @@ package com.intellij.openapi.vcs.changes; import com.intellij.diagnostic.Activity; import com.intellij.diagnostic.ActivityCategory; import com.intellij.diagnostic.StartUpMeasurer; +import com.intellij.diagnostic.telemetry.TraceManager; import com.intellij.icons.AllIcons; import com.intellij.ide.CommonActionsManager; import com.intellij.ide.TreeExpander; @@ -18,8 +19,7 @@ import com.intellij.openapi.components.PersistentStateComponent; import com.intellij.openapi.components.State; import com.intellij.openapi.components.Storage; import com.intellij.openapi.components.StoragePathMacros; -import com.intellij.openapi.progress.EmptyProgressIndicator; -import com.intellij.openapi.progress.ProgressIndicator; +import com.intellij.openapi.diagnostic.Logger; import com.intellij.openapi.progress.ProgressManager; import com.intellij.openapi.project.DumbAware; import com.intellij.openapi.project.Project; @@ -45,9 +45,9 @@ import com.intellij.ui.ExperimentalUI; import com.intellij.ui.JBColor; import com.intellij.ui.components.panels.Wrapper; import com.intellij.ui.content.Content; -import com.intellij.util.Alarm; import com.intellij.util.ModalityUiUtil; import com.intellij.util.ObjectUtils; +import com.intellij.util.concurrency.annotations.RequiresBackgroundThread; import com.intellij.util.concurrency.annotations.RequiresEdt; import com.intellij.util.containers.ContainerUtil; import com.intellij.util.messages.MessageBusConnection; @@ -61,9 +61,13 @@ import com.intellij.vcs.commit.ChangesViewCommitPanel; import com.intellij.vcs.commit.ChangesViewCommitWorkflowHandler; import com.intellij.vcs.commit.CommitModeManager; import com.intellij.vcs.commit.PartialCommitChangeNodeDecorator; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.Tracer; +import org.jetbrains.annotations.CalledInAny; import org.jetbrains.annotations.Nls; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import org.jetbrains.concurrency.Promise; import javax.swing.*; import javax.swing.tree.DefaultMutableTreeNode; @@ -87,6 +91,7 @@ import static com.intellij.openapi.vcs.changes.ui.ChangesViewContentManagerKt.is import static com.intellij.util.containers.ContainerUtil.set; import static com.intellij.util.ui.JBUI.Panels.simplePanel; import static java.util.Arrays.asList; +import static org.jetbrains.concurrency.Promises.cancelledPromise; @State( name = "ChangesViewManager", @@ -96,6 +101,7 @@ public class ChangesViewManager implements ChangesViewEx, PersistentStateComponent, Disposable { + private static final Tracer TRACER = TraceManager.INSTANCE.getTracer("vcs"); private static final String CHANGES_VIEW_PREVIEW_SPLITTER_PROPORTION = "ChangesViewManager.DETAILS_SPLITTER_PROPORTION"; @NotNull private final Project myProject; @@ -117,7 +123,7 @@ public class ChangesViewManager implements ChangesViewEx, public ChangesViewManager(@NotNull Project project) { myProject = project; - ChangesViewModifier.KEY.addChangeListener(project, this::refreshImmediately, this); + ChangesViewModifier.KEY.addChangeListener(project, this::resetViewImmediatelyAndRefreshLater, this); MessageBusConnection busConnection = project.getMessageBus().connect(this); busConnection.subscribe(ChangesViewWorkflowManager.TOPIC, () -> updateCommitWorkflow()); @@ -250,11 +256,15 @@ public class ChangesViewManager implements ChangesViewEx, public boolean myShowIgnored; } + @Override + public Promise promiseRefresh() { + if (myToolWindowPanel == null) return cancelledPromise(); + return myToolWindowPanel.scheduleRefresh(); + } @Override public void scheduleRefresh() { - if (myToolWindowPanel == null) return; - myToolWindowPanel.scheduleRefresh(); + promiseRefresh(); } @Override @@ -295,7 +305,7 @@ public class ChangesViewManager implements ChangesViewEx, @Override public void refreshImmediately() { if (myToolWindowPanel == null) return; - myToolWindowPanel.refreshImmediately(); + myToolWindowPanel.scheduleRefreshNow(); } @Override @@ -308,20 +318,18 @@ public class ChangesViewManager implements ChangesViewEx, return EditorTabDiffPreviewManager.getInstance(project).isEditorDiffPreviewAvailable(); } - public void openEditorPreview() { - if (myToolWindowPanel == null) return; - myToolWindowPanel.openEditorPreview(false); - } - - public void closeEditorPreview() { - closeEditorPreview(false); - } - public void closeEditorPreview(boolean onlyIfEmpty) { if (myToolWindowPanel == null) return; myToolWindowPanel.closeEditorPreview(onlyIfEmpty); } + @Override + public void resetViewImmediatelyAndRefreshLater() { + if (myToolWindowPanel != null) { + myToolWindowPanel.resetViewImmediatelyAndRefreshLater(); + } + } + public static final class ChangesViewToolWindowPanel extends SimpleToolWindowPanel implements Disposable { @NotNull private static final RegistryValue isToolbarHorizontalSetting = Registry.get("vcs.local.changes.toolbar.horizontal"); @NotNull private static final RegistryValue isOpenEditorDiffPreviewWithSingleClick = @@ -346,9 +354,6 @@ public class ChangesViewManager implements ChangesViewEx, @Nullable private ChangesViewCommitPanel myCommitPanel; @Nullable private ChangesViewCommitWorkflowHandler myCommitWorkflowHandler; - @NotNull private final Alarm myTreeUpdateAlarm; - @NotNull private final Object myTreeUpdateIndicatorLock = new Object(); - @NotNull private ProgressIndicator myTreeUpdateIndicator = new EmptyProgressIndicator(); private boolean myModelUpdateInProgress; private boolean myDisposed = false; @@ -363,7 +368,6 @@ public class ChangesViewManager implements ChangesViewEx, MessageBusConnection busConnection = myProject.getMessageBus().connect(this); myVcsConfiguration = VcsConfiguration.getInstance(myProject); - myTreeUpdateAlarm = new Alarm(Alarm.ThreadToUse.POOLED_THREAD, this); myView = myChangesPanel.getChangesView(); myView.installPopupHandler((DefaultActionGroup)ActionManager.getInstance().getAction("ChangesViewPopupMenu")); @@ -451,11 +455,6 @@ public class ChangesViewManager implements ChangesViewEx, @Override public void dispose() { myDisposed = true; - myTreeUpdateAlarm.cancelAllRequests(); - - synchronized (myTreeUpdateIndicatorLock) { - myTreeUpdateIndicator.cancel(); - } } private void setDiffPreview() { @@ -563,13 +562,6 @@ public class ChangesViewManager implements ChangesViewEx, return !isOpenEditorDiffPreviewWithSingleClick.asBoolean() || myVcsConfiguration.LOCAL_CHANGES_DETAILS_PREVIEW_SHOWN; } - private void openEditorPreview(boolean focusEditor) { - if (myEditorDiffPreview == null) return; - if (!isEditorPreviewAllowed()) return; - - myEditorDiffPreview.openPreview(focusEditor); - } - private void closeEditorPreview(boolean onlyIfEmpty) { if (myEditorDiffPreview == null) return; @@ -718,35 +710,35 @@ public class ChangesViewManager implements ChangesViewEx, invokeLaterIfNeeded(() -> myView.setPaintBusy(b)); } - public void scheduleRefresh() { - invokeLaterIfNeeded(() -> { - if (myDisposed) return; - myTreeUpdateAlarm.cancelAllRequests(); - myTreeUpdateAlarm.addRequest(() -> refreshView(), 100); + public Promise scheduleRefresh() { + return scheduleRefreshWithDelay(100); + } + + private final BackgroundRefresher myBackgroundRefresher = new BackgroundRefresher<>( + getClass().getSimpleName() + " refresh", this); + + @CalledInAny + private Promise scheduleRefreshWithDelay(int delayMillis) { + setBusy(true); + return myBackgroundRefresher.requestRefresh(delayMillis, () -> { + refreshView(); + return null; + }).then(result -> { + setBusy(false); + return null; }); } - public void refreshImmediately() { - ApplicationManager.getApplication().assertIsDispatchThread(); - myTreeUpdateAlarm.cancelAllRequests(); - - refreshView(false); + private void scheduleRefreshNow() { + scheduleRefreshWithDelay(0); } + @RequiresBackgroundThread private void refreshView() { - refreshView(true); - } - - private void refreshView(boolean canBeCancelled) { - ProgressIndicator indicator = new EmptyProgressIndicator(); - synchronized (myTreeUpdateIndicatorLock) { - myTreeUpdateIndicator.cancel(); - myTreeUpdateIndicator = indicator; - } - - ProgressManager.getInstance().executeProcessUnderProgress(() -> { - if (myDisposed || !myProject.isInitialized() || ApplicationManager.getApplication().isUnitTestMode()) return; + if (myDisposed || !myProject.isInitialized() || ApplicationManager.getApplication().isUnitTestMode()) return; + Span span = TRACER.spanBuilder("changes-view-refresh-background").startSpan(); + try { ChangeListManagerImpl changeListManager = ChangeListManagerImpl.getInstanceImpl(myProject); List changeLists = changeListManager.getChangeLists(); List unversionedFiles = changeListManager.getUnversionedFilesPaths(); @@ -766,26 +758,62 @@ public class ChangesViewManager implements ChangesViewEx, treeModelBuilder.setIgnored(changeListManager.getIgnoredFilePaths()); } - invokeLaterIfNeeded(() -> { - if (myDisposed) return; - if (canBeCancelled) indicator.checkCanceled(); - - for (ChangesViewModifier extension : ChangesViewModifier.KEY.getExtensions(myProject)) { + for (ChangesViewModifier extension : ChangesViewModifier.KEY.getExtensions(myProject)) { + try { extension.modifyTreeModelBuilder(treeModelBuilder); } - DefaultTreeModel newModel = treeModelBuilder.build(); + catch (Throwable t) { + Logger.getInstance(ChangesViewToolWindowPanel.class).error(t); + } + } - myModelUpdateInProgress = true; - try { - myView.updateModel(newModel); - if (myCommitWorkflowHandler != null) myCommitWorkflowHandler.synchronizeInclusion(changeLists, unversionedFiles); - } - finally { - myModelUpdateInProgress = false; - } - updatePreview(true); + DefaultTreeModel treeModel = treeModelBuilder.build(); + + ProgressManager.checkCanceled(); + + ApplicationManager.getApplication().invokeAndWait(() -> { + refreshViewOnEdt(treeModel, changeLists, unversionedFiles); }); - }, canBeCancelled ? indicator : null); + } + finally { + span.end(); + } + } + + /** + * Immediately reset changes view and request refresh when NON_MODAL modality allows (i.e. after a plugin was unloaded or a dialog closed) + */ + @RequiresEdt + private void resetViewImmediatelyAndRefreshLater() { + myView.setModel(new DefaultTreeModel(ChangesBrowserNode.createRoot())); + myView.setPaintBusy(true); + ApplicationManager.getApplication().invokeLater(() -> { + scheduleRefreshNow(); + }, ModalityState.NON_MODAL); + } + + @RequiresEdt + private void refreshViewOnEdt(@NotNull DefaultTreeModel treeModel, + @NotNull List changeLists, + @NotNull List unversionedFiles) { + if (myDisposed) return; + + Span span = TRACER.spanBuilder("changes-view-refresh-edt").startSpan(); + try { + myModelUpdateInProgress = true; + try { + myView.updateModel(treeModel); + if (myCommitWorkflowHandler != null) myCommitWorkflowHandler.synchronizeInclusion(changeLists, unversionedFiles); + } + finally { + myModelUpdateInProgress = false; + } + + updatePreview(true); + } + finally { + span.end(); + } } public void setGrouping(@NotNull String groupingKey) { @@ -887,7 +915,7 @@ public class ChangesViewManager implements ChangesViewEx, @Override public void setSelected(@NotNull AnActionEvent e, boolean state) { myChangesViewManager.myState.myShowIgnored = state; - refreshView(); + scheduleRefreshNow(); } } } diff --git a/platform/vcs-impl/src/com/intellij/openapi/vcs/changes/DummyChangesView.java b/platform/vcs-impl/src/com/intellij/openapi/vcs/changes/DummyChangesView.java index bff57c86fba2..0aee9c0c3b8d 100644 --- a/platform/vcs-impl/src/com/intellij/openapi/vcs/changes/DummyChangesView.java +++ b/platform/vcs-impl/src/com/intellij/openapi/vcs/changes/DummyChangesView.java @@ -15,25 +15,31 @@ */ package com.intellij.openapi.vcs.changes; -import com.intellij.openapi.project.Project; import com.intellij.openapi.vfs.VirtualFile; import com.intellij.vcs.commit.ChangesViewCommitWorkflowHandler; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import org.jetbrains.concurrency.Promise; import java.util.List; +import static org.jetbrains.concurrency.Promises.resolvedPromise; + /** * @author irengrig */ class DummyChangesView implements ChangesViewEx { - DummyChangesView(Project project) { + DummyChangesView() { } @Override public void scheduleRefresh() { } + @Override + public void resetViewImmediatelyAndRefreshLater() { + } + @Override public void selectFile(VirtualFile vFile) { } @@ -58,6 +64,11 @@ class DummyChangesView implements ChangesViewEx { public void refreshImmediately() { } + @Override + public Promise promiseRefresh() { + return resolvedPromise(); + } + @Override public boolean isAllowExcludeFromCommit() { return false; diff --git a/platform/vcs-impl/src/com/intellij/openapi/vcs/changes/actions/diff/ShowDiffFromLocalChangesActionProvider.java b/platform/vcs-impl/src/com/intellij/openapi/vcs/changes/actions/diff/ShowDiffFromLocalChangesActionProvider.java index 08b6c5899663..f1aa9cfe2442 100644 --- a/platform/vcs-impl/src/com/intellij/openapi/vcs/changes/actions/diff/ShowDiffFromLocalChangesActionProvider.java +++ b/platform/vcs-impl/src/com/intellij/openapi/vcs/changes/actions/diff/ShowDiffFromLocalChangesActionProvider.java @@ -93,14 +93,15 @@ public class ShowDiffFromLocalChangesActionProvider implements AnActionExtension // this trick is essential since we are under some conditions to refresh changes; // but we can only rely on callback after refresh ChangeListManager.getInstance(project).invokeAfterUpdate(true, () -> { - try { - ChangesViewManager.getInstanceEx(project).refreshImmediately(); - List actualChanges = loadFakeRevisions(project, changes); - resultRef.set(collectRequestProducers(project, actualChanges, unversioned, view)); - } - catch (Throwable err) { - resultRef.setException(err); - } + ChangesViewManager.getInstanceEx(project).promiseRefresh().onProcessed(__ -> { + try { + List actualChanges = loadFakeRevisions(project, changes); + resultRef.set(collectRequestProducers(project, actualChanges, unversioned, view)); + } + catch (Throwable err) { + resultRef.setException(err); + } + }); }); chain = new ChangeDiffRequestChain.Async() { @@ -126,7 +127,6 @@ public class ShowDiffFromLocalChangesActionProvider implements AnActionExtension DiffManager.getInstance().showDiff(project, chain, DiffDialogHints.DEFAULT); } - private static boolean checkIfThereAreFakeRevisions(@NotNull Project project, @NotNull List changes) { boolean needsConversion = false; for (Change change : changes) { diff --git a/platform/vcs-impl/src/com/intellij/openapi/vcs/changes/ui/BackgroundRefresher.kt b/platform/vcs-impl/src/com/intellij/openapi/vcs/changes/ui/BackgroundRefresher.kt new file mode 100644 index 000000000000..86dd9198ff2a --- /dev/null +++ b/platform/vcs-impl/src/com/intellij/openapi/vcs/changes/ui/BackgroundRefresher.kt @@ -0,0 +1,115 @@ +// Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.openapi.vcs.changes.ui + +import com.intellij.openapi.Disposable +import com.intellij.openapi.progress.EmptyProgressIndicator +import com.intellij.openapi.progress.ProcessCanceledException +import com.intellij.openapi.progress.ProgressManager +import com.intellij.openapi.util.Computable +import com.intellij.openapi.util.Disposer +import com.intellij.util.concurrency.AppExecutorUtil +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.concurrency.AsyncPromise +import org.jetbrains.concurrency.Promise +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit +import java.util.concurrent.locks.Lock +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock + +@ApiStatus.Internal +class BackgroundRefresher(name: String, parentDisposable: Disposable) { + private val executor = AppExecutorUtil.createBoundedScheduledExecutorService(name, 1) + private val requestLock: Lock = ReentrantLock() + + // Concurrent access is fully handled by requestLock + private var currentIndicator = EmptyProgressIndicator() + private var isDisposed = false + private var currentTask: ScheduledFuture<*>? = null + private var nextRefresh: Boolean = false + private val promisesToFulfil = mutableListOf>() + + init { + Disposer.register(parentDisposable, Disposable { + requestLock.withLock { + isDisposed = true + } + + executor.shutdownNow() + currentIndicator.cancel() + currentTask = null + + for (asyncPromise in collectPromises(force = true)) { + asyncPromise.setError(AsyncPromise.CANCELED) + } + }) + } + + + /** + * Request refresh and return the result of the latest one. + * Promise will be resolved only when there are no more pending refreshes upon finishing next refresh. + * Current refresh may be canceled via progress indicator by the next refresh to do less obsolete computations. + */ + fun requestRefresh(delayMillis: Int, block: Computable): Promise = requestLock.withLock { + if (isDisposed) return AsyncPromise().also { it.cancel(false) } + + // Cancel queued refresh (does not affect already running one) + currentTask?.cancel(false) + // Cancel any already running refresh to save time + currentIndicator.cancel() + + val promise = AsyncPromise() + promisesToFulfil.add(promise) + + nextRefresh = true + + currentTask = executor.schedule(Runnable { + val indicator = requestLock.withLock { + nextRefresh = false + currentTask = null + + val indicator = EmptyProgressIndicator() + currentIndicator = indicator + indicator + } + + // If the indicator is cancelled means next request was queued or parentDisposable was terminated + try { + val value = ProgressManager.getInstance().runProcess(block, indicator) + ProgressManager.checkCanceled() + if (executor.isShutdown) { + throw ProcessCanceledException() + } + + for (asyncPromise in collectPromises(force = false)) { + asyncPromise.setResult(value) + } + } + catch (t: Throwable) { + // Pass any exception even ProcessCancelledException + // If PCE was initiated by next refresh, collectPromises will return an empty list + // and promises will be intact until next refresh + for (asyncPromise in collectPromises(force = false)) { + asyncPromise.setError(t) + } + } + }, delayMillis.toLong(), TimeUnit.MILLISECONDS) + + promise + } + + private fun collectPromises(force: Boolean): List> { + requestLock.withLock { + if (!force && (nextRefresh || isDisposed)) { + // wait for the next refresh + return emptyList() + } + else { + val promises = promisesToFulfil.toList() + promisesToFulfil.clear() + return promises + } + } + } +} \ No newline at end of file diff --git a/platform/vcs-impl/src/com/intellij/openapi/vcs/changes/ui/CommitChangeListDialog.java b/platform/vcs-impl/src/com/intellij/openapi/vcs/changes/ui/CommitChangeListDialog.java index 15cf0947450d..3a4642214a69 100644 --- a/platform/vcs-impl/src/com/intellij/openapi/vcs/changes/ui/CommitChangeListDialog.java +++ b/platform/vcs-impl/src/com/intellij/openapi/vcs/changes/ui/CommitChangeListDialog.java @@ -32,6 +32,7 @@ import com.intellij.ui.components.JBLabel; import com.intellij.ui.scale.JBUIScale; import com.intellij.util.Alarm; import com.intellij.util.EventDispatcher; +import com.intellij.util.Futures; import com.intellij.util.containers.ContainerUtil; import com.intellij.util.containers.JBIterable; import com.intellij.util.ui.AbstractLayoutManager; @@ -45,6 +46,8 @@ import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NonNls; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import org.jetbrains.concurrency.Promise; +import org.jetbrains.concurrency.Promises; import javax.swing.*; import java.awt.*; @@ -53,6 +56,7 @@ import java.awt.event.ComponentAdapter; import java.awt.event.ComponentEvent; import java.util.List; import java.util.*; +import java.util.concurrent.CompletableFuture; import static com.intellij.openapi.util.text.StringUtil.escapeXmlEntities; import static com.intellij.openapi.vcs.VcsBundle.message; @@ -258,6 +262,12 @@ public abstract class CommitChangeListDialog extends DialogWrapper implements Si @NotNull public abstract CommitDialogChangesBrowser getBrowser(); + @NotNull + @Override + public ModalityState getModalityState() { + return ModalityState.stateForComponent(getRootPane()); + } + @Override public boolean activate() { beforeInit(); @@ -644,8 +654,11 @@ public abstract class CommitChangeListDialog extends DialogWrapper implements Si } @Override - public void refreshData() { - getBrowser().updateDisplayedChangeLists(); + public @NotNull Promise refreshData() { + CompletableFuture future = Futures.runInEdtAsync(() -> { + getBrowser().updateDisplayedChangeLists(); + }); + return Promises.asPromise(future); } @NotNull diff --git a/platform/vcs-impl/src/com/intellij/vcs/commit/AbstractCommitWorkflowHandler.kt b/platform/vcs-impl/src/com/intellij/vcs/commit/AbstractCommitWorkflowHandler.kt index c3f9f3a02773..4825f0c5e3bc 100644 --- a/platform/vcs-impl/src/com/intellij/vcs/commit/AbstractCommitWorkflowHandler.kt +++ b/platform/vcs-impl/src/com/intellij/vcs/commit/AbstractCommitWorkflowHandler.kt @@ -3,6 +3,7 @@ package com.intellij.vcs.commit import com.intellij.openapi.Disposable import com.intellij.openapi.actionSystem.DataProvider +import com.intellij.openapi.application.invokeLater import com.intellij.openapi.diagnostic.logger import com.intellij.openapi.fileEditor.FileDocumentManager import com.intellij.openapi.project.Project @@ -124,10 +125,12 @@ abstract class AbstractCommitWorkflowHandler Unit) = ChangeListManager.getInstance(project).invokeAfterUpdateWithModal(true, VcsBundle.message("commit.progress.title")) { - ui.refreshData() - callback() + ui.refreshData().then { + callback() + } } override fun dispose() = Unit diff --git a/platform/vcs-impl/src/com/intellij/vcs/commit/ChangesViewCommitPanel.kt b/platform/vcs-impl/src/com/intellij/vcs/commit/ChangesViewCommitPanel.kt index c343a4aa1623..0a2942b45b8d 100644 --- a/platform/vcs-impl/src/com/intellij/vcs/commit/ChangesViewCommitPanel.kt +++ b/platform/vcs-impl/src/com/intellij/vcs/commit/ChangesViewCommitPanel.kt @@ -3,6 +3,8 @@ package com.intellij.vcs.commit import com.intellij.openapi.Disposable import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.invokeLater import com.intellij.openapi.diagnostic.logger import com.intellij.openapi.project.Project import com.intellij.openapi.ui.popup.JBPopup @@ -26,6 +28,7 @@ import com.intellij.util.ui.JBUI.Panels.simplePanel import com.intellij.util.ui.UIUtil import com.intellij.util.ui.tree.TreeUtil.* import com.intellij.vcsUtil.VcsUtil.getFilePath +import org.jetbrains.concurrency.Promise import javax.swing.JComponent import javax.swing.SwingConstants import kotlin.properties.Delegates.observable @@ -108,8 +111,11 @@ class ChangesViewCommitPanel(project: Project, private val changesViewHost: Chan } override var editedCommit by observable(null) { _, _, newValue -> - refreshData() - newValue?.let { expand(it) } + refreshData().then { + invokeLater(ModalityState.NON_MODAL) { + newValue?.let { expand(it) } + } + } } override val isActive: Boolean get() = isVisible @@ -183,7 +189,7 @@ class ChangesViewCommitPanel(project: Project, private val changesViewHost: Chan commitMessage.setChangesSupplier(ChangeListChangesSupplier(changeLists)) } - override fun refreshData() = ChangesViewManager.getInstanceEx(project).refreshImmediately() + override fun refreshData(): Promise<*> = ChangesViewManager.getInstanceEx(project).promiseRefresh() override fun getDisplayedChanges(): List = all(changesView).userObjects(Change::class.java) override fun getIncludedChanges(): List = included(changesView).userObjects(Change::class.java) @@ -208,8 +214,11 @@ class ChangesViewCommitPanel(project: Project, private val changesViewHost: Chan val changesViewManager = ChangesViewManager.getInstance(project) as? ChangesViewManager ?: return if (!ChangesViewManager.isEditorPreview(project)) return - refreshData() - changesViewManager.closeEditorPreview(true) + refreshData().then { + invokeLater(ModalityState.NON_MODAL) { + changesViewManager.closeEditorPreview(true) + } + } } override fun dispose() { diff --git a/platform/vcs-impl/src/com/intellij/vcs/commit/NonModalCommitPanel.kt b/platform/vcs-impl/src/com/intellij/vcs/commit/NonModalCommitPanel.kt index dc9213dbf571..a8e94b36875c 100644 --- a/platform/vcs-impl/src/com/intellij/vcs/commit/NonModalCommitPanel.kt +++ b/platform/vcs-impl/src/com/intellij/vcs/commit/NonModalCommitPanel.kt @@ -7,6 +7,7 @@ import com.intellij.openapi.actionSystem.ActionManager import com.intellij.openapi.actionSystem.DataContext import com.intellij.openapi.actionSystem.DataProvider import com.intellij.openapi.actionSystem.impl.ActionButton +import com.intellij.openapi.application.ModalityState import com.intellij.openapi.editor.colors.EditorColorsListener import com.intellij.openapi.editor.colors.EditorColorsScheme import com.intellij.openapi.editor.ex.EditorEx @@ -88,6 +89,7 @@ abstract class NonModalCommitPanel( override val commitMessageUi: CommitMessageUi get() = commitMessage + override val modalityState: ModalityState = ModalityState.NON_MODAL override fun getComponent(): JComponent = this override fun getPreferredFocusableComponent(): JComponent = commitMessage.editorField diff --git a/platform/vcs-impl/testSrc/com/intellij/openapi/vcs/changes/ui/BackgroundRefresherTest.kt b/platform/vcs-impl/testSrc/com/intellij/openapi/vcs/changes/ui/BackgroundRefresherTest.kt new file mode 100644 index 000000000000..4a4bd29ee1c7 --- /dev/null +++ b/platform/vcs-impl/testSrc/com/intellij/openapi/vcs/changes/ui/BackgroundRefresherTest.kt @@ -0,0 +1,144 @@ +// Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.openapi.vcs.changes.ui + +import com.intellij.idea.TestFor +import com.intellij.openapi.progress.ProcessCanceledException +import com.intellij.openapi.progress.ProgressManager +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.ApplicationRule +import com.intellij.testFramework.DisposableRule +import com.intellij.testFramework.RuleChain +import org.junit.Assert +import org.junit.Rule +import org.junit.Test +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit + +@TestFor(classes = [BackgroundRefresher::class]) +class BackgroundRefresherTest { + val disposableRule = DisposableRule() + + @JvmField + @Rule + val chain = RuleChain(ApplicationRule(), disposableRule) + + @Test + fun requestCancelsPreviousRequests() { + val refresher = BackgroundRefresher("T", disposableRule.disposable) + + val refresh1Started = CompletableFuture() + val result1 = refresher.requestRefresh(0) { + try { + refresh1Started.complete(Unit) + val start = System.currentTimeMillis() + while (System.currentTimeMillis() - start < 10000) { + ProgressManager.checkCanceled() + Thread.sleep(10) + } + 1 + } + catch (t: ProcessCanceledException) { + 666 + } + } + refresh1Started.get() + val result2 = refresher.requestRefresh(0) { + 2 + } + + Assert.assertEquals(2, result2.blockingGet(5000)) + Assert.assertEquals(2, result1.blockingGet(5000)) + } + + @Test + fun disposeLeadsToProgressCancel() { + val disposable = Disposer.newDisposable() + val refresher = BackgroundRefresher("T", disposable) + + val cancelled = CompletableFuture() + val refreshStarted = CompletableFuture() + refresher.requestRefresh(0) { + try { + refreshStarted.complete(Unit) + + val start = System.currentTimeMillis() + while (System.currentTimeMillis() - start < 3000) { + ProgressManager.checkCanceled() + Thread.sleep(10) + } + if (Thread.interrupted()) { + cancelled.completeExceptionally(IllegalStateException("must not be interrupted")) + } + cancelled.completeExceptionally(IllegalStateException("must be cancelled")) + } + catch (t: ProcessCanceledException) { + if (Thread.interrupted()) { + cancelled.completeExceptionally(IllegalStateException("must not be interrupted")) + } + cancelled.complete(Unit) + } + } + refreshStarted.get() + Disposer.dispose(disposable) + cancelled.get(10, TimeUnit.SECONDS) + } + + @Test + fun disposeLeadsToImmediateCancelResult() { + val disposable = Disposer.newDisposable() + val refresher = BackgroundRefresher("T", disposable) + + val refreshStarted = CompletableFuture() + val refreshEnded = CompletableFuture() + val result = refresher.requestRefresh(0) { + refreshStarted.complete(Unit) + Thread.sleep(2000) + refreshEnded.complete(Unit) + } + refreshStarted.get() + Disposer.dispose(disposable) + Assert.assertNull(result.blockingGet(10000)) + Assert.assertFalse(refreshEnded.isDone) + } + + @Test + fun refreshWhenDisposed() { + val disposable = Disposer.newDisposable() + val refresher = BackgroundRefresher("T", disposable) + Disposer.dispose(disposable) + + val refreshWhenDisposed = refresher.requestRefresh(0) { + 1 + } + Assert.assertNull(refreshWhenDisposed.blockingGet(10000)) + } + + @Test + fun refreshReturnsException() { + val refresher = BackgroundRefresher("T", disposableRule.disposable) + + val result = refresher.requestRefresh(0) { + throw IllegalStateException("some text") + } + + try { + result.blockingGet(10000) + Assert.fail() + } catch (e: IllegalStateException) { + Assert.assertEquals("some text", e.message) + } + } + + @Test + fun refreshReturnsCanceledException() { + val refresher = BackgroundRefresher("T", disposableRule.disposable) + val result = refresher.requestRefresh(0) { + throw ProcessCanceledException() + } + try { + result.blockingGet(10000) + Assert.fail() + } catch (_: ProcessCanceledException) { + } + } +} \ No newline at end of file diff --git a/plugins/git4idea/src/git4idea/index/ui/GitStageCommitPanel.kt b/plugins/git4idea/src/git4idea/index/ui/GitStageCommitPanel.kt index 06b384e9a48a..9ee5ebb755ce 100644 --- a/plugins/git4idea/src/git4idea/index/ui/GitStageCommitPanel.kt +++ b/plugins/git4idea/src/git4idea/index/ui/GitStageCommitPanel.kt @@ -18,6 +18,7 @@ import git4idea.index.ContentVersion import git4idea.index.GitFileStatus import git4idea.index.GitStageTracker import git4idea.index.createChange +import org.jetbrains.concurrency.resolvedPromise import kotlin.properties.Delegates.observable private fun GitStageTracker.State.getStaged(): Set = @@ -77,7 +78,7 @@ class GitStageCommitPanel(project: Project) : NonModalCommitPanel(project) { } override fun activate(): Boolean = true - override fun refreshData() = Unit + override fun refreshData() = resolvedPromise() override fun getDisplayedChanges(): List = emptyList() override fun getIncludedChanges(): List = state.stagedChanges