Commit Diff


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
@@ -316,4 +316,19 @@ class GotCommandLineWrapper {
         }
         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
@@ -0,0 +1,19 @@
+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
@@ -0,0 +1,209 @@
+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
@@ -0,0 +1,18 @@
+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
@@ -41,6 +41,8 @@
         <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>