commit - 9ad5985df1ea8ce7f124e05009ea579d466d1142
commit + d0d47588e6e7212652f98d758170e30ee404ed6d
blob - 047ec84028d0f72f2d6515f20671b560e0f40f22
blob + a6ed987a10b8e363ec14e65568d0c7a2070cb521
--- src/main/kotlin/dev/nezzontli/gotvcs/cli/GotCommandLineWrapper.kt
+++ src/main/kotlin/dev/nezzontli/gotvcs/cli/GotCommandLineWrapper.kt
}
return entries
}
+
+ /**
+ * `got clone` only ever produces a bare repository, never a work tree
+ * (got has no non-bare repo layout) -- [checkout] is the separate step
+ * that populates a usable `.got/` work tree from it.
+ */
+ @Throws(VcsException::class)
+ fun clone(parentDir: File, url: String, bareRepoDir: File) {
+ run(parentDir, "clone", url, bareRepoDir.absolutePath)
+ }
+
+ @Throws(VcsException::class)
+ fun checkout(parentDir: File, bareRepoDir: File, workTreeDir: File) {
+ run(parentDir, "checkout", bareRepoDir.absolutePath, workTreeDir.absolutePath)
+ }
}
blob - /dev/null
blob + 03df669dac2b86b3755b08217c6f73ddc7219fc0 (mode 644)
--- /dev/null
+++ src/main/kotlin/dev/nezzontli/gotvcs/checkout/GotCheckoutProvider.kt
+package dev.nezzontli.gotvcs.checkout
+
+import com.intellij.openapi.application.ModalityState
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.vcs.CheckoutProvider
+import com.intellij.openapi.vcs.ui.VcsCloneComponent
+import com.intellij.openapi.vcs.ui.cloneDialog.VcsCloneDialogComponentStateListener
+
+/** Adds "Got" to the Version control dropdown of the Clone Repository dialog. */
+class GotCheckoutProvider : CheckoutProvider {
+
+ override fun getVcsName(): String = "Got"
+
+ override fun buildVcsCloneComponent(
+ project: Project,
+ modalityState: ModalityState,
+ listener: VcsCloneDialogComponentStateListener,
+ ): VcsCloneComponent = GotCloneDialogComponent(project, modalityState, listener)
+}
blob - /dev/null
blob + 4cdde7df3d578313f2ac9c8d3b56712ec079a767 (mode 644)
--- /dev/null
+++ src/main/kotlin/dev/nezzontli/gotvcs/checkout/GotCloneDialogComponent.kt
+package dev.nezzontli.gotvcs.checkout
+
+import com.intellij.notification.NotificationGroupManager
+import com.intellij.notification.NotificationType
+import com.intellij.openapi.application.ModalityState
+import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory
+import com.intellij.openapi.progress.ProgressIndicator
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.ui.TextFieldWithBrowseButton
+import com.intellij.openapi.ui.ValidationInfo
+import com.intellij.openapi.vcs.CheckoutProvider
+import com.intellij.openapi.vcs.ui.VcsCloneComponent
+import com.intellij.openapi.vcs.ui.cloneDialog.VcsCloneDialogComponentStateListener
+import com.intellij.openapi.vfs.LocalFileSystem
+import com.intellij.openapi.wm.impl.welcomeScreen.cloneableProjects.CloneableProjectsService
+import com.intellij.ui.JBColor
+import com.intellij.ui.TextFieldWithHistory
+import com.intellij.ui.components.JBLabel
+import com.intellij.util.ui.FormBuilder
+import dev.nezzontli.gotvcs.GotVcs
+import dev.nezzontli.gotvcs.cli.GotCommandLineWrapper
+import java.awt.Dimension
+import java.io.File
+import java.nio.file.Files
+import java.nio.file.Path
+import java.nio.file.Paths
+import javax.swing.JComponent
+import javax.swing.event.DocumentEvent
+import javax.swing.event.DocumentListener
+
+private const val SSH_URL_PREFIX = "ssh://"
+
+/**
+ * Backs the "Got" entry of the Version control dropdown in the Clone
+ * Repository dialog. This is a plain [VcsCloneComponent] implementation
+ * (URL field + directory field) rather than a subclass of the platform's
+ * `DvcsCloneDialogComponent` (which Git4Idea uses for the same UI) --
+ * that base class and its `MainPanelCustomizer` are `@ApiStatus.Internal`
+ * and fail `verifyPlugin`.
+ */
+class GotCloneDialogComponent(
+ private val project: Project,
+ @Suppress("UNUSED_PARAMETER") modalityState: ModalityState,
+ private var dialogStateListener: VcsCloneDialogComponentStateListener,
+) : VcsCloneComponent {
+
+ private val urlField = TextFieldWithHistory().apply {
+ setHistorySize(-1)
+ history = GotRememberedInputs.getInstance().visitedUrls
+ // The combo box's own max-size hint otherwise caps it well short of
+ // the panel's width, unlike the (already full-width) directory field.
+ preferredSize = Dimension(400, preferredSize.height)
+ maximumSize = Dimension(Int.MAX_VALUE, preferredSize.height)
+ }
+
+ private val directoryField = TextFieldWithBrowseButton().apply {
+ addBrowseFolderListener(
+ project,
+ FileChooserDescriptorFactory.createSingleFolderDescriptor().withTitle("Destination Directory"),
+ )
+ }
+
+ private val errorLabel = JBLabel().apply {
+ foreground = JBColor.RED
+ isVisible = false
+ }
+
+ private val panel = FormBuilder.createFormBuilder()
+ .addLabeledComponent("URL:", urlField)
+ .addLabeledComponent("Directory:", directoryField)
+ .addComponent(errorLabel)
+ .panel
+
+ // Tracks the last directory this component filled in on its own, so
+ // typing a URL keeps suggesting a directory until the user overrides it.
+ private var lastSuggestedDirectory: String? = null
+
+ private var listenersAttached = false
+
+ init {
+ directoryField.text = suggestedParentDir()
+ }
+
+ override fun getView(): JComponent = panel
+
+ override fun getPreferredFocusedComponent(): JComponent = urlField
+
+ override fun isOkEnabled(): Boolean = doValidateAll().isEmpty()
+
+ override fun doValidateAll(): List<ValidationInfo> {
+ val url = urlField.text.trim()
+ if (url.isEmpty()) return listOf(ValidationInfo("URL cannot be empty", urlField))
+ if (!url.startsWith(SSH_URL_PREFIX)) {
+ return listOf(
+ ValidationInfo("Got only supports cloning over ssh:// (e.g. ssh://user@host:port/path/repo.git)", urlField)
+ )
+ }
+
+ val directory = directoryField.text.trim()
+ if (directory.isEmpty()) return listOf(ValidationInfo("Directory cannot be empty", directoryField.textField))
+ val workTreeDir = Paths.get(directory).toAbsolutePath()
+ if (Files.exists(workTreeDir)) {
+ return listOf(ValidationInfo("Directory already exists", directoryField.textField))
+ }
+ if (Files.exists(bareRepoDirFor(workTreeDir))) {
+ return listOf(ValidationInfo("${bareRepoDirFor(workTreeDir)} already exists", directoryField.textField))
+ }
+ return emptyList()
+ }
+
+ override fun onComponentSelected(dialogStateListener: VcsCloneDialogComponentStateListener) {
+ this.dialogStateListener = dialogStateListener
+ updateState()
+ if (listenersAttached) return
+ listenersAttached = true
+
+ urlField.textEditor.document.addDocumentListener(object : DocumentListener {
+ override fun insertUpdate(e: DocumentEvent) = onUrlChanged()
+ override fun removeUpdate(e: DocumentEvent) = onUrlChanged()
+ override fun changedUpdate(e: DocumentEvent) = onUrlChanged()
+ private fun onUrlChanged() {
+ if (directoryField.text.isBlank() || directoryField.text == lastSuggestedDirectory) {
+ directoryField.text = suggestedParentDir()
+ }
+ updateState()
+ }
+ })
+ directoryField.textField.document.addDocumentListener(object : DocumentListener {
+ override fun insertUpdate(e: DocumentEvent) = updateState()
+ override fun removeUpdate(e: DocumentEvent) = updateState()
+ override fun changedUpdate(e: DocumentEvent) = updateState()
+ })
+ }
+
+ // Only surface a validation message once both fields have real content --
+ // the "cannot be empty" errors from doValidateAll() would otherwise flash
+ // on every keystroke while a field is still being typed into.
+ private fun updateState() {
+ val errors = doValidateAll()
+ dialogStateListener.onOkActionEnabled(errors.isEmpty())
+ val bothFieldsFilled = urlField.text.isNotBlank() && directoryField.text.isNotBlank()
+ val message = errors.firstOrNull()?.message.takeIf { bothFieldsFilled }
+ errorLabel.text = message.orEmpty()
+ errorLabel.isVisible = message != null
+ }
+
+ // CloneableProjectsService (the same path Git4Idea uses) is what adds the
+ // cloned directory to Recent Projects once CloneTask.run() returns
+ // SUCCESS; a plain Task.Backgroundable wouldn't register it anywhere.
+ override fun doClone(listener: CheckoutProvider.Listener) {
+ val cloneUrl = urlField.text.trim()
+ val workTreeDir = Paths.get(directoryField.text.trim()).toAbsolutePath()
+ val parentDir = workTreeDir.parent
+ val bareRepoDir = bareRepoDirFor(workTreeDir)
+
+ GotRememberedInputs.getInstance().addUrl(cloneUrl)
+ GotRememberedInputs.getInstance().setCloneParentDir(parentDir.toString())
+
+ val cloneProject = project
+ val task = object : CloneableProjectsService.CloneTask {
+ override fun taskInfo() = CloneableProjectsService.CloneTaskInfo(
+ "Cloning $cloneUrl...",
+ "Cancel cloning $cloneUrl",
+ "Clone",
+ "Clone the repository",
+ "Clone failed",
+ "Clone canceled",
+ "Stop cloning?",
+ "Stop cloning $cloneUrl?",
+ )
+
+ override fun run(indicator: ProgressIndicator): CloneableProjectsService.CloneStatus {
+ indicator.isIndeterminate = true
+ val commandLine = GotCommandLineWrapper()
+ try {
+ Files.createDirectories(parentDir)
+ commandLine.clone(parentDir.toFile(), cloneUrl, bareRepoDir.toFile())
+ commandLine.checkout(parentDir.toFile(), bareRepoDir.toFile(), workTreeDir.toFile())
+ } catch (e: Exception) {
+ NotificationGroupManager.getInstance().getNotificationGroup("got")
+ .createNotification("got clone failed", e.message.orEmpty(), NotificationType.ERROR)
+ .notify(cloneProject)
+ return CloneableProjectsService.CloneStatus.FAILURE
+ }
+
+ LocalFileSystem.getInstance().refreshAndFindFileByIoFile(workTreeDir.toFile())
+ listener.directoryCheckedOut(workTreeDir.toFile(), GotVcs.getKey())
+ listener.checkoutCompleted()
+ return CloneableProjectsService.CloneStatus.SUCCESS
+ }
+ }
+ CloneableProjectsService.getInstance().runCloneTask(workTreeDir, task)
+ }
+
+ override fun dispose() {}
+
+ /** got clone always produces a bare repository; the work tree is a separate `got checkout` step. */
+ private fun bareRepoDirFor(workTreeDir: Path): Path =
+ workTreeDir.resolveSibling("${workTreeDir.fileName}.git")
+
+ private fun suggestedParentDir(): String {
+ val parent = GotRememberedInputs.getInstance().cloneParentDir?.takeIf { it.isNotBlank() }
+ ?: (System.getProperty("user.home") + File.separator + "IdeaProjects")
+ val repoName = urlField.text.trim().trimEnd('/').substringAfterLast('/').removeSuffix(".git")
+ val suggestion = if (repoName.isBlank()) parent else Paths.get(parent, repoName).toString()
+ lastSuggestedDirectory = suggestion
+ return suggestion
+ }
+}
blob - /dev/null
blob + f051d283d10df04baba33f17bc277d6118c74b70 (mode 644)
--- /dev/null
+++ src/main/kotlin/dev/nezzontli/gotvcs/checkout/GotRememberedInputs.kt
+package dev.nezzontli.gotvcs.checkout
+
+import com.intellij.dvcs.DvcsRememberedInputs
+import com.intellij.openapi.application.ApplicationManager
+import com.intellij.openapi.components.PersistentStateComponent
+import com.intellij.openapi.components.RoamingType
+import com.intellij.openapi.components.State
+import com.intellij.openapi.components.Storage
+
+/** Backs the URL history dropdown and remembered parent directory in the Clone dialog's "Got" tab. */
+@State(name = "GotRememberedInputs", storages = [Storage("vcs-inputs.xml", roamingType = RoamingType.DISABLED)])
+class GotRememberedInputs : DvcsRememberedInputs(), PersistentStateComponent<DvcsRememberedInputs.State> {
+ companion object {
+ @JvmStatic
+ fun getInstance(): GotRememberedInputs =
+ ApplicationManager.getApplication().getService(GotRememberedInputs::class.java)
+ }
+}
blob - a6dd0a63121ba168cd3275d6a5e99e4a6de1f598
blob + c6e9e42f498a5ecb8ced21c080dfcb7426723865
--- src/main/resources/META-INF/plugin.xml
+++ src/main/resources/META-INF/plugin.xml
<pushSupport implementation="dev.nezzontli.gotvcs.push.GotPushSupport"/>
<logProvider implementation="dev.nezzontli.gotvcs.log.GotVcsLogProvider"/>
<postStartupActivity implementation="dev.nezzontli.gotvcs.log.GotVcsRootWatcher"/>
+ <checkoutProvider implementation="dev.nezzontli.gotvcs.checkout.GotCheckoutProvider"/>
+ <applicationService serviceImplementation="dev.nezzontli.gotvcs.checkout.GotRememberedInputs"/>
</extensions>
<actions>