git-branches-dashboard: add edit/remove actions for Git remotes
[idea/community.git] / plugins / git4idea / src / git4idea / remote / GitConfigureRemotesDialog.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
3 package git4idea.remote
4
5 import com.intellij.dvcs.DvcsUtil
6 import com.intellij.dvcs.DvcsUtil.sortRepositories
7 import com.intellij.openapi.components.service
8 import com.intellij.openapi.diagnostic.logger
9 import com.intellij.openapi.progress.ProgressIndicator
10 import com.intellij.openapi.progress.ProgressManager
11 import com.intellij.openapi.progress.Task
12 import com.intellij.openapi.project.Project
13 import com.intellij.openapi.ui.DialogWrapper
14 import com.intellij.openapi.ui.DialogWrapper.IdeModalityType.IDE
15 import com.intellij.openapi.ui.DialogWrapper.IdeModalityType.PROJECT
16 import com.intellij.openapi.ui.Messages.*
17 import com.intellij.openapi.util.registry.Registry
18 import com.intellij.ui.ColoredTableCellRenderer
19 import com.intellij.ui.SimpleTextAttributes
20 import com.intellij.ui.ToolbarDecorator
21 import com.intellij.ui.table.JBTable
22 import com.intellij.util.ui.JBUI
23 import com.intellij.util.ui.UIUtil.DEFAULT_HGAP
24 import git4idea.commands.Git
25 import git4idea.commands.GitCommandResult
26 import git4idea.i18n.GitBundle.message
27 import git4idea.repo.GitRemote
28 import git4idea.repo.GitRemote.ORIGIN
29 import git4idea.repo.GitRepository
30 import org.jetbrains.annotations.Nls
31 import java.awt.Component
32 import java.awt.Font
33 import java.util.*
34 import javax.swing.*
35 import javax.swing.table.AbstractTableModel
36 import kotlin.math.min
37
38 private val LOG = logger<GitConfigureRemotesDialog>()
39
40 class GitConfigureRemotesDialog(val project: Project, val repositories: Collection<GitRepository>) :
41     DialogWrapper(project, true, getModalityType()) {
42
43   private val git = service<Git>()
44
45   private val NAME_COLUMN = 0
46   private val URL_COLUMN = 1
47   private val REMOTE_PADDING = 30
48   private val table = JBTable(RemotesTableModel())
49
50   private var nodes = buildNodes(repositories)
51
52   init {
53     init()
54     title = message("remotes.dialog.title")
55     updateTableWidth()
56   }
57
58   override fun getDimensionServiceKey(): String = javaClass.name
59
60   override fun createActions(): Array<Action> = arrayOf(okAction)
61
62   override fun getPreferredFocusedComponent(): JBTable = table
63
64   override fun createCenterPanel(): JComponent? {
65     table.selectionModel = DefaultListSelectionModel()
66     table.setSelectionMode(ListSelectionModel.SINGLE_SELECTION)
67     table.intercellSpacing = JBUI.emptySize()
68     table.setDefaultRenderer(Any::class.java, MyCellRenderer())
69
70     return ToolbarDecorator.createDecorator(table).
71         setAddAction { addRemote() }.
72         setRemoveAction { removeRemote() }.
73         setEditAction { editRemote() }.
74         setEditActionUpdater { isRemoteSelected() }.
75         setRemoveActionUpdater { isRemoteSelected() }.
76         disableUpDownActions().createPanel()
77   }
78
79   private fun addRemote() {
80     val repository = getSelectedRepo()
81     val proposedName = if (repository.remotes.any { it.name == ORIGIN }) "" else ORIGIN
82     val dialog = GitDefineRemoteDialog(repository, git, proposedName, "")
83     if (dialog.showAndGet()) {
84       runInModalTask(message("remotes.dialog.adding.remote"),
85                      message("remote.dialog.add.remote"),
86                      message("remotes.dialog.cannot.add.remote.error.message", dialog.remoteName, dialog.remoteUrl),
87                      repository, rebuildTreeOnSuccess) {
88         git.addRemote(repository, dialog.remoteName, dialog.remoteUrl)
89       }
90     }
91   }
92
93   private fun removeRemote() {
94     val remoteNode = getSelectedRemote()!!
95     val remote = remoteNode.remote
96     val repository = remoteNode.repository
97
98     removeRemote(git, repository, remote, rootPane, rebuildTreeOnSuccess)
99   }
100
101   private fun editRemote() {
102     val remoteNode = getSelectedRemote()!!
103     val remote = remoteNode.remote
104     val repository = remoteNode.repository
105
106     editRemote(git, repository, remote, rebuildTreeOnSuccess)
107   }
108
109   private fun updateTableWidth() {
110     var maxNameWidth = 30
111     var maxUrlWidth = 250
112     for (node in nodes) {
113       val fontMetrics = table.getFontMetrics(UIManager.getFont("Table.font").deriveFont(Font.BOLD))
114       val nameWidth = fontMetrics.stringWidth(node.getPresentableString())
115       val remote = (node as? RemoteNode)?.remote
116       val urlWidth = if (remote == null) 0 else fontMetrics.stringWidth(getUrl(remote))
117       if (maxNameWidth < nameWidth) maxNameWidth = nameWidth
118       if (maxUrlWidth < urlWidth) maxUrlWidth = urlWidth
119     }
120     maxNameWidth += REMOTE_PADDING + DEFAULT_HGAP
121
122     table.columnModel.getColumn(NAME_COLUMN).preferredWidth = maxNameWidth
123     table.columnModel.getColumn(URL_COLUMN).preferredWidth = maxUrlWidth
124
125     table.preferredScrollableViewportSize = JBUI.size(maxNameWidth + maxUrlWidth + DEFAULT_HGAP, -1)
126     table.visibleRowCount = min(nodes.size + 3, 8)
127   }
128
129   private fun buildNodes(repositories: Collection<GitRepository>): List<Node> {
130     val nodes = mutableListOf<Node>()
131     for (repository in sortRepositories(repositories)) {
132       if (repositories.size > 1) nodes.add(RepoNode(repository))
133       for (remote in sortedRemotes(repository)) {
134         nodes.add(RemoteNode(remote, repository))
135       }
136     }
137     return nodes
138   }
139
140   private fun sortedRemotes(repository: GitRepository): List<GitRemote> {
141     return repository.remotes.sortedWith(Comparator<GitRemote> { r1, r2 ->
142       if (r1.name == ORIGIN) {
143         if (r2.name == ORIGIN) 0 else -1
144       }
145       else if (r2.name == ORIGIN) 1 else r1.name.compareTo(r2.name)
146     })
147   }
148
149   private fun rebuildTable() {
150     nodes = buildNodes(repositories)
151     (table.model as RemotesTableModel).fireTableDataChanged()
152   }
153
154   private val rebuildTreeOnSuccess: () -> Unit = { rebuildTable() }
155
156   private fun getSelectedRepo(): GitRepository {
157     val selectedRow = table.selectedRow
158     if (selectedRow < 0) return sortRepositories(repositories).first()
159     val value = nodes[selectedRow]
160     if (value is RepoNode) return value.repository
161     if (value is RemoteNode) return value.repository
162     throw IllegalStateException("Unexpected selected value: $value")
163   }
164
165   private fun getSelectedRemote() : RemoteNode? {
166     val selectedRow = table.selectedRow
167     if (selectedRow < 0) return null
168     return nodes[selectedRow] as? RemoteNode
169   }
170
171   private fun isRemoteSelected() = getSelectedRemote() != null
172
173   private abstract class Node {
174     abstract fun getPresentableString() : String
175   }
176   private class RepoNode(val repository: GitRepository) : Node() {
177     override fun toString() = repository.presentableUrl
178     override fun getPresentableString() = DvcsUtil.getShortRepositoryName(repository)
179   }
180   private class RemoteNode(val remote: GitRemote, val repository: GitRepository) : Node() {
181     override fun toString() = remote.name
182     override fun getPresentableString() = remote.name
183   }
184
185   private inner class RemotesTableModel : AbstractTableModel() {
186     override fun getRowCount() = nodes.size
187     override fun getColumnCount() = 2
188
189     override fun getColumnName(column: Int): String {
190       if (column == NAME_COLUMN) return message("remotes.remote.column.name")
191       else return message("remotes.remote.column.url")
192     }
193
194     override fun getValueAt(rowIndex: Int, columnIndex: Int): Any {
195       val node = nodes[rowIndex]
196       when {
197         columnIndex == NAME_COLUMN -> return node
198         node is RepoNode -> return ""
199         node is RemoteNode -> return getUrl(node.remote)
200         else -> {
201           LOG.error("Unexpected position at row $rowIndex and column $columnIndex")
202           return ""
203         }
204       }
205     }
206   }
207
208   private inner class MyCellRenderer : ColoredTableCellRenderer() {
209     override fun customizeCellRenderer(table: JTable, value: Any?, selected: Boolean, hasFocus: Boolean, row: Int, column: Int) {
210       if (value is RepoNode) {
211         append(value.getPresentableString(), SimpleTextAttributes.REGULAR_BOLD_ATTRIBUTES)
212       }
213       else if (value is RemoteNode) {
214         if (repositories.size > 1) append("", SimpleTextAttributes.REGULAR_ATTRIBUTES, REMOTE_PADDING, SwingConstants.LEFT)
215         append(value.getPresentableString())
216       }
217       else if (value is String) {
218         append(value)
219       }
220       border = null
221     }
222   }
223 }
224
225 fun removeRemote(git: Git, repository: GitRepository, remote: GitRemote, parent: Component? = null, onSuccess: () -> Unit = {}) {
226   if (YES == showYesNoDialog(if (parent == null) parent else repository.project,
227                              message("remotes.dialog.remove.remote.message", remote.name, getUrl(remote)),
228                              message("remotes.dialog.remove.remote.title"), getQuestionIcon())) {
229     runInModalTask(message("remotes.dialog.removing.remote.progress"),
230                    message("remotes.dialog.removing.remote.error.title"),
231                    message("remotes.dialog.removing.remote.error.message", remote),
232                    repository, onSuccess) {
233       git.removeRemote(repository, remote)
234     }
235   }
236 }
237
238 fun editRemote(git: Git, repository: GitRepository, remote: GitRemote, onSuccess: () -> Unit = {}) {
239   val oldName = remote.name
240   val oldUrl = getUrl(remote)
241   val dialog = GitDefineRemoteDialog(repository, git, oldName, oldUrl)
242   if (dialog.showAndGet()) {
243     val newRemoteName = dialog.remoteName
244     val newRemoteUrl = dialog.remoteUrl
245     if (newRemoteName == oldName && newRemoteUrl == oldUrl) return
246     runInModalTask(message("remotes.changing.remote.progress"),
247                    message("remotes.changing.remote.error.title"),
248                    message("remotes.changing.remote.error.message", oldName, newRemoteName, newRemoteUrl),
249                    repository, onSuccess) {
250       changeRemote(git, repository, oldName, oldUrl, newRemoteName, newRemoteUrl)
251     }
252   }
253 }
254
255 private fun getUrl(remote: GitRemote) = remote.urls.firstOrNull() ?: ""
256
257 private fun changeRemote(git: Git, repo: GitRepository, oldName: String, oldUrl: String, newName: String, newUrl: String): GitCommandResult {
258   var result : GitCommandResult? = null
259   if (newName != oldName) {
260     result = git.renameRemote(repo, oldName, newName)
261     if (!result.success()) return result
262   }
263   if (newUrl != oldUrl) {
264     result = git.setRemoteUrl(repo, newName, newUrl) // NB: remote name has just been changed
265   }
266   return result!! // at least one of two has changed
267 }
268
269 private fun runInModalTask(@Nls(capitalization = Nls.Capitalization.Title) title: String,
270                            @Nls(capitalization = Nls.Capitalization.Title) errorTitle: String,
271                            @Nls(capitalization = Nls.Capitalization.Sentence) errorMessage: String,
272                            repository: GitRepository,
273                            onSuccess: () -> Unit,
274                            operation: () -> GitCommandResult) {
275   ProgressManager.getInstance().run(object : Task.Modal(repository.project, title, true) {
276     private var result: GitCommandResult? = null
277
278     override fun run(indicator: ProgressIndicator) {
279       result = operation()
280       repository.update()
281     }
282
283     override fun onSuccess() {
284       onSuccess()
285       if (result == null || !result!!.success()) {
286         val errorDetails = if (result == null) message("remotes.operation.not.executed.message") else result!!.errorOutputAsJoinedString
287         val message = message("remotes.operation.error.message", errorMessage, repository, errorDetails)
288         LOG.warn(message)
289         showErrorDialog(myProject, message, errorTitle)
290       }
291     }
292   })
293 }
294
295 private fun getModalityType() = if (Registry.`is`("ide.perProjectModality")) PROJECT else IDE