[git-index] use the context menu "Merge" action on double click
[idea/community.git] / plugins / git4idea / src / git4idea / index / ui / GitStagePanel.kt
1 // Copyright 2000-2020 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 git4idea.index.ui
3
4 import com.intellij.ide.DataManager
5 import com.intellij.openapi.Disposable
6 import com.intellij.openapi.actionSystem.*
7 import com.intellij.openapi.actionSystem.ex.ActionUtil
8 import com.intellij.openapi.fileEditor.FileDocumentManager
9 import com.intellij.openapi.progress.util.ProgressWindow
10 import com.intellij.openapi.project.Project
11 import com.intellij.openapi.util.Disposer
12 import com.intellij.openapi.vcs.AbstractVcsHelper
13 import com.intellij.openapi.vcs.VcsException
14 import com.intellij.openapi.vcs.VcsNotifier
15 import com.intellij.openapi.vcs.VcsRoot
16 import com.intellij.openapi.vcs.changes.ui.ChangesTree
17 import com.intellij.openapi.vcs.changes.ui.TreeActionsToolbarPanel
18 import com.intellij.openapi.vfs.VirtualFile
19 import com.intellij.openapi.wm.IdeFocusManager
20 import com.intellij.ui.OnePixelSplitter
21 import com.intellij.ui.PopupHandler
22 import com.intellij.ui.ScrollPaneFactory
23 import com.intellij.ui.SideBorder
24 import com.intellij.util.EditSourceOnDoubleClickHandler
25 import com.intellij.util.OpenSourceUtil
26 import com.intellij.util.Processor
27 import com.intellij.util.ui.UIUtil
28 import com.intellij.vcs.commit.getDefaultCommitShortcut
29 import com.intellij.vcs.commit.showEmptyCommitMessageConfirmation
30 import com.intellij.vcs.log.runInEdt
31 import com.intellij.vcs.log.runInEdtAsync
32 import com.intellij.vcs.log.ui.frame.ProgressStripe
33 import com.intellij.vcsUtil.VcsImplUtil
34 import com.intellij.xml.util.XmlStringUtil
35 import git4idea.GitVcs
36 import git4idea.conflicts.GitMergeHandler
37 import git4idea.i18n.GitBundle
38 import git4idea.index.CommitListener
39 import git4idea.index.GitStageTracker
40 import git4idea.index.GitStageTrackerListener
41 import git4idea.index.actions.*
42 import git4idea.merge.GitDefaultMergeDialogCustomizer
43 import git4idea.merge.GitMergeUtil
44 import git4idea.repo.GitRepository
45 import git4idea.repo.GitRepositoryManager
46 import git4idea.status.GitChangeProvider
47 import org.jetbrains.annotations.CalledInAwt
48 import java.awt.BorderLayout
49 import javax.swing.JPanel
50
51 val GIT_STAGE_TRACKER = DataKey.create<GitStageTracker>("GitStageTracker")
52
53 internal class GitStagePanel(private val tracker: GitStageTracker, disposableParent: Disposable) :
54   JPanel(BorderLayout()), DataProvider, Disposable {
55   private val project = tracker.project
56
57   private val tree: GitStageTree
58   private val commitPanel: GitCommitPanel
59   private val progressStripe: ProgressStripe
60
61   private val state: GitStageTracker.State
62     get() = tracker.state
63
64   private var isCommitInProgress = false
65   private var hasPendingUpdates = false
66
67   init {
68     tree = MyChangesTree(project)
69
70     commitPanel = MyGitCommitPanel()
71     commitPanel.createCommitAction().registerCustomShortcutSet(getDefaultCommitShortcut(), this)
72
73     val toolbarGroup = DefaultActionGroup()
74     toolbarGroup.add(ActionManager.getInstance().getAction("Git.Stage.Toolbar"))
75     toolbarGroup.addSeparator()
76     toolbarGroup.add(ActionManager.getInstance().getAction(ChangesTree.GROUP_BY_ACTION_GROUP))
77     toolbarGroup.addSeparator()
78     toolbarGroup.addAll(TreeActionsToolbarPanel.createTreeActions(tree))
79     val toolbar = ActionManager.getInstance().createActionToolbar(ActionPlaces.UNKNOWN, toolbarGroup, true)
80     toolbar.setTargetComponent(tree)
81
82     PopupHandler.installPopupHandler(tree, "Git.Stage.Tree.Menu", "Git.Stage.Tree.Menu")
83
84     val scrolledTree = ScrollPaneFactory.createScrollPane(tree, SideBorder.TOP)
85     progressStripe = ProgressStripe(scrolledTree, this, ProgressWindow.DEFAULT_PROGRESS_DIALOG_POSTPONE_TIME_MILLIS)
86     val treeMessageSplitter = OnePixelSplitter(true, "git.stage.tree.message.splitter", 0.7f)
87     treeMessageSplitter.firstComponent = progressStripe
88     treeMessageSplitter.secondComponent = commitPanel
89
90     val leftPanel = JPanel(BorderLayout())
91     leftPanel.add(toolbar.component, BorderLayout.NORTH)
92     leftPanel.add(treeMessageSplitter, BorderLayout.CENTER)
93
94     val diffPreview = GitStageDiffPreview(project, tree, tracker, this)
95     diffPreview.getToolbarWrapper().setVerticalSizeReferent(toolbar.component)
96
97     val commitDiffSplitter = OnePixelSplitter("git.stage.commit.diff.splitter", 0.5f)
98     commitDiffSplitter.firstComponent = leftPanel
99     commitDiffSplitter.secondComponent = diffPreview.component
100
101     add(commitDiffSplitter, BorderLayout.CENTER)
102
103     tracker.addListener(MyGitStageTrackerListener(), this)
104     project.messageBus.connect(this).subscribe(GitChangeProvider.TOPIC, MyGitChangeProviderListener())
105     if (GitVcs.getInstance(project).changeProvider?.isRefreshInProgress == true) {
106       tree.setEmptyText(GitBundle.message("stage.loading.status"))
107       progressStripe.startLoadingImmediately()
108     }
109
110     Disposer.register(disposableParent, this)
111
112     runInEdtAsync(this, { tree.rebuildTree() })
113   }
114
115   private fun performCommit(amend: Boolean) {
116     val rootsToCommit = state.stagedRoots
117     if (rootsToCommit.isEmpty()) return
118
119     val commitMessage = commitPanel.commitMessage.text
120     if (commitMessage.isBlank() && !showEmptyCommitMessageConfirmation()) return
121
122     commitStarted()
123
124     FileDocumentManager.getInstance().saveAllDocuments()
125     git4idea.index.performCommit(project, rootsToCommit, commitMessage, amend, MyCommitListener(commitMessage))
126   }
127
128   @CalledInAwt
129   private fun commitStarted() {
130     isCommitInProgress = true
131     commitPanel.commitButton.isEnabled = false
132   }
133
134   @CalledInAwt
135   private fun commitFinished(success: Boolean) {
136     isCommitInProgress = false
137     // commit button is going to be enabled after state update
138     if (success) commitPanel.isAmend = false
139     if (hasPendingUpdates) {
140       hasPendingUpdates = false
141       update()
142     }
143   }
144
145   @CalledInAwt
146   fun update() {
147     if (isCommitInProgress) {
148       hasPendingUpdates = true
149       return
150     }
151     tree.update()
152     commitPanel.commitButton.isEnabled = state.hasStagedRoots()
153   }
154
155   override fun getData(dataId: String): Any? {
156     if (GIT_STAGE_TRACKER.`is`(dataId)) return tracker
157     return null
158   }
159
160   override fun dispose() {
161   }
162
163   private inner class MyChangesTree(project: Project) : GitStageTree(project, this) {
164     override val state
165       get() = this@GitStagePanel.state
166     override val operations: List<StagingAreaOperation> = listOf(GitAddOperation, GitResetOperation)
167
168     init {
169       doubleClickHandler = Processor { e ->
170         if (EditSourceOnDoubleClickHandler.isToggleEvent(this, e)) return@Processor false
171
172         val dataContext = DataManager.getInstance().getDataContext(this)
173
174         val mergeAction = ActionManager.getInstance().getAction("Git.Stage.Merge")
175         val event = AnActionEvent.createFromAnAction(mergeAction, e, ActionPlaces.UNKNOWN, dataContext)
176         if (ActionUtil.lastUpdateAndCheckDumb(mergeAction, event, true)) {
177           ActionUtil.performActionDumbAwareWithCallbacks(mergeAction, event, dataContext)
178         }
179         else {
180           OpenSourceUtil.openSourcesFrom(dataContext, true)
181         }
182         true
183       }
184     }
185
186     override fun performStageOperation(nodes: List<GitFileStatusNode>, operation: StagingAreaOperation) {
187       performStageOperation(project, nodes, operation)
188     }
189
190     override fun getDndOperation(targetKind: NodeKind): StagingAreaOperation? {
191       return when (targetKind) {
192         NodeKind.STAGED -> GitAddOperation
193         NodeKind.UNSTAGED -> GitResetOperation
194         else -> null
195       }
196     }
197
198     override fun showMergeDialog(conflictedFiles: List<VirtualFile>) {
199       AbstractVcsHelper.getInstance(project).showMergeDialog(conflictedFiles)
200     }
201   }
202
203   private inner class MyGitCommitPanel : GitCommitPanel(project, this) {
204     override fun isFocused(): Boolean {
205       return IdeFocusManager.getInstance(project).getFocusedDescendantFor(this@GitStagePanel) != null
206     }
207
208     override fun performCommit() {
209       performCommit(isAmend)
210     }
211
212     override fun rootsToCommit() = state.stagedRoots.map { VcsRoot(GitVcs.getInstance(project), it) }
213   }
214
215   private inner class MyGitStageTrackerListener : GitStageTrackerListener {
216     override fun update() {
217       this@GitStagePanel.update()
218     }
219   }
220
221   private inner class MyGitChangeProviderListener : GitChangeProvider.ChangeProviderListener {
222     override fun progressStarted() {
223       runInEdt(this@GitStagePanel) {
224         tree.setEmptyText(GitBundle.message("stage.loading.status"))
225         progressStripe.startLoading()
226       }
227     }
228
229     override fun progressStopped() {
230       runInEdt(this@GitStagePanel) {
231         progressStripe.stopLoading()
232         tree.setEmptyText("")
233       }
234     }
235
236     override fun repositoryUpdated(repository: GitRepository) = Unit
237   }
238
239   private inner class MyCommitListener(private val commitMessage: String) : CommitListener {
240     private val notifier = VcsNotifier.getInstance(project)
241
242     override fun commitProcessFinished(successfulRoots: Collection<VirtualFile>, failedRoots: Map<VirtualFile, VcsException>) {
243       commitFinished(successfulRoots.isNotEmpty() && failedRoots.isEmpty())
244
245       if (successfulRoots.isNotEmpty()) {
246         notifier.notifySuccess(GitBundle.message("stage.commit.successful", successfulRoots.joinToString {
247           "'${VcsImplUtil.getShortVcsRootName(project, it)}'"
248         }, XmlStringUtil.escapeString(commitMessage)))
249       }
250       if (failedRoots.isNotEmpty()) {
251         notifier.notifyError(GitBundle.message("stage.commit.failed", failedRoots.keys.joinToString {
252           "'${VcsImplUtil.getShortVcsRootName(project, it)}'"
253         }), failedRoots.values.joinToString(UIUtil.BR) { it.localizedMessage })
254       }
255     }
256   }
257 }
258
259 internal fun Project.isReversedRoot(root: VirtualFile): Boolean {
260   return GitRepositoryManager.getInstance(this).getRepositoryForRootQuick(root)?.let { repository ->
261     GitMergeUtil.isReverseRoot(repository)
262   } ?: false
263 }
264
265 internal fun createMergeHandler(project: Project) = GitMergeHandler(project, GitDefaultMergeDialogCustomizer(project))