vcs: non-modal: Move "Amend" checkbox and commit toolbar actions above commit message...
[idea/community.git] / platform / vcs-impl / src / com / intellij / vcs / commit / ChangesViewCommitPanel.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 com.intellij.vcs.commit
3
4 import com.intellij.openapi.Disposable
5 import com.intellij.openapi.actionSystem.*
6 import com.intellij.openapi.editor.colors.EditorColorsListener
7 import com.intellij.openapi.editor.colors.EditorColorsScheme
8 import com.intellij.openapi.editor.ex.EditorEx
9 import com.intellij.openapi.project.DumbAwareAction
10 import com.intellij.openapi.ui.ComponentContainer
11 import com.intellij.openapi.ui.Messages
12 import com.intellij.openapi.ui.popup.JBPopup
13 import com.intellij.openapi.ui.popup.JBPopupFactory
14 import com.intellij.openapi.ui.popup.JBPopupListener
15 import com.intellij.openapi.ui.popup.LightweightWindowEvent
16 import com.intellij.openapi.util.Disposer
17 import com.intellij.openapi.util.SystemInfo.isMac
18 import com.intellij.openapi.vcs.FilePath
19 import com.intellij.openapi.vcs.VcsBundle.message
20 import com.intellij.openapi.vcs.changes.*
21 import com.intellij.openapi.vcs.changes.ui.ChangesBrowserNode
22 import com.intellij.openapi.vcs.changes.ui.ChangesBrowserNode.UNVERSIONED_FILES_TAG
23 import com.intellij.openapi.vcs.changes.ui.ChangesViewContentManager
24 import com.intellij.openapi.vcs.changes.ui.ChangesViewContentManager.Companion.LOCAL_CHANGES
25 import com.intellij.openapi.vcs.changes.ui.ChangesViewContentManager.Companion.getToolWindowFor
26 import com.intellij.openapi.vcs.changes.ui.EditChangelistSupport
27 import com.intellij.openapi.vcs.changes.ui.VcsTreeModelData.*
28 import com.intellij.openapi.vcs.checkin.CheckinHandler
29 import com.intellij.openapi.vcs.ui.CommitMessage
30 import com.intellij.openapi.wm.IdeFocusManager
31 import com.intellij.openapi.wm.ToolWindow
32 import com.intellij.ui.IdeBorderFactory.createBorder
33 import com.intellij.ui.JBColor
34 import com.intellij.ui.SideBorder
35 import com.intellij.ui.awt.RelativePoint
36 import com.intellij.ui.components.JBOptionButton
37 import com.intellij.ui.components.JBOptionButton.Companion.getDefaultShowPopupShortcut
38 import com.intellij.ui.components.JBPanel
39 import com.intellij.ui.components.panels.VerticalLayout
40 import com.intellij.util.EventDispatcher
41 import com.intellij.util.IJSwingUtilities.updateComponentTreeUI
42 import com.intellij.util.ui.JBUI.Borders.empty
43 import com.intellij.util.ui.JBUI.Borders.emptyLeft
44 import com.intellij.util.ui.JBUI.Panels.simplePanel
45 import com.intellij.util.ui.JBUI.scale
46 import com.intellij.util.ui.UIUtil.getTreeBackground
47 import com.intellij.util.ui.components.BorderLayoutPanel
48 import com.intellij.util.ui.tree.TreeUtil.*
49 import com.intellij.vcs.log.VcsUser
50 import java.awt.LayoutManager
51 import java.awt.Point
52 import java.awt.event.ActionEvent
53 import java.awt.event.InputEvent
54 import java.awt.event.KeyEvent
55 import javax.swing.*
56 import javax.swing.KeyStroke.getKeyStroke
57 import javax.swing.border.Border
58 import javax.swing.border.EmptyBorder
59 import kotlin.properties.Delegates.observable
60
61 private val CTRL_ENTER = KeyboardShortcut(getKeyStroke(KeyEvent.VK_ENTER, InputEvent.CTRL_DOWN_MASK), null)
62 private val META_ENTER = KeyboardShortcut(getKeyStroke(KeyEvent.VK_ENTER, InputEvent.META_DOWN_MASK), null)
63 private val DEFAULT_COMMIT_ACTION_SHORTCUT: ShortcutSet =
64   if (isMac) CustomShortcutSet(CTRL_ENTER, META_ENTER) else CustomShortcutSet(CTRL_ENTER)
65 fun getDefaultCommitShortcut() = DEFAULT_COMMIT_ACTION_SHORTCUT
66
67 private fun panel(layout: LayoutManager): JBPanel<*> = JBPanel<JBPanel<*>>(layout)
68
69 fun showEmptyCommitMessageConfirmation() = Messages.YES == Messages.showYesNoDialog(
70   message("confirmation.text.check.in.with.empty.comment"),
71   message("confirmation.title.check.in.with.empty.comment"),
72   Messages.getWarningIcon()
73 )
74
75 fun JBOptionButton.getBottomInset(): Int =
76   border?.getBorderInsets(this)?.bottom
77   ?: (components.firstOrNull() as? JComponent)?.insets?.bottom
78   ?: 0
79
80 private fun JBPopup.showAbove(component: JComponent) {
81   val northWest = RelativePoint(component, Point())
82
83   addListener(object : JBPopupListener {
84     override fun beforeShown(event: LightweightWindowEvent) {
85       val popup = event.asPopup()
86       val location = Point(popup.locationOnScreen).apply { y = northWest.screenPoint.y - popup.size.height }
87
88       popup.setLocation(location)
89     }
90   })
91   show(northWest)
92 }
93
94 internal fun ChangesBrowserNode<*>.subtreeRootObject(): Any? = (path.getOrNull(1) as? ChangesBrowserNode<*>)?.userObject
95
96 class ChangesViewCommitPanel(private val changesViewHost: ChangesViewPanel, private val rootComponent: JComponent) :
97   BorderLayoutPanel(), ChangesViewCommitWorkflowUi, EditorColorsListener, ComponentContainer, DataProvider {
98
99   private val changesView get() = changesViewHost.changesView
100   private val project get() = changesView.project
101
102   private val dataProviders = mutableListOf<DataProvider>()
103
104   private val executorEventDispatcher = EventDispatcher.create(CommitExecutorListener::class.java)
105   private val inclusionEventDispatcher = EventDispatcher.create(InclusionListener::class.java)
106
107   private val centerPanel = simplePanel()
108   private val buttonPanel = simplePanel().apply { isOpaque = false }
109   private val toolbarPanel = simplePanel().apply {
110     isOpaque = false
111     border = emptyLeft(1)
112   }
113   private val actions = ActionManager.getInstance().getAction("ChangesView.CommitToolbar") as ActionGroup
114   private val toolbar = ActionManager.getInstance().createActionToolbar(COMMIT_TOOLBAR_PLACE, actions, false).apply {
115     setTargetComponent(this@ChangesViewCommitPanel)
116     component.isOpaque = false
117   }
118
119   private val commitMessage = CommitMessage(project, false, false, true).apply {
120     editorField.addSettingsProvider { it.setBorder(emptyLeft(6)) }
121     editorField.setPlaceholder(message("commit.message.placeholder"))
122   }
123   private val defaultCommitAction = object : AbstractAction() {
124     override fun actionPerformed(e: ActionEvent) = fireDefaultExecutorCalled()
125   }
126   private val commitButton = object : JBOptionButton(defaultCommitAction, emptyArray()) {
127     init {
128       background = getButtonPanelBackground()
129       optionTooltipText = getDefaultTooltip()
130       isOkToProcessDefaultMnemonics = false
131     }
132
133     override fun isDefaultButton(): Boolean = IdeFocusManager.getInstance(project).getFocusedDescendantFor(rootComponent) != null
134   }
135   private val commitAuthorComponent = CommitAuthorComponent(project)
136
137   private var needUpdateCommitOptionsUi = false
138
139   private var isHideToolWindowOnDeactivate = false
140
141   var isToolbarHorizontal: Boolean by observable(false) { _, oldValue, newValue ->
142     if (oldValue != newValue) {
143       addToolbar(newValue) // this also removes toolbar from previous parent
144     }
145   }
146
147   init {
148     Disposer.register(this, commitMessage)
149
150     buildLayout()
151     for (support in EditChangelistSupport.EP_NAME.getExtensions(project)) {
152       support.installSearch(commitMessage.editorField, commitMessage.editorField)
153     }
154
155     with(changesView) {
156       setInclusionListener { inclusionEventDispatcher.multicaster.inclusionChanged() }
157       isShowCheckboxes = true
158     }
159     changesViewHost.statusComponent =
160       ChangesViewCommitStatusPanel(changesView, this).apply { addToLeft(toolbarPanel) }
161
162     setupShortcuts(rootComponent)
163   }
164
165   private fun buildLayout() {
166     buttonPanel.apply {
167       border = getButtonPanelBorder()
168
169       addToLeft(commitButton)
170     }
171     centerPanel
172       .addToCenter(commitMessage)
173       .addToBottom(panel(VerticalLayout(0)).apply {
174         background = getButtonPanelBackground()
175
176         add(commitAuthorComponent.apply { border = empty(0, 5, 4, 0) })
177         add(buttonPanel)
178       })
179     addToCenter(centerPanel)
180     addToolbar(isToolbarHorizontal)
181
182     withPreferredHeight(85)
183   }
184
185   private fun addToolbar(isHorizontal: Boolean) {
186     if (isHorizontal) {
187       toolbar.setOrientation(SwingConstants.HORIZONTAL)
188       toolbar.setReservePlaceAutoPopupIcon(false)
189
190       centerPanel.border = null
191       toolbarPanel.addToCenter(toolbar.component)
192     }
193     else {
194       toolbar.setOrientation(SwingConstants.VERTICAL)
195       toolbar.setReservePlaceAutoPopupIcon(true)
196
197       centerPanel.border = createBorder(JBColor.border(), SideBorder.LEFT)
198       addToLeft(toolbar.component)
199     }
200   }
201
202   private fun getButtonPanelBorder(): Border =
203     EmptyBorder(0, scale(3), (scale(6) - commitButton.getBottomInset()).coerceAtLeast(0), 0)
204
205   private fun getButtonPanelBackground() =
206     JBColor { (commitMessage.editorField.editor as? EditorEx)?.backgroundColor ?: getTreeBackground() }
207
208   private fun fireDefaultExecutorCalled() = executorEventDispatcher.multicaster.executorCalled(null)
209
210   private fun setupShortcuts(component: JComponent) {
211     DefaultCommitAction().registerCustomShortcutSet(DEFAULT_COMMIT_ACTION_SHORTCUT, component, this)
212     ShowCustomCommitActions().registerCustomShortcutSet(getDefaultShowPopupShortcut(), component, this)
213   }
214
215   override fun globalSchemeChange(scheme: EditorColorsScheme?) {
216     needUpdateCommitOptionsUi = true
217     buttonPanel.border = getButtonPanelBorder()
218   }
219
220   override val commitMessageUi: CommitMessageUi get() = commitMessage
221
222   // NOTE: getter should return text with mnemonic (if any) to make mnemonics available in dialogs shown by commit handlers.
223   //  See CheckinProjectPanel.getCommitActionName() usages.
224   override var defaultCommitActionName: String
225     get() = (defaultCommitAction.getValue(Action.NAME) as? String).orEmpty()
226     set(value) = defaultCommitAction.putValue(Action.NAME, value)
227
228   override var isDefaultCommitActionEnabled: Boolean
229     get() = defaultCommitAction.isEnabled
230     set(value) {
231       defaultCommitAction.isEnabled = value
232     }
233
234   override fun setCustomCommitActions(actions: List<AnAction>) = commitButton.setOptions(actions)
235
236   override var commitAuthor: VcsUser?
237     get() = commitAuthorComponent.commitAuthor
238     set(value) {
239       commitAuthorComponent.commitAuthor = value
240     }
241
242   override fun addCommitAuthorListener(listener: CommitAuthorListener, parent: Disposable) =
243     commitAuthorComponent.addCommitAuthorListener(listener, parent)
244
245   override var editedCommit by observable<EditedCommitDetails?>(null) { _, _, newValue ->
246     refreshData()
247     newValue?.let { expand(it) }
248   }
249
250   override val isActive: Boolean get() = isVisible
251
252   override fun activate(): Boolean {
253     val toolWindow = getVcsToolWindow() ?: return false
254     val contentManager = ChangesViewContentManager.getInstance(project)
255
256     saveToolWindowState()
257     changesView.isShowCheckboxes = true
258     isVisible = true
259
260     contentManager.selectContent(LOCAL_CHANGES)
261     toolWindow.activate({ commitMessage.requestFocusInMessage() }, false)
262     return true
263   }
264
265   override fun deactivate(isRestoreState: Boolean) {
266     if (isRestoreState) restoreToolWindowState()
267     clearToolWindowState()
268     changesView.isShowCheckboxes = false
269     isVisible = false
270   }
271
272   private fun saveToolWindowState() {
273     if (!isActive) {
274       isHideToolWindowOnDeactivate = getVcsToolWindow()?.isVisible != true
275     }
276   }
277
278   private fun restoreToolWindowState() {
279     if (isHideToolWindowOnDeactivate) {
280       getVcsToolWindow()?.hide(null)
281     }
282   }
283
284   private fun clearToolWindowState() {
285     isHideToolWindowOnDeactivate = false
286   }
287
288   private fun getVcsToolWindow(): ToolWindow? = getToolWindowFor(project, LOCAL_CHANGES)
289
290   override fun expand(item: Any) {
291     val node = changesView.findNodeInTree(item)
292     node?.let { changesView.expandSafe(it) }
293   }
294
295   override fun select(item: Any) {
296     val path = changesView.findNodePathInTree(item)
297     path?.let { selectPath(changesView, it, false) }
298   }
299
300   override fun selectFirst(items: Collection<Any>) {
301     if (items.isEmpty()) return
302
303     val path = treePathTraverser(changesView).preOrderDfsTraversal().find { getLastUserObject(it) in items }
304     path?.let { selectPath(changesView, it, false) }
305   }
306
307   override fun showCommitOptions(options: CommitOptions, actionName: String, isFromToolbar: Boolean, dataContext: DataContext) {
308     val commitOptionsPanel = CommitOptionsPanel { actionName }.apply {
309       focusTraversalPolicy = LayoutFocusTraversalPolicy()
310       isFocusCycleRoot = true
311
312       setOptions(options)
313       border = empty(0, 10)
314
315       // to reflect LaF changes as commit options components are created once per commit
316       if (needUpdateCommitOptionsUi) {
317         needUpdateCommitOptionsUi = false
318         updateComponentTreeUI(this)
319       }
320     }
321     val focusComponent = IdeFocusManager.getInstance(project).getFocusTargetFor(commitOptionsPanel)
322     val commitOptionsPopup = JBPopupFactory.getInstance()
323       .createComponentPopupBuilder(commitOptionsPanel, focusComponent)
324       .setRequestFocus(true)
325       .createPopup()
326
327     commitOptionsPopup.show(isFromToolbar, dataContext)
328   }
329
330   private fun JBPopup.show(isFromToolbar: Boolean, dataContext: DataContext) =
331     when {
332       isFromToolbar && isToolbarHorizontal -> showAbove(toolbar.component)
333       isFromToolbar && !isToolbarHorizontal -> showAbove(this@ChangesViewCommitPanel)
334       else -> showInBestPositionFor(dataContext)
335     }
336
337   override fun setCompletionContext(changeLists: List<LocalChangeList>) {
338     commitMessage.changeLists = changeLists
339   }
340
341   override fun getComponent(): JComponent = this
342   override fun getPreferredFocusableComponent(): JComponent = commitMessage.editorField
343
344   override fun getData(dataId: String) = getDataFromProviders(dataId) ?: commitMessage.getData(dataId)
345   fun getDataFromProviders(dataId: String) = dataProviders.asSequence().mapNotNull { it.getData(dataId) }.firstOrNull()
346
347   override fun addDataProvider(provider: DataProvider) {
348     dataProviders += provider
349   }
350
351   override fun addExecutorListener(listener: CommitExecutorListener, parent: Disposable) =
352     executorEventDispatcher.addListener(listener, parent)
353
354   override fun refreshData() = ChangesViewManager.getInstanceEx(project).refreshImmediately()
355
356   override fun getDisplayedChanges(): List<Change> = all(changesView).userObjects(Change::class.java)
357   override fun getIncludedChanges(): List<Change> = included(changesView).userObjects(Change::class.java)
358
359   override fun getDisplayedUnversionedFiles(): List<FilePath> =
360     allUnderTag(changesView, UNVERSIONED_FILES_TAG).userObjects(FilePath::class.java)
361
362   override fun getIncludedUnversionedFiles(): List<FilePath> =
363     includedUnderTag(changesView, UNVERSIONED_FILES_TAG).userObjects(FilePath::class.java)
364
365   override var inclusionModel: InclusionModel?
366     get() = changesView.inclusionModel
367     set(value) {
368       changesView.setInclusionModel(value)
369     }
370
371   override fun includeIntoCommit(items: Collection<*>) = changesView.includeChanges(items)
372
373   override fun addInclusionListener(listener: InclusionListener, parent: Disposable) =
374     inclusionEventDispatcher.addListener(listener, parent)
375
376   override fun confirmCommitWithEmptyMessage(): Boolean = showEmptyCommitMessageConfirmation()
377
378   override fun startBeforeCommitChecks() = Unit
379   override fun endBeforeCommitChecks(result: CheckinHandler.ReturnResult) = Unit
380
381   override fun endExecution() = closeEditorPreviewIfEmpty()
382
383   private fun closeEditorPreviewIfEmpty() {
384     val changesViewManager = ChangesViewManager.getInstance(project) as? ChangesViewManager ?: return
385     if (!changesViewManager.isEditorPreview) return
386
387     refreshData()
388     changesViewManager.closeEditorPreview(true)
389   }
390
391   override fun dispose() {
392     changesViewHost.statusComponent = null
393     with(changesView) {
394       isShowCheckboxes = false
395       setInclusionListener(null)
396     }
397   }
398
399   inner class DefaultCommitAction : DumbAwareAction() {
400     override fun update(e: AnActionEvent) {
401       e.presentation.isEnabledAndVisible = isActive && defaultCommitAction.isEnabled
402     }
403
404     override fun actionPerformed(e: AnActionEvent) = fireDefaultExecutorCalled()
405   }
406
407   private inner class ShowCustomCommitActions : DumbAwareAction() {
408     override fun update(e: AnActionEvent) {
409       e.presentation.isEnabledAndVisible = isActive && commitButton.isEnabled
410     }
411
412     override fun actionPerformed(e: AnActionEvent) = commitButton.showPopup()
413   }
414
415   companion object {
416     internal const val COMMIT_TOOLBAR_PLACE: String = "ChangesView.CommitToolbar"
417   }
418 }