New merge dialog initial implementation

This commit is contained in:
Dmitry Jemerov
2018-05-14 16:04:26 +02:00
parent fada5bfd20
commit 37382e7c30
4 changed files with 486 additions and 4 deletions
@@ -530,6 +530,9 @@ vcs.enable.partial.changelists.redo.restartRequired=true
vcs.skip.single.default.changelist=true vcs.skip.single.default.changelist=true
vcs.skip.single.default.changelist.description=Don't show changelist node in Local Changes view when only the Default changelist exists vcs.skip.single.default.changelist.description=Don't show changelist node in Local Changes view when only the Default changelist exists
vcs.new.multiple.file.merge=false
vcs.new.multiple.file.merge.description=Use new implementation of multiple files merge dialog
psi.incremental.reparse.depth.limit=1000 psi.incremental.reparse.depth.limit=1000
psi.deferIconLoading=true psi.deferIconLoading=true
@@ -130,6 +130,7 @@ public final class VcsConfiguration implements PersistentStateComponent<VcsConfi
public boolean UPDATE_GROUP_BY_CHANGELIST = false; public boolean UPDATE_GROUP_BY_CHANGELIST = false;
public boolean UPDATE_FILTER_BY_SCOPE = false; public boolean UPDATE_FILTER_BY_SCOPE = false;
public boolean SHOW_FILE_HISTORY_AS_TREE = false; public boolean SHOW_FILE_HISTORY_AS_TREE = false;
public boolean GROUP_MULTIFILE_MERGE_BY_DIRECTORY = false;
private static final int MAX_STORED_MESSAGES = 25; private static final int MAX_STORED_MESSAGES = 25;
@@ -28,6 +28,7 @@ import com.intellij.openapi.ui.Messages;
import com.intellij.openapi.util.Disposer; import com.intellij.openapi.util.Disposer;
import com.intellij.openapi.util.Getter; import com.intellij.openapi.util.Getter;
import com.intellij.openapi.util.Pair; import com.intellij.openapi.util.Pair;
import com.intellij.openapi.util.registry.Registry;
import com.intellij.openapi.util.text.StringUtil; import com.intellij.openapi.util.text.StringUtil;
import com.intellij.openapi.vcs.*; import com.intellij.openapi.vcs.*;
import com.intellij.openapi.vcs.actions.AnnotateToggleAction; import com.intellij.openapi.vcs.actions.AnnotateToggleAction;
@@ -45,6 +46,7 @@ import com.intellij.openapi.vcs.history.VcsRevisionNumber;
import com.intellij.openapi.vcs.merge.MergeDialogCustomizer; import com.intellij.openapi.vcs.merge.MergeDialogCustomizer;
import com.intellij.openapi.vcs.merge.MergeProvider; import com.intellij.openapi.vcs.merge.MergeProvider;
import com.intellij.openapi.vcs.merge.MultipleFileMergeDialog; import com.intellij.openapi.vcs.merge.MultipleFileMergeDialog;
import com.intellij.openapi.vcs.merge.MultipleFileMergeDialog2;
import com.intellij.openapi.vcs.versionBrowser.ChangeBrowserSettings; import com.intellij.openapi.vcs.versionBrowser.ChangeBrowserSettings;
import com.intellij.openapi.vcs.versionBrowser.ChangesBrowserSettingsEditor; import com.intellij.openapi.vcs.versionBrowser.ChangesBrowserSettingsEditor;
import com.intellij.openapi.vcs.versionBrowser.CommittedChangeList; import com.intellij.openapi.vcs.versionBrowser.CommittedChangeList;
@@ -494,10 +496,18 @@ public class AbstractVcsHelperImpl extends AbstractVcsHelper {
@NotNull MergeDialogCustomizer mergeDialogCustomizer) { @NotNull MergeDialogCustomizer mergeDialogCustomizer) {
if (files.isEmpty()) return Collections.emptyList(); if (files.isEmpty()) return Collections.emptyList();
VfsUtil.markDirtyAndRefresh(false, false, false, ArrayUtil.toObjectArray(files, VirtualFile.class)); VfsUtil.markDirtyAndRefresh(false, false, false, ArrayUtil.toObjectArray(files, VirtualFile.class));
final MultipleFileMergeDialog fileMergeDialog = new MultipleFileMergeDialog(myProject, files, provider, mergeDialogCustomizer); if (Registry.is("vcs.new.multiple.file.merge")) {
AppIcon.getInstance().requestAttention(myProject, true); final MultipleFileMergeDialog2 fileMergeDialog = new MultipleFileMergeDialog2(myProject, files, provider, mergeDialogCustomizer);
fileMergeDialog.show(); AppIcon.getInstance().requestAttention(myProject, true);
return fileMergeDialog.getProcessedFiles(); fileMergeDialog.show();
return fileMergeDialog.getProcessedFiles();
}
else {
final MultipleFileMergeDialog fileMergeDialog = new MultipleFileMergeDialog(myProject, files, provider, mergeDialogCustomizer);
AppIcon.getInstance().requestAttention(myProject, true);
fileMergeDialog.show();
return fileMergeDialog.getProcessedFiles();
}
} }
public void openCommittedChangesTab(final AbstractVcs vcs, public void openCommittedChangesTab(final AbstractVcs vcs,
@@ -0,0 +1,468 @@
// Copyright 2000-2018 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.merge
import com.intellij.CommonBundle
import com.intellij.diff.DiffManager
import com.intellij.diff.DiffRequestFactory
import com.intellij.diff.InvalidDiffRequestException
import com.intellij.diff.merge.MergeRequest
import com.intellij.diff.merge.MergeResult
import com.intellij.diff.merge.MergeUtil
import com.intellij.diff.util.DiffUtil
import com.intellij.icons.AllIcons
import com.intellij.ide.presentation.VirtualFilePresentation
import com.intellij.openapi.actionSystem.*
import com.intellij.openapi.command.WriteCommandAction.writeCommandAction
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.diff.impl.mergeTool.MergeVersion
import com.intellij.openapi.fileEditor.FileDocumentManager
import com.intellij.openapi.project.Project
import com.intellij.openapi.project.ex.ProjectManagerEx
import com.intellij.openapi.ui.DialogWrapper
import com.intellij.openapi.ui.Messages
import com.intellij.openapi.util.io.FileUtil
import com.intellij.openapi.vcs.VcsBundle
import com.intellij.openapi.vcs.VcsConfiguration
import com.intellij.openapi.vcs.VcsException
import com.intellij.openapi.vcs.changes.VcsDirtyScopeManager
import com.intellij.openapi.vfs.VfsUtil
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.ui.ColoredTreeCellRenderer
import com.intellij.ui.DoubleClickListener
import com.intellij.ui.SimpleTextAttributes
import com.intellij.ui.TableSpeedSearch
import com.intellij.ui.layout.*
import com.intellij.ui.speedSearch.SpeedSearchUtil
import com.intellij.ui.treeStructure.treetable.ListTreeTableModelOnColumns
import com.intellij.ui.treeStructure.treetable.TreeTable
import com.intellij.ui.treeStructure.treetable.TreeTableModel
import com.intellij.util.containers.Convertor
import com.intellij.util.ui.ColumnInfo
import com.intellij.util.ui.UIUtil
import com.intellij.util.ui.tree.TreeUtil
import org.jetbrains.annotations.NonNls
import java.awt.event.ActionEvent
import java.awt.event.MouseEvent
import java.io.IOException
import java.util.*
import javax.swing.*
import javax.swing.tree.DefaultMutableTreeNode
/**
* @author yole
*/
open class MultipleFileMergeDialog2(
private val project: Project?,
files: List<VirtualFile>,
private val mergeProvider: MergeProvider,
private val mergeDialogCustomizer: MergeDialogCustomizer
) : DialogWrapper(project) {
private var files = files.toMutableList()
private val mergeSession = (mergeProvider as? MergeProvider2)?.createMergeSession(files)
val processedFiles = mutableListOf<VirtualFile>()
private lateinit var table: TreeTable
private lateinit var acceptYoursButton: JButton
private lateinit var acceptTheirsButton: JButton
private lateinit var mergeButton: JButton
private val tableModel = ListTreeTableModelOnColumns(DefaultMutableTreeNode(), createColumns())
private val binaryFiles = files.filter { it.fileType.isBinary || mergeProvider.isBinary(it) }
private val projectManager = ProjectManagerEx.getInstanceEx()
private var groupByDirectory = project?.let { VcsConfiguration.getInstance(it).GROUP_MULTIFILE_MERGE_BY_DIRECTORY } ?: false
private val virtualFileRenderer = object : ColoredTreeCellRenderer() {
override fun customizeCellRenderer(tree: JTree,
value: Any?,
selected: Boolean,
expanded: Boolean,
leaf: Boolean,
row: Int,
hasFocus: Boolean) {
val data = (value as DefaultMutableTreeNode).userObject
when (data) {
is String -> {
icon = AllIcons.Nodes.Folder
val parent = value.parent as DefaultMutableTreeNode
val parentPath = parent.userObject as String?
append(if (parentPath == null) FileUtil.getLocationRelativeToUserHome(data) else data.substring(parentPath.length + 1))
}
is VirtualFile -> {
icon = VirtualFilePresentation.getIcon(data)
append(data.name, SimpleTextAttributes.REGULAR_ATTRIBUTES)
if (data in binaryFiles) {
append(" [${VcsBundle.message("multiple.file.merge.type.binary")}]", SimpleTextAttributes.REGULAR_ATTRIBUTES)
}
if (!groupByDirectory) {
val parent = data.parent
if (parent != null) {
append(" (" + FileUtil.getLocationRelativeToUserHome(FileUtil.toSystemDependentName(parent.presentableUrl)) + ")", SimpleTextAttributes.GRAYED_ATTRIBUTES)
}
}
}
}
SpeedSearchUtil.applySpeedSearchHighlighting(this@MultipleFileMergeDialog2.table, this, true, selected)
}
override fun calcFocusedState() = table.hasFocus()
}
init {
projectManager.blockReloadingProjectOnExternalChanges()
title = mergeDialogCustomizer.multipleFileDialogTitle
virtualFileRenderer.font = UIUtil.getListFont()
@Suppress("LeakingThis")
init()
updateColumnSizes()
updateTree()
table.tree.selectionModel.addTreeSelectionListener { updateButtonState() }
table.selectionModel.setSelectionInterval(0, 0)
object : DoubleClickListener() {
override fun onDoubleClick(event: MouseEvent): Boolean {
showMergeDialog()
return true
}
}.installOn(table)
TableSpeedSearch(table, Convertor { (it as? VirtualFile)?.name })
}
override fun createCenterPanel(): JComponent {
return panel {
val description = mergeDialogCustomizer.getMultipleFileMergeDescription(processedFiles)
if (!description.isNullOrBlank()) {
row {
label(description!!)
}
}
row {
ActionManager.getInstance().createActionToolbar(ActionPlaces.UNKNOWN, createToolbarActionGroup(), true).component()
}
row {
scrollPane(TreeTable(tableModel).also {
table = it
it.tree.isRootVisible = false
it.setTreeCellRenderer(virtualFileRenderer)
if (tableModel.columnCount > 1) {
it.setShowColumns(true)
}
it.rowHeight = virtualFileRenderer.preferredSize.height
}, growX, growY, pushX, pushY)
cell(isVerticalFlow = true) {
JButton("Accept Yours").also {
it.addActionListener { acceptRevision(MergeSession.Resolution.AcceptedYours) }
acceptYoursButton = it
}()
JButton("Accept Theirs").also {
it.addActionListener { acceptRevision(MergeSession.Resolution.AcceptedTheirs) }
acceptTheirsButton = it
}()
val mergeAction = object : AbstractAction() {
override fun actionPerformed(e: ActionEvent) {
showMergeDialog()
}
}
mergeAction.putValue(DialogWrapper.DEFAULT_ACTION, java.lang.Boolean.TRUE)
createJButtonForAction(mergeAction).also {
it.text = "Merge..."
mergeButton = it
}()
}
}
}
}
private fun createColumns(): Array<ColumnInfo<*, *>> {
val columns = ArrayList<ColumnInfo<*, *>>()
columns.add(object : ColumnInfo<DefaultMutableTreeNode, Any>(VcsBundle.message("multiple.file.merge.column.name")) {
override fun valueOf(node: DefaultMutableTreeNode) = node.userObject
override fun getColumnClass(): Class<*> = TreeTableModel::class.java
})
mergeSession?.mergeInfoColumns?.mapTo(columns) { ColumnInfoAdapter(it) }
return columns.toTypedArray()
}
private class ColumnInfoAdapter(private val base: ColumnInfo<Any, Any>) : ColumnInfo<DefaultMutableTreeNode, Any>(base.name) {
override fun valueOf(node: DefaultMutableTreeNode) = (node.userObject as? VirtualFile)?.let { base.valueOf(it) }
override fun getMaxStringValue() = base.maxStringValue
override fun getAdditionalWidth() = base.additionalWidth
}
private fun updateColumnSizes() {
for ((index, columnInfo) in tableModel.columns.withIndex()) {
val column = table.columnModel.getColumn(index)
columnInfo.maxStringValue?.let {
column.maxWidth = table.getFontMetrics(table.font).stringWidth(it) + columnInfo.additionalWidth
}
}
}
private fun createToolbarActionGroup() = DefaultActionGroup().apply {
add(object : ToggleAction("Group by Directory", "Group by Directory", AllIcons.Actions.GroupByPackage) {
override fun isSelected(e: AnActionEvent?) = groupByDirectory
override fun setSelected(e: AnActionEvent?, state: Boolean) {
groupByDirectory = state
project?.let { VcsConfiguration.getInstance(it).GROUP_MULTIFILE_MERGE_BY_DIRECTORY = groupByDirectory }
updateTree()
}
})
}
private fun updateTree() {
val root = DefaultMutableTreeNode()
tableModel.setRoot(root)
val commonAncestor = if (groupByDirectory) VfsUtil.getCommonAncestor(files) else null
if (commonAncestor != null) {
buildGroupedFileTree(root, commonAncestor)
}
else {
for (file in files) {
tableModel.insertNodeInto(DefaultMutableTreeNode(file, false), root, root.childCount)
}
}
TreeUtil.expandAll(table.tree)
}
private fun buildGroupedFileTree(root: DefaultMutableTreeNode, commonAncestor: VirtualFile) {
val directoryNodes = mutableMapOf<String, DefaultMutableTreeNode>()
val commonAncestorNode = DefaultMutableTreeNode(commonAncestor.path)
directoryNodes[commonAncestor.path] = commonAncestorNode
tableModel.insertNodeInto(commonAncestorNode, root, 0)
for (file in files) {
var currentParentNode = commonAncestorNode
var index = commonAncestor.path.length
val lastIndex = file.path.lastIndexOf('/')
while (index < lastIndex) {
val nextIndex = file.path.indexOf('/', index)
val path = file.path.substring(0, nextIndex)
val directoryNode = directoryNodes.getOrPut(path) {
DefaultMutableTreeNode(path).also {
TreeUtil.insertNode(it, currentParentNode, tableModel, compareBy { it.userObject as String })
}
}
index = nextIndex + 1
currentParentNode = directoryNode
}
TreeUtil.insertNode(
DefaultMutableTreeNode(file, false), currentParentNode, tableModel,
compareBy { (it.userObject as VirtualFile).path }
)
}
collapseMiddlePaths(commonAncestorNode)
}
private fun collapseMiddlePaths(node: DefaultMutableTreeNode) {
if (node.childCount == 1) {
val child = node.firstChild as DefaultMutableTreeNode
if (child.userObject is String) {
val parent = node.parent as DefaultMutableTreeNode
tableModel.removeNodeFromParent(node)
TreeUtil.insertNode(child, parent, tableModel, compareBy { it.userObject as String })
collapseMiddlePaths(child)
}
}
else {
for (child in node.children()) {
collapseMiddlePaths(child as DefaultMutableTreeNode)
}
}
}
private fun updateButtonState() {
val selectedFiles = getSelectedFiles()
val haveSelection = selectedFiles.any()
val haveUnmergeableFiles = selectedFiles.any { mergeSession?.canMerge(it) == false }
acceptYoursButton.isEnabled = haveSelection
acceptTheirsButton.isEnabled = haveSelection
mergeButton.isEnabled = haveSelection && !haveUnmergeableFiles
}
private fun getSelectedFiles(): List<VirtualFile> {
return TreeUtil.collectSelectedObjectsOfType(table.tree, VirtualFile::class.java)
}
protected fun beforeResolve(files: Collection<VirtualFile>): Boolean {
return true
}
override fun createActions() = arrayOf(cancelAction)
override fun getCancelAction(): Action = super.getCancelAction().apply {
putValue(Action.NAME, CommonBundle.getCloseButtonText())
}
override fun dispose() {
projectManager.unblockReloadingProjectOnExternalChanges()
super.dispose()
}
@NonNls
override fun getDimensionServiceKey() = "MultipleFileMergeDialog"
private fun acceptRevision(resolution: MergeSession.Resolution) {
FileDocumentManager.getInstance().saveAllDocuments()
val files = getSelectedFiles()
if (!beforeResolve(files)) {
return
}
try {
for (file in files) {
acceptFileRevision(file, resolution)
checkMarkModifiedProject(file)
markFileProcessed(file, resolution)
}
}
catch (e: Exception) {
LOG.warn(e)
Messages.showErrorDialog(contentPanel, "Error saving merged data: " + e.message)
}
updateModelFromFiles()
}
private fun acceptFileRevision(file: VirtualFile, resolution: MergeSession.Resolution) {
if (mergeSession?.canMerge(file) == false) return
if (mergeSession?.acceptFileRevision(file, resolution) == true) return
if (!DiffUtil.makeWritable(project, file)) {
throw IOException("File is read-only: " + file.presentableName)
}
val isCurrent = resolution == MergeSession.Resolution.AcceptedYours
val data = mergeProvider.loadRevisions(file)
writeCommandAction(project).withName("Accept " + if (isCurrent) "Yours" else "Theirs").run<Exception> {
if (isCurrent) {
file.setBinaryContent(data.CURRENT)
}
else {
file.setBinaryContent(data.LAST)
}
}
}
private fun markFileProcessed(file: VirtualFile, resolution: MergeSession.Resolution) {
files.remove(file)
if (mergeSession != null) {
mergeSession.conflictResolvedForFile(file, resolution)
}
else {
mergeProvider.conflictResolvedForFile(file)
}
processedFiles.add(file)
project?.let { VcsDirtyScopeManager.getInstance(it).fileDirty(file) }
}
private fun updateModelFromFiles() {
if (files.isEmpty()) {
doCancelAction()
}
else {
var selIndex = table.selectionModel.minSelectionIndex
updateTree()
if (selIndex >= files.size) {
selIndex = files.size - 1
}
table.selectionModel.setSelectionInterval(selIndex, selIndex)
}
}
private fun showMergeDialog() {
val requestFactory = DiffRequestFactory.getInstance()
val files = getSelectedFiles()
if (files.isEmpty()) return
if (!beforeResolve(files)) {
return
}
for (file in files) {
val mergeData: MergeData
try {
mergeData = mergeProvider.loadRevisions(file)
}
catch (ex: VcsException) {
Messages.showErrorDialog(contentPanel, "Error loading revisions to merge: " + ex.message)
break
}
if (mergeData.CURRENT == null || mergeData.LAST == null || mergeData.ORIGINAL == null) {
Messages.showErrorDialog(contentPanel, "Error loading revisions to merge")
break
}
val leftTitle = mergeDialogCustomizer.getLeftPanelTitle(file)
val baseTitle = mergeDialogCustomizer.getCenterPanelTitle(file)
val rightTitle = mergeDialogCustomizer.getRightPanelTitle(file, mergeData.LAST_REVISION_NUMBER)
val title = mergeDialogCustomizer.getMergeWindowTitle(file)
val byteContents = listOf(mergeData.CURRENT, mergeData.ORIGINAL, mergeData.LAST)
val contentTitles = listOf(leftTitle, baseTitle, rightTitle)
val callback = { result: MergeResult ->
val document = FileDocumentManager.getInstance().getCachedDocument(file)
if (document != null) FileDocumentManager.getInstance().saveDocument(document)
checkMarkModifiedProject(file)
if (result != MergeResult.CANCEL) {
markFileProcessed(file, getSessionResolution(result))
}
}
val request: MergeRequest
try {
if (mergeProvider.isBinary(file)) { // respect MIME-types in svn
request = requestFactory.createBinaryMergeRequest(project, file, byteContents, title, contentTitles, callback)
}
else {
request = requestFactory.createMergeRequest(project, file, byteContents, title, contentTitles, callback)
}
MergeUtil.putRevisionInfos(request, mergeData)
}
catch (e: InvalidDiffRequestException) {
LOG.error(e)
Messages.showErrorDialog(contentPanel, "Can't show merge dialog")
break
}
DiffManager.getInstance().showMerge(project, request)
}
updateModelFromFiles()
}
private fun getSessionResolution(result: MergeResult): MergeSession.Resolution = when (result) {
MergeResult.LEFT -> MergeSession.Resolution.AcceptedYours
MergeResult.RIGHT -> MergeSession.Resolution.AcceptedTheirs
MergeResult.RESOLVED -> MergeSession.Resolution.Merged
else -> throw IllegalArgumentException(result.name)
}
private fun checkMarkModifiedProject(file: VirtualFile) {
MergeVersion.MergeDocumentVersion.reportProjectFileChangeIfNeeded(project, file)
}
override fun getPreferredFocusedComponent(): JComponent? = table
companion object {
private val LOG = Logger.getInstance(MultipleFileMergeDialog2::class.java)
}
}