diff --git a/platform/util/resources/misc/registry.properties b/platform/util/resources/misc/registry.properties index 8263623bfee0..0ec6d2e6afc7 100644 --- a/platform/util/resources/misc/registry.properties +++ b/platform/util/resources/misc/registry.properties @@ -530,6 +530,9 @@ vcs.enable.partial.changelists.redo.restartRequired=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.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.deferIconLoading=true diff --git a/platform/vcs-api/src/com/intellij/openapi/vcs/VcsConfiguration.java b/platform/vcs-api/src/com/intellij/openapi/vcs/VcsConfiguration.java index b92e72fff974..3a84e2f994bc 100644 --- a/platform/vcs-api/src/com/intellij/openapi/vcs/VcsConfiguration.java +++ b/platform/vcs-api/src/com/intellij/openapi/vcs/VcsConfiguration.java @@ -130,6 +130,7 @@ public final class VcsConfiguration implements PersistentStateComponent, + 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() + 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> { + val columns = ArrayList>() + columns.add(object : ColumnInfo(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) : ColumnInfo(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() + 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 { + return TreeUtil.collectSelectedObjectsOfType(table.tree, VirtualFile::class.java) + } + + protected fun beforeResolve(files: Collection): 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 { + 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) + } + +} \ No newline at end of file