├── settings.gradle.kts ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── src └── main │ ├── kotlin │ └── org │ │ └── wavescale │ │ └── sourcesync │ │ ├── configurations │ │ ├── SyncConfigurationType.kt │ │ ├── AuthenticationType.kt │ │ ├── SyncConfigurations.kt │ │ ├── SyncConfigurationWithAuthentication.kt │ │ └── BaseSyncConfiguration.kt │ │ ├── action │ │ ├── ActionSourceSyncMenu.kt │ │ ├── ActionLocalFileToRemote.kt │ │ ├── ActionSelectedFilesToRemote.kt │ │ ├── OldUIConnectionConfigurationSelector.kt │ │ ├── ActionChangedFilesToRemote.kt │ │ └── NewUIConnectionConfigurationSelector.kt │ │ ├── services │ │ ├── StatsService.kt │ │ ├── SyncStatusService.kt │ │ ├── SyncRemoteConfigurationsService.kt │ │ └── SyncRemoteConfigurationsServiceImpl.kt │ │ ├── ui │ │ ├── tree │ │ │ ├── SyncConnectionsTree.kt │ │ │ ├── SyncConfigurationTreeRenderer.kt │ │ │ └── SyncConnectionsTreeModel.kt │ │ ├── AddSyncRemoteConfigurationPopUp.kt │ │ ├── ConnectionConfigurationComponent.kt │ │ └── ConnectionConfigurationDialog.kt │ │ ├── SourcesyncBundle.kt │ │ ├── synchronizer │ │ ├── Synchronizer.kt │ │ ├── PathExtensions.kt │ │ ├── ChannelSftpExtensions.kt │ │ ├── SFTPFileSynchronizer.kt │ │ └── SCPFileSynchronizer.kt │ │ ├── activities │ │ └── RemoteConfigMigrationActivity.kt │ │ ├── SourceSyncIcons.kt │ │ └── notifications │ │ └── Notifier.kt │ ├── java │ └── org │ │ └── wavescale │ │ └── sourcesync │ │ ├── api │ │ ├── PasswordlessSSH.java │ │ ├── ConnectionConstants.java │ │ ├── Utils.java │ │ └── ConnectionConfiguration.java │ │ ├── config │ │ ├── FTPConfiguration.java │ │ ├── FTPSConfiguration.java │ │ ├── SCPConfiguration.java │ │ └── SFTPConfiguration.java │ │ └── factory │ │ ├── ConnectionConfig.java │ │ └── ConfigConnectionFactory.java │ └── resources │ ├── expui │ ├── sourcesync.svg │ └── sourcesync_dark.svg │ ├── sourcesync.svg │ ├── sourcesync_dark.svg │ ├── META-INF │ ├── pluginIcon.svg │ ├── pluginIcon_dark.svg │ └── plugin.xml │ └── messages │ └── SourcesyncBundle.properties ├── .gitignore ├── .github ├── dependabot.yml └── workflows │ ├── release.yml │ └── build.yml ├── LICENSE ├── gradle.properties ├── gradlew.bat ├── CHANGELOG.md ├── README.md └── gradlew /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "sourcesync" 2 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fioan89/sourcesync/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /src/main/kotlin/org/wavescale/sourcesync/configurations/SyncConfigurationType.kt: -------------------------------------------------------------------------------- 1 | package org.wavescale.sourcesync.configurations 2 | 3 | enum class SyncConfigurationType(val prettyName: String) { 4 | SFTP("SSH"), SCP("SCP") 5 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Intellij IDEA 2 | *.iml 3 | *.eml 4 | *.ipr 5 | *.iws 6 | .idea 7 | *java.orig 8 | **/out/* 9 | 10 | ## Eclipse 11 | *.classpath 12 | *.project 13 | 14 | ## Gradle 15 | .gradle 16 | build 17 | 18 | ## Qodana 19 | .qodana -------------------------------------------------------------------------------- /src/main/kotlin/org/wavescale/sourcesync/configurations/AuthenticationType.kt: -------------------------------------------------------------------------------- 1 | package org.wavescale.sourcesync.configurations 2 | 3 | enum class AuthenticationType(val prettyName: String) { 4 | PASSWORD("Password"), KEY_PAIR("Key pair") 5 | } -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /src/main/kotlin/org/wavescale/sourcesync/configurations/SyncConfigurations.kt: -------------------------------------------------------------------------------- 1 | package org.wavescale.sourcesync.configurations 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class SyncConfigurations(val connections: Set = emptySet(), val mainConnection: String? = null) -------------------------------------------------------------------------------- /src/main/kotlin/org/wavescale/sourcesync/action/ActionSourceSyncMenu.kt: -------------------------------------------------------------------------------- 1 | package org.wavescale.sourcesync.action 2 | 3 | import com.intellij.openapi.actionSystem.AnAction 4 | import com.intellij.openapi.actionSystem.AnActionEvent 5 | import org.wavescale.sourcesync.ui.ConnectionConfigurationDialog 6 | 7 | class ActionSourceSyncMenu : AnAction() { 8 | override fun actionPerformed(e: AnActionEvent) { 9 | ConnectionConfigurationDialog(e.project!!).show() 10 | } 11 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/wavescale/sourcesync/services/StatsService.kt: -------------------------------------------------------------------------------- 1 | package org.wavescale.sourcesync.services 2 | 3 | import com.intellij.openapi.components.Service 4 | 5 | @Service(Service.Level.APP) 6 | class StatsService { 7 | private var successfulUploads = 0 8 | fun registerSuccessfulUpload() { 9 | successfulUploads++ 10 | } 11 | 12 | fun eligibleForDonations(): Boolean = when (successfulUploads) { 13 | 10, 30, 60, 100 -> true 14 | else -> successfulUploads % 100 == 0 15 | } 16 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/wavescale/sourcesync/ui/tree/SyncConnectionsTree.kt: -------------------------------------------------------------------------------- 1 | package org.wavescale.sourcesync.ui.tree 2 | 3 | import com.intellij.ui.treeStructure.Tree 4 | import com.intellij.util.ui.tree.TreeUtil 5 | import javax.swing.tree.TreeModel 6 | import javax.swing.tree.TreeNode 7 | 8 | class SyncConnectionsTree(model: TreeModel) : Tree(model) { 9 | 10 | fun selectNode(node: TreeNode) { 11 | TreeUtil.selectNode(this, node) 12 | } 13 | 14 | fun expandAll() { 15 | TreeUtil.expandAll(this) 16 | } 17 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/wavescale/sourcesync/SourcesyncBundle.kt: -------------------------------------------------------------------------------- 1 | package org.wavescale.sourcesync 2 | 3 | import com.intellij.DynamicBundle 4 | import org.jetbrains.annotations.NonNls 5 | import org.jetbrains.annotations.PropertyKey 6 | 7 | @NonNls 8 | private const val BUNDLE = "messages.SourcesyncBundle" 9 | 10 | object SourcesyncBundle : DynamicBundle(BUNDLE) { 11 | 12 | @Suppress("SpreadOperator") 13 | @JvmStatic 14 | fun message(@PropertyKey(resourceBundle = BUNDLE) key: String, vararg params: Any) = 15 | getMessage(key, *params) 16 | } -------------------------------------------------------------------------------- /src/main/java/org/wavescale/sourcesync/api/PasswordlessSSH.java: -------------------------------------------------------------------------------- 1 | package org.wavescale.sourcesync.api; 2 | 3 | /** 4 | * Created by fauri on 03/05/16. 5 | */ 6 | public interface PasswordlessSSH { 7 | boolean isPasswordlessSSHSelected(); 8 | 9 | void setPasswordlessSSHSelected(boolean shouldUseCertificate); 10 | 11 | boolean isPasswordlessWithPassphrase(); 12 | 13 | void setPasswordlessWithPassphrase(boolean shouldUseCertificateWithPassphrase); 14 | 15 | String getCertificatePath(); 16 | 17 | void setCertificatePath(String certificatePath); 18 | } 19 | -------------------------------------------------------------------------------- /src/main/resources/expui/sourcesync.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | -------------------------------------------------------------------------------- /src/main/resources/expui/sourcesync_dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Dependabot configuration: 2 | # https://docs.github.com/en/free-pro-team@latest/github/administering-a-repository/configuration-options-for-dependency-updates 3 | 4 | version: 2 5 | updates: 6 | # Maintain dependencies for Gradle dependencies 7 | - package-ecosystem: "gradle" 8 | directory: "/" 9 | target-branch: "master" 10 | schedule: 11 | interval: "weekly" 12 | # Maintain dependencies for GitHub Actions 13 | - package-ecosystem: "github-actions" 14 | directory: "/" 15 | target-branch: "master" 16 | schedule: 17 | interval: "daily" -------------------------------------------------------------------------------- /src/main/kotlin/org/wavescale/sourcesync/synchronizer/Synchronizer.kt: -------------------------------------------------------------------------------- 1 | package org.wavescale.sourcesync.synchronizer 2 | 3 | import com.intellij.openapi.progress.ProgressIndicator 4 | import java.nio.file.Path 5 | 6 | sealed interface Synchronizer { 7 | fun connect(): Boolean 8 | fun disconnect() 9 | fun syncFiles(src: Collection>, indicator: ProgressIndicator) { 10 | src.forEach { (src, dst) -> 11 | syncFile(src, dst, indicator) 12 | } 13 | } 14 | 15 | fun syncFile(src: String, remoteDest: Path, indicator: ProgressIndicator) 16 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/wavescale/sourcesync/activities/RemoteConfigMigrationActivity.kt: -------------------------------------------------------------------------------- 1 | package org.wavescale.sourcesync.activities 2 | 3 | import com.intellij.openapi.project.Project 4 | import com.intellij.openapi.startup.ProjectActivity 5 | import org.wavescale.sourcesync.factory.ConfigConnectionFactory 6 | import org.wavescale.sourcesync.factory.ConnectionConfig 7 | 8 | class RemoteConfigMigrationActivity : ProjectActivity { 9 | override suspend fun execute(project: Project) { 10 | ConfigConnectionFactory.getInstance().migrate() 11 | ConnectionConfig.getInstance().migrate() 12 | } 13 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/wavescale/sourcesync/services/SyncStatusService.kt: -------------------------------------------------------------------------------- 1 | package org.wavescale.sourcesync.services 2 | 3 | import com.intellij.ide.ActivityTracker 4 | import com.intellij.openapi.components.Service 5 | import java.util.Collections 6 | 7 | 8 | @Service(Service.Level.APP) 9 | class SyncStatusService { 10 | private val syncJobs = Collections.synchronizedSet(mutableSetOf()) 11 | 12 | fun addRunningSync(connectionName: String) { 13 | syncJobs.add(connectionName) 14 | ActivityTracker.getInstance().inc() 15 | } 16 | 17 | fun removeRunningSync(connectionName: String) { 18 | syncJobs.remove(connectionName) 19 | ActivityTracker.getInstance().inc() 20 | } 21 | 22 | fun isAnySyncJobRunning() = syncJobs.isNotEmpty() 23 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/wavescale/sourcesync/SourceSyncIcons.kt: -------------------------------------------------------------------------------- 1 | package org.wavescale.sourcesync 2 | 3 | import com.intellij.openapi.util.IconLoader 4 | import com.intellij.ui.IconManager 5 | import com.intellij.util.ui.JBUI 6 | 7 | class SourceSyncIcons { 8 | companion object { 9 | val SOURCESYNC = IconLoader.getIcon("sourcesync.svg", javaClass) 10 | val SOURCESYNC_RUNNING = IconManager.getInstance().withIconBadge(SOURCESYNC, JBUI.CurrentTheme.IconBadge.SUCCESS) 11 | } 12 | 13 | class ExpUI { 14 | companion object { 15 | val SOURCESYNC = IconLoader.getIcon("expui/sourcesync.svg", javaClass) 16 | val SOURCESYNC_RUNNING = IconManager.getInstance().withIconBadge(SOURCESYNC, JBUI.CurrentTheme.IconBadge.SUCCESS) 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /src/main/resources/sourcesync.svg: -------------------------------------------------------------------------------- 1 | 2 | 8 | 12 | 16 | 17 | -------------------------------------------------------------------------------- /src/main/resources/sourcesync_dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 8 | 12 | 16 | 17 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/pluginIcon.svg: -------------------------------------------------------------------------------- 1 | 2 | 8 | 12 | 16 | 17 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/pluginIcon_dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 8 | 12 | 16 | 17 | -------------------------------------------------------------------------------- /src/main/kotlin/org/wavescale/sourcesync/services/SyncRemoteConfigurationsService.kt: -------------------------------------------------------------------------------- 1 | package org.wavescale.sourcesync.services 2 | 3 | import org.wavescale.sourcesync.configurations.BaseSyncConfiguration 4 | import org.wavescale.sourcesync.configurations.SyncConfigurationType 5 | 6 | interface SyncRemoteConfigurationsService { 7 | fun hasNoConfiguration(): Boolean 8 | fun add(connection: BaseSyncConfiguration) 9 | fun addAll(connections: Set) 10 | fun findAllOfType(type: SyncConfigurationType): Set 11 | fun clear() 12 | 13 | fun mainConnection(): BaseSyncConfiguration? 14 | fun allConnectionNames(): Set 15 | fun findFirstWithName(name: String): BaseSyncConfiguration? 16 | fun mainConnectionName(): String? 17 | fun hasNoMainConnectionConfigured(): Boolean 18 | fun setMainConnection(connectionName: String) 19 | fun resetMainConnection() 20 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/wavescale/sourcesync/synchronizer/PathExtensions.kt: -------------------------------------------------------------------------------- 1 | package org.wavescale.sourcesync.synchronizer 2 | 3 | import java.nio.file.Path 4 | import kotlin.io.path.pathString 5 | 6 | private const val UNIX_SEPARATOR = "/" 7 | private const val WIN_SEPARATOR = """\""" 8 | 9 | /** 10 | * Returns the string representation of this path. 11 | * 12 | * The returned path string uses [other] separator. [other] should contain only 13 | * one type of separator. If there are two separators or none then the path is returned as is. 14 | */ 15 | fun Path.pathStringLike(other: String): String { 16 | return if (other.isUnixPath()) { 17 | this.pathString.replace(WIN_SEPARATOR, UNIX_SEPARATOR) 18 | } else if (other.isWindowsPath()) { 19 | this.pathString.replace(UNIX_SEPARATOR, WIN_SEPARATOR) 20 | } else this.pathString 21 | } 22 | 23 | private fun String.isUnixPath() = this.contains(UNIX_SEPARATOR) && !this.contains(WIN_SEPARATOR) 24 | private fun String.isWindowsPath() = this.contains(WIN_SEPARATOR) && !this.contains(UNIX_SEPARATOR) -------------------------------------------------------------------------------- /src/main/java/org/wavescale/sourcesync/api/ConnectionConstants.java: -------------------------------------------------------------------------------- 1 | package org.wavescale.sourcesync.api; 2 | 3 | /** 4 | * **************************************************************************** 5 | * Copyright (c) 2014-2107 Faur Ioan-Aurel. * 6 | * All rights reserved. This program and the accompanying materials * 7 | * are made available under the terms of the MIT License * 8 | * which accompanies this distribution, and is available at * 9 | * http://opensource.org/licenses/MIT * 10 | * * 11 | * For any issues or questions send an email at: fioan89@gmail.com * 12 | * ***************************************************************************** 13 | */ 14 | public class ConnectionConstants { 15 | // ftp connection 16 | public static final String CONN_TYPE_FTP = "FTP"; 17 | // ftp secured connection 18 | public static final String CONN_TYPE_FTPS = "FTPS"; 19 | // ssh ftp connection 20 | public static final String CONN_TYPE_SFTP = "SFTP"; 21 | // ssh scp connection 22 | public static final String CONN_TYPE_SCP = "SCP"; 23 | } 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | [OSI Approved License] 4 | 5 | The MIT License (MIT) 6 | 7 | Copyright (c) 2013 Faur Ioan-Aurel 8 | 9 | Permission is hereby granted, free of charge, to any person obtaining a copy 10 | of this software and associated documentation files (the "Software"), to deal 11 | in the Software without restriction, including without limitation the rights 12 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | copies of the Software, and to permit persons to whom the Software is 14 | furnished to do so, subject to the following conditions: 15 | 16 | The above copyright notice and this permission notice shall be included in 17 | all copies or substantial portions of the Software. 18 | 19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /src/main/kotlin/org/wavescale/sourcesync/ui/tree/SyncConfigurationTreeRenderer.kt: -------------------------------------------------------------------------------- 1 | package org.wavescale.sourcesync.ui.tree 2 | 3 | import com.intellij.ui.ColoredTreeCellRenderer 4 | import com.intellij.ui.SimpleTextAttributes 5 | import org.wavescale.sourcesync.ui.ConnectionConfigurationComponent 6 | import javax.swing.JTree 7 | import javax.swing.tree.DefaultMutableTreeNode 8 | 9 | class SyncConfigurationTreeRenderer : ColoredTreeCellRenderer() { 10 | override fun customizeCellRenderer(tree: JTree, value: Any, selected: Boolean, expanded: Boolean, leaf: Boolean, row: Int, hasFocus: Boolean) { 11 | if (value !is DefaultMutableTreeNode) { 12 | return 13 | } 14 | 15 | val nameToRender = getNameToRender(value.userObject) 16 | if (leaf) { 17 | append(nameToRender, SimpleTextAttributes.REGULAR_ATTRIBUTES) 18 | } else { 19 | // connection types 20 | append(nameToRender, SimpleTextAttributes.REGULAR_BOLD_ATTRIBUTES) 21 | } 22 | } 23 | 24 | private fun getNameToRender(userObject: Any) = when (userObject) { 25 | is ConnectionConfigurationComponent -> userObject.displayName 26 | else -> userObject as String 27 | } 28 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/wavescale/sourcesync/ui/AddSyncRemoteConfigurationPopUp.kt: -------------------------------------------------------------------------------- 1 | package org.wavescale.sourcesync.ui 2 | 3 | import com.intellij.openapi.ui.popup.PopupStep 4 | import com.intellij.openapi.ui.popup.util.BaseListPopupStep 5 | import com.intellij.ui.popup.PopupFactoryImpl 6 | import org.wavescale.sourcesync.SourcesyncBundle 7 | import org.wavescale.sourcesync.configurations.SyncConfigurationType 8 | 9 | class AddSyncRemoteConfigurationPopUp(configurationTypes: List, private val onChosenCallback: (SyncConfigurationType) -> Unit) : BaseListPopupStep( 10 | SourcesyncBundle.message("add.new.sync.configuration.action.name"), 11 | configurationTypes 12 | ) { 13 | 14 | override fun getTextFor(value: SyncConfigurationType) = value.prettyName 15 | 16 | override fun onChosen(selectedValue: SyncConfigurationType?, finalChoice: Boolean): PopupStep<*>? { 17 | if (selectedValue != null) { 18 | onChosenCallback(selectedValue) 19 | } 20 | return FINAL_CHOICE 21 | } 22 | 23 | companion object { 24 | fun create(configurationTypes: List, onChosenCallback: (SyncConfigurationType) -> Unit) = 25 | PopupFactoryImpl.getInstance().createListPopup(AddSyncRemoteConfigurationPopUp(configurationTypes, onChosenCallback)) 26 | } 27 | } -------------------------------------------------------------------------------- /src/main/java/org/wavescale/sourcesync/config/FTPConfiguration.java: -------------------------------------------------------------------------------- 1 | package org.wavescale.sourcesync.config; 2 | 3 | import org.wavescale.sourcesync.api.ConnectionConfiguration; 4 | import org.wavescale.sourcesync.api.ConnectionConstants; 5 | 6 | /** 7 | * **************************************************************************** 8 | * Copyright (c) 2014-2107 Faur Ioan-Aurel. * 9 | * All rights reserved. This program and the accompanying materials * 10 | * are made available under the terms of the MIT License * 11 | * which accompanies this distribution, and is available at * 12 | * http://opensource.org/licenses/MIT * 13 | * * 14 | * For any issues or questions send an email at: fioan89@gmail.com * 15 | * ***************************************************************************** 16 | */ 17 | public class FTPConfiguration extends ConnectionConfiguration { 18 | 19 | public FTPConfiguration(String connectionName) { 20 | super(connectionName); 21 | this.connectionType = ConnectionConstants.CONN_TYPE_FTP; 22 | this.port = 21; 23 | this.workspaceBasePath = ""; 24 | this.host = "ftp://"; 25 | this.userName = ""; 26 | this.userPassword = ""; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/main/kotlin/org/wavescale/sourcesync/ui/tree/SyncConnectionsTreeModel.kt: -------------------------------------------------------------------------------- 1 | package org.wavescale.sourcesync.ui.tree 2 | 3 | import org.wavescale.sourcesync.configurations.SyncConfigurationType 4 | import org.wavescale.sourcesync.ui.ConnectionConfigurationComponent 5 | import java.util.* 6 | import javax.swing.tree.DefaultMutableTreeNode 7 | import javax.swing.tree.DefaultTreeModel 8 | 9 | class SyncConnectionsTreeModel(private val rootNode: DefaultMutableTreeNode) : DefaultTreeModel(rootNode) { 10 | fun getOrCreateNodeFor(syncType: SyncConfigurationType): DefaultMutableTreeNode { 11 | var node = rootNode.children().asSequence().find { (it as DefaultMutableTreeNode).userObject.equals(syncType.prettyName) } 12 | if (node == null) { 13 | node = DefaultMutableTreeNode(syncType.prettyName) 14 | 15 | rootNode.add(node) 16 | reload() 17 | } 18 | return node as DefaultMutableTreeNode 19 | } 20 | 21 | fun getAllComponents(): List { 22 | val stack = Stack() 23 | stack.push(rootNode) 24 | 25 | val components = mutableListOf() 26 | while (!stack.isEmpty()) { 27 | val node = stack.pop() 28 | if (node.userObject is ConnectionConfigurationComponent) { 29 | components.add(node.userObject as ConnectionConfigurationComponent) 30 | } else { 31 | node.children().asIterator().forEach { 32 | stack.push(it as DefaultMutableTreeNode) 33 | } 34 | } 35 | } 36 | 37 | return components 38 | } 39 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | pluginGroup=org.wavescale.sourcesync 2 | pluginName=sourcesync 3 | # SemVer format -> https://semver.org 4 | pluginVersion=3.0.3 5 | pluginRepositoryUrl=https://github.com/fioan89/sourcesync 6 | # See https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html 7 | # for insight into build numbers and IntelliJ Platform versions. 8 | pluginSinceBuild=232 9 | pluginUntilBuild= 10 | # IntelliJ Platform Properties -> https://github.com/JetBrains/gradle-intellij-plugin#intellij-platform-properties 11 | platformType=IC 12 | platformVersion=2023.3.1 13 | # Plugin Dependencies -> https://plugins.jetbrains.com/docs/intellij/plugin-dependencies.html 14 | # Example: platformPlugins = com.intellij.java, com.jetbrains.php:203.4449.22 15 | platformPlugins= 16 | # Java language level used to compile sources and to generate the files for - Java 11 is required since 2020.3 17 | javaVersion=17 18 | # Gradle Releases -> https://github.com/gradle/gradle/releases 19 | gradleVersion=8.1.1 20 | # Opt-out flag for bundling Kotlin standard library. 21 | # See https://plugins.jetbrains.com/docs/intellij/kotlin.html#kotlin-standard-library for details. 22 | # suppress inspection "UnusedProperty" 23 | kotlin.stdlib.default.dependency=false 24 | org.gradle.jvmargs=-Xmx2048m 25 | # Enable Gradle Configuration Cache -> https://docs.gradle.org/current/userguide/configuration_cache.html 26 | org.gradle.configuration-cache=true 27 | # Enable Gradle Build Cache -> https://docs.gradle.org/current/userguide/build_cache.html 28 | org.gradle.caching=true 29 | # Enable Gradle Kotlin DSL Lazy Property Assignment -> https://docs.gradle.org/current/userguide/kotlin_dsl.html#kotdsl:assignment 30 | systemProp.org.gradle.unsafe.kotlin.assignment=true 31 | # Temporary workaround for Kotlin Compiler OutOfMemoryError -> https://jb.gg/intellij-platform-kotlin-oom 32 | kotlin.incremental.useClasspathSnapshot=false -------------------------------------------------------------------------------- /src/main/java/org/wavescale/sourcesync/config/FTPSConfiguration.java: -------------------------------------------------------------------------------- 1 | package org.wavescale.sourcesync.config; 2 | 3 | import org.wavescale.sourcesync.api.ConnectionConfiguration; 4 | import org.wavescale.sourcesync.api.ConnectionConstants; 5 | 6 | /** 7 | * **************************************************************************** 8 | * Copyright (c) 2014-2107 Faur Ioan-Aurel. * 9 | * All rights reserved. This program and the accompanying materials * 10 | * are made available under the terms of the MIT License * 11 | * which accompanies this distribution, and is available at * 12 | * http://opensource.org/licenses/MIT * 13 | * * 14 | * For any issues or questions send an email at: fioan89@gmail.com * 15 | * ***************************************************************************** 16 | */ 17 | public class FTPSConfiguration extends ConnectionConfiguration { 18 | private boolean requireImplicitTLS; 19 | private boolean requireExplicitTLS; 20 | 21 | public FTPSConfiguration(String connectionName) { 22 | super(connectionName); 23 | this.connectionType = ConnectionConstants.CONN_TYPE_FTPS; 24 | this.port = 21; 25 | this.workspaceBasePath = ""; 26 | this.host = "ftp://"; 27 | this.userName = ""; 28 | this.userPassword = ""; 29 | this.requireImplicitTLS = false; 30 | this.requireExplicitTLS = true; 31 | } 32 | 33 | public boolean isRequireImplicitTLS() { 34 | return requireImplicitTLS; 35 | } 36 | 37 | public void setRequireImplicitTLS(boolean requireImplicitTLS) { 38 | this.requireImplicitTLS = requireImplicitTLS; 39 | } 40 | 41 | public boolean isRequireExplicitTLS() { 42 | return requireExplicitTLS; 43 | } 44 | 45 | public void setRequireExplicitTLS(boolean requireExplicitTLS) { 46 | this.requireExplicitTLS = requireExplicitTLS; 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /src/main/kotlin/org/wavescale/sourcesync/configurations/SyncConfigurationWithAuthentication.kt: -------------------------------------------------------------------------------- 1 | package org.wavescale.sourcesync.configurations 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | @SerialName("ssh_configuration") 8 | class SshSyncConfiguration : BaseSyncConfiguration() { 9 | override val protocol = SyncConfigurationType.SFTP 10 | 11 | override fun clone() = SshSyncConfiguration().apply { 12 | name = this@SshSyncConfiguration.name 13 | hostname = this@SshSyncConfiguration.hostname 14 | port = this@SshSyncConfiguration.port 15 | username = this@SshSyncConfiguration.username 16 | authenticationType = this@SshSyncConfiguration.authenticationType 17 | password = this@SshSyncConfiguration.password 18 | workspaceBasePath = this@SshSyncConfiguration.workspaceBasePath 19 | excludedFiles = this@SshSyncConfiguration.excludedFiles 20 | preserveTimestamps = this@SshSyncConfiguration.preserveTimestamps 21 | privateKey = this@SshSyncConfiguration.privateKey 22 | passphrase = this@SshSyncConfiguration.passphrase 23 | } 24 | } 25 | 26 | @Serializable 27 | @SerialName("scp_configuration") 28 | class ScpSyncConfiguration : BaseSyncConfiguration() { 29 | override val protocol = SyncConfigurationType.SCP 30 | 31 | override fun clone() = ScpSyncConfiguration().apply { 32 | name = this@ScpSyncConfiguration.name 33 | hostname = this@ScpSyncConfiguration.hostname 34 | port = this@ScpSyncConfiguration.port 35 | username = this@ScpSyncConfiguration.username 36 | authenticationType = this@ScpSyncConfiguration.authenticationType 37 | password = this@ScpSyncConfiguration.password 38 | workspaceBasePath = this@ScpSyncConfiguration.workspaceBasePath 39 | excludedFiles = this@ScpSyncConfiguration.excludedFiles 40 | preserveTimestamps = this@ScpSyncConfiguration.preserveTimestamps 41 | privateKey = this@ScpSyncConfiguration.privateKey 42 | passphrase = this@ScpSyncConfiguration.passphrase 43 | } 44 | } -------------------------------------------------------------------------------- /src/main/java/org/wavescale/sourcesync/config/SCPConfiguration.java: -------------------------------------------------------------------------------- 1 | package org.wavescale.sourcesync.config; 2 | 3 | import org.wavescale.sourcesync.api.ConnectionConfiguration; 4 | import org.wavescale.sourcesync.api.ConnectionConstants; 5 | import org.wavescale.sourcesync.api.PasswordlessSSH; 6 | 7 | /** 8 | * **************************************************************************** 9 | * Copyright (c) 2014-2107 Faur Ioan-Aurel. * 10 | * All rights reserved. This program and the accompanying materials * 11 | * are made available under the terms of the MIT License * 12 | * which accompanies this distribution, and is available at * 13 | * http://opensource.org/licenses/MIT * 14 | * * 15 | * For any issues or questions send an email at: fioan89@gmail.com * 16 | * ***************************************************************************** 17 | */ 18 | public class SCPConfiguration extends ConnectionConfiguration implements PasswordlessSSH { 19 | private boolean shouldUseCertificate; 20 | private boolean shouldUseCertificateWithPassphrase; 21 | private String certificatePath; 22 | 23 | public SCPConfiguration(String connectionName) { 24 | super(connectionName); 25 | this.connectionType = ConnectionConstants.CONN_TYPE_SCP; 26 | this.port = 22; 27 | this.workspaceBasePath = ""; 28 | this.host = "scp://"; 29 | this.userName = ""; 30 | this.userPassword = ""; 31 | } 32 | 33 | @Override 34 | public boolean isPasswordlessSSHSelected() { 35 | return shouldUseCertificate; 36 | } 37 | 38 | @Override 39 | public void setPasswordlessSSHSelected(boolean shouldUseCertificate) { 40 | this.shouldUseCertificate = shouldUseCertificate; 41 | } 42 | 43 | @Override 44 | public boolean isPasswordlessWithPassphrase() { 45 | return shouldUseCertificateWithPassphrase; 46 | } 47 | 48 | @Override 49 | public void setPasswordlessWithPassphrase(boolean shouldUseCertificateWithPassphrase) { 50 | this.shouldUseCertificateWithPassphrase = shouldUseCertificateWithPassphrase; 51 | } 52 | 53 | @Override 54 | public String getCertificatePath() { 55 | return certificatePath; 56 | } 57 | 58 | @Override 59 | public void setCertificatePath(String certificatePath) { 60 | this.certificatePath = certificatePath; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/main/kotlin/org/wavescale/sourcesync/synchronizer/ChannelSftpExtensions.kt: -------------------------------------------------------------------------------- 1 | package org.wavescale.sourcesync.synchronizer 2 | 3 | import com.jcraft.jsch.ChannelSftp 4 | import com.jcraft.jsch.SftpException 5 | import java.nio.file.Paths 6 | 7 | /** 8 | * Checks if a remote and absolute path exists on remote 9 | */ 10 | fun ChannelSftp.absoluteDirExists(dirPath: String): Boolean { 11 | return try { 12 | val attrs = this.stat(dirPath) 13 | attrs != null && attrs.isDir 14 | } catch (e: SftpException) { 15 | false 16 | } 17 | } 18 | 19 | /** 20 | * Checks if a local path exists on the remote. The check is done against the working directory of the sftp session. 21 | */ 22 | fun ChannelSftp.localDirExistsOnRemote(dirPath: String): Boolean { 23 | val pwd = this.pwd() 24 | Paths.get(dirPath).forEach { 25 | try { 26 | val attrs = this.stat(it.toString()) 27 | if (attrs != null && attrs.isDir) { 28 | this.cd(it.toString()) 29 | } else { 30 | return@forEach 31 | } 32 | 33 | } catch (e: SftpException) { 34 | this.cd(pwd) 35 | return false 36 | } 37 | } 38 | 39 | this.cd(pwd) 40 | return true 41 | } 42 | 43 | /** 44 | * Creates a local path directory on the remote, with its parent directories as needed. The check is done against the working directory of the sftp session. 45 | */ 46 | fun ChannelSftp.mkLocalDirsOnRemote(dirPath: String): Boolean { 47 | if (this.localDirExistsOnRemote(dirPath)) { 48 | return true 49 | } 50 | 51 | val pwd = this.pwd() 52 | Paths.get(dirPath).forEach { 53 | if (this.localDirExistsOnRemote(it.toString())) { 54 | this.cd(it.toString()) 55 | } else { 56 | try { 57 | this.mkdir(it.toString()) 58 | this.cd(it.toString()) 59 | } catch (e: SftpException) { 60 | this.cd(pwd) 61 | return false 62 | } 63 | } 64 | } 65 | this.cd(pwd) 66 | return true 67 | } 68 | 69 | /** 70 | * Changes working directory on remote to a local path value. The check is done against the working directory of the sftp session. 71 | */ 72 | fun ChannelSftp.cdLocalDirsOnRemote(dirPath: String): Boolean { 73 | if (!this.localDirExistsOnRemote(dirPath)) { 74 | return false 75 | } 76 | 77 | val pwd = this.pwd() 78 | Paths.get(dirPath).forEach { 79 | if (this.localDirExistsOnRemote(it.toString())) { 80 | this.cd(it.toString()) 81 | } else { 82 | this.cd(pwd) 83 | return false 84 | } 85 | } 86 | return true 87 | } -------------------------------------------------------------------------------- /src/main/java/org/wavescale/sourcesync/config/SFTPConfiguration.java: -------------------------------------------------------------------------------- 1 | package org.wavescale.sourcesync.config; 2 | 3 | import org.wavescale.sourcesync.api.ConnectionConfiguration; 4 | import org.wavescale.sourcesync.api.ConnectionConstants; 5 | import org.wavescale.sourcesync.api.PasswordlessSSH; 6 | 7 | import java.io.File; 8 | 9 | /** 10 | * **************************************************************************** 11 | * Copyright (c) 2014-2107 Faur Ioan-Aurel. * 12 | * All rights reserved. This program and the accompanying materials * 13 | * are made available under the terms of the MIT License * 14 | * which accompanies this distribution, and is available at * 15 | * http://opensource.org/licenses/MIT * 16 | * * 17 | * For any issues or questions send an email at: fioan89@gmail.com * 18 | * ***************************************************************************** 19 | */ 20 | public class SFTPConfiguration extends ConnectionConfiguration implements PasswordlessSSH { 21 | private boolean shouldUseCertificate; 22 | private boolean shouldUseCertificateWithPassphrase; 23 | private String certificatePath; 24 | 25 | public SFTPConfiguration(String connectionName) { 26 | super(connectionName); 27 | this.connectionType = ConnectionConstants.CONN_TYPE_SFTP; 28 | this.port = 22; 29 | this.workspaceBasePath = ""; 30 | this.host = "sftp://"; 31 | this.userName = ""; 32 | this.userPassword = ""; 33 | this.shouldUseCertificate = false; 34 | this.certificatePath = new File(System.getProperty("user.home") + File.separator + ".ssh" + File.separator + "id_rsa").getAbsolutePath(); 35 | 36 | } 37 | 38 | @Override 39 | public boolean isPasswordlessSSHSelected() { 40 | return shouldUseCertificate; 41 | } 42 | 43 | @Override 44 | public void setPasswordlessSSHSelected(boolean shouldUseCertificate) { 45 | this.shouldUseCertificate = shouldUseCertificate; 46 | } 47 | 48 | @Override 49 | public boolean isPasswordlessWithPassphrase() { 50 | return shouldUseCertificateWithPassphrase; 51 | } 52 | 53 | @Override 54 | public void setPasswordlessWithPassphrase(boolean shouldUseCertificateWithPassphrase) { 55 | this.shouldUseCertificateWithPassphrase = shouldUseCertificateWithPassphrase; 56 | } 57 | 58 | @Override 59 | public String getCertificatePath() { 60 | return certificatePath; 61 | } 62 | 63 | @Override 64 | public void setCertificatePath(String certificatePath) { 65 | this.certificatePath = certificatePath; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /src/main/resources/messages/SourcesyncBundle.properties: -------------------------------------------------------------------------------- 1 | connectionConfigurationDialogTitle=Sourcesync Remote Configurations 2 | add.new.sync.configuration.action.name=Add New Configuration 3 | status.text.no.sync.configurations.added=No sync configurations added. 4 | sync.editor.name.label=Name: 5 | sync.editor.host.label=Host: 6 | sync.editor.port.label=Port: 7 | sync.editor.username.label=Username: 8 | sync.editor.authentication.type.label=Authentication type: 9 | sync.editor.password.label=Password: 10 | sync.editor.private.key.label=Private key: 11 | sync.editor.private.key.dialog.title=Select Private Key File 12 | sync.editor.passphrase.label=Passphrase: 13 | sync.editor.workspace.label=Workspace: 14 | sync.editor.workspace.tooltip=Workspace base path where project will be uploaded 15 | sync.editor.skip.extensions.label=Exclude files with extension: 16 | sync.editor.timestamps.label=Preserve timestamp 17 | sourcesyncConfigurations=Sourcesync Configurations 18 | sourcesyncAddConfigurations=Add Sourcesync Configuration\u2026 19 | notification.group.sourcesync.donation=Sourcesync Support&Donations 20 | scp.upload.fail.title=SCP upload failed 21 | scp.upload.fail.channel.connect.error.message=Could not initiate SCP connection to {0} because of an error: {1} 22 | scp.upload.fail.channel.connect.fatal.error.message=Could not initiate SCP connection to {0} because of a fatal error: {1} 23 | scp.upload.fail.preserve.timestamps.error.message=Could not preserve file timestamps because of an error: {0} 24 | scp.upload.fail.preserve.timestamps.fatal.error.message=Could not preserve file timestamps because of a fatal error: {0} 25 | scp.upload.fail.file.mode.error.message=Could not send file modes to {0} because of an error: {1} 26 | scp.upload.fail.file.mode.fatal.error.message=Could not send file modes to {0} because of a fatal error: {1} 27 | scp.upload.fail.file.content.error.message=Could not send file content to {0} because of an error: {1} 28 | scp.upload.fail.file.content.fatal.error.message=Could not send file content to {0} because of a fatal error: {1} 29 | ssh.upload.fail.text=SSH upload failed 30 | no.vcs.changes.to.sync=No changes to sync 31 | no.remote.sync.connection.configured.title=Invalid Remote Sync Connection 32 | no.remote.sync.connection.configured.message=Please check the target remote connection exists and is properly defined 33 | no.file.selected.to.sync=No file selected to sync 34 | no.files.selected.to.sync=No files selected to sync 35 | # don't remove this 36 | notification.group.sourcesync=Sourcesync Upload 37 | 38 | upgrade.nudge.pro.tip.title=Pro tip! 39 | upgrade.nudge.pro.tip.message=Upgrading to Sourcesync Pro will unlock automatic uploads on file save, folder uploads and many other features. 40 | upgrade.nudge.directory.upload.title=You discovered a Pro feature! 41 | upgrade.nudge.directory.upload.message=Upgrading to Sourcesync Pro will unlock folder uploads and many other features. 42 | buy.me.a.coffee=Buy me a coffee 43 | upgrade.to.pro.version=Upgrade to Sourcesync Pro 44 | 45 | 46 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # GitHub Actions Workflow created for handling the release process based on the draft release prepared 2 | # with the Build workflow. Running the publishPlugin task requires the PUBLISH_TOKEN secret provided. 3 | 4 | name: Release 5 | on: 6 | release: 7 | types: [prereleased, released] 8 | 9 | jobs: 10 | 11 | # Prepare and publish the plugin to the Marketplace repository 12 | release: 13 | name: Publish Plugin 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: write 17 | pull-requests: write 18 | steps: 19 | 20 | # Check out current repository 21 | - name: Fetch Sources 22 | uses: actions/checkout@v4 23 | with: 24 | ref: ${{ github.event.release.tag_name }} 25 | 26 | # Setup Java 17 environment for the next steps 27 | - name: Setup Java 28 | uses: actions/setup-java@v4 29 | with: 30 | distribution: zulu 31 | java-version: 17 32 | cache: gradle 33 | 34 | # Set environment variables 35 | - name: Export Properties 36 | id: properties 37 | shell: bash 38 | run: | 39 | CHANGELOG="$(cat << 'EOM' | sed -e 's/^[[:space:]]*$//g' -e '/./,$!d' 40 | ${{ github.event.release.body }} 41 | EOM 42 | )" 43 | 44 | CHANGELOG="${CHANGELOG//'%'/'%25'}" 45 | CHANGELOG="${CHANGELOG//$'\n'/'%0A'}" 46 | CHANGELOG="${CHANGELOG//$'\r'/'%0D'}" 47 | 48 | echo "::set-output name=changelog::$CHANGELOG" 49 | 50 | # Update Unreleased section with the current release note 51 | - name: Patch Changelog 52 | if: ${{ steps.properties.outputs.changelog != '' }} 53 | env: 54 | CHANGELOG: ${{ steps.properties.outputs.changelog }} 55 | run: | 56 | ./gradlew patchChangelog --release-note="$CHANGELOG" 57 | 58 | # Publish the plugin to the Marketplace 59 | - name: Publish Plugin 60 | env: 61 | PUBLISH_TOKEN: ${{ secrets.PUBLISH_TOKEN }} 62 | CERTIFICATE_CHAIN: ${{ secrets.CERTIFICATE_CHAIN }} 63 | PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} 64 | PRIVATE_KEY_PASSWORD: ${{ secrets.PRIVATE_KEY_PASSWORD }} 65 | run: ./gradlew publishPlugin 66 | 67 | # Upload artifact as a release asset 68 | - name: Upload Release Asset 69 | env: 70 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 71 | run: gh release upload ${{ github.event.release.tag_name }} ./build/distributions/* 72 | 73 | # Create pull request 74 | - name: Create Pull Request 75 | if: ${{ steps.properties.outputs.changelog != '' }} 76 | env: 77 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 78 | run: | 79 | VERSION="${{ github.event.release.tag_name }}" 80 | BRANCH="changelog-update-$VERSION" 81 | 82 | git config user.email "action@github.com" 83 | git config user.name "GitHub Action" 84 | 85 | git checkout -b $BRANCH 86 | git commit -am "Changelog update - $VERSION" 87 | git push --set-upstream origin $BRANCH 88 | 89 | gh pr create \ 90 | --title "Changelog update - \`$VERSION\`" \ 91 | --body "Current pull request contains patched \`CHANGELOG.md\` file for the \`$VERSION\` version." \ 92 | --base master \ 93 | --head $BRANCH -------------------------------------------------------------------------------- /src/main/kotlin/org/wavescale/sourcesync/services/SyncRemoteConfigurationsServiceImpl.kt: -------------------------------------------------------------------------------- 1 | package org.wavescale.sourcesync.services 2 | 3 | import com.intellij.openapi.components.* 4 | import com.intellij.openapi.diagnostic.Logger 5 | import com.intellij.openapi.project.Project 6 | import org.wavescale.sourcesync.configurations.BaseSyncConfiguration 7 | import org.wavescale.sourcesync.configurations.SyncConfigurationType 8 | import org.wavescale.sourcesync.configurations.SyncConfigurations 9 | 10 | @Suppress("UnstableApiUsage") 11 | @Service(Service.Level.PROJECT) 12 | @State(name = "SourceSyncRemoteConfigurationsService", storages = [Storage(value = "sourcesync.xml")]) 13 | class SyncRemoteConfigurationsServiceImpl(val project: Project) : SerializablePersistentStateComponent(SyncConfigurations()), SyncRemoteConfigurationsService { 14 | override fun hasNoConfiguration() = state.connections.isEmpty() 15 | 16 | override fun add(connection: BaseSyncConfiguration) { 17 | updateState { oldState -> 18 | logger.info("Added ${connection.protocol.prettyName} remote connection configuration with name ${connection.name}") 19 | SyncConfigurations(oldState.connections union setOf(connection), oldState.mainConnection) 20 | } 21 | } 22 | 23 | override fun addAll(connections: Set) { 24 | updateState { oldState -> 25 | connections.forEach { 26 | logger.info("Added ${it.protocol.prettyName} remote connection configuration with name ${it.name}") 27 | } 28 | SyncConfigurations(oldState.connections union connections, oldState.mainConnection) 29 | } 30 | } 31 | 32 | override fun findAllOfType(type: SyncConfigurationType) = state.connections.filter { type == it.protocol }.toSet() 33 | 34 | override fun clear() { 35 | logger.info("Removed all remote connection configurations") 36 | updateState { oldState -> 37 | SyncConfigurations(emptySet(), oldState.mainConnection) 38 | } 39 | } 40 | 41 | override fun mainConnection(): BaseSyncConfiguration? { 42 | if (mainConnectionName() != null) { 43 | return findFirstWithName(mainConnectionName()!!) 44 | } 45 | 46 | return null 47 | } 48 | 49 | override fun allConnectionNames() = state.connections.map { it.name }.toSet() 50 | override fun findFirstWithName(name: String) = state.connections.firstOrNull { it.name == name } 51 | override fun mainConnectionName() = state.mainConnection 52 | 53 | override fun hasNoMainConnectionConfigured(): Boolean = state.mainConnection.isNullOrEmpty() 54 | 55 | override fun setMainConnection(connectionName: String) { 56 | updateState { oldState -> 57 | logger.info("Marked $connectionName as main remote sync connection for project ${project.name}") 58 | SyncConfigurations(oldState.connections, connectionName) 59 | } 60 | } 61 | 62 | override fun resetMainConnection() { 63 | updateState { oldState -> 64 | logger.info("Removed ${oldState.mainConnection} as main remote sync connection for project ${project.name}") 65 | SyncConfigurations(oldState.connections, null) 66 | } 67 | } 68 | 69 | override fun noStateLoaded() { 70 | super.noStateLoaded() 71 | logger.info("No SourceSync connections were loaded for project ${project.name}") 72 | } 73 | 74 | companion object { 75 | val logger = Logger.getInstance(SyncRemoteConfigurationsServiceImpl::class.java.simpleName) 76 | } 77 | } -------------------------------------------------------------------------------- /src/main/java/org/wavescale/sourcesync/api/Utils.java: -------------------------------------------------------------------------------- 1 | package org.wavescale.sourcesync.api; 2 | 3 | import java.io.File; 4 | import java.io.IOException; 5 | import java.nio.file.Path; 6 | import com.intellij.openapi.components.impl.stores.IProjectStore; 7 | import com.intellij.openapi.vfs.VirtualFile; 8 | 9 | /** 10 | * **************************************************************************** 11 | * Copyright (c) 2014-2107 Faur Ioan-Aurel. * 12 | * All rights reserved. This program and the accompanying materials * 13 | * are made available under the terms of the MIT License * 14 | * which accompanies this distribution, and is available at * 15 | * http://opensource.org/licenses/MIT * 16 | * * 17 | * For any issues or questions send an email at: fioan89@gmail.com * 18 | * ***************************************************************************** 19 | */ 20 | public class Utils 21 | { 22 | 23 | /** 24 | * Checks if a given filename can be uploaded or not. 25 | * 26 | * @param fileName a string representing a file name plus extension. 27 | * @param extensionsToFilter a string that contains file extensions separated by 28 | * by space, comma or ";" character that are not to be uploaded. The 29 | * extension MUST contain the dot character - ex: ".crt .iml .etc" 30 | * @return true if file extension is not on the extensionsToFilter, False otherwise. 31 | */ 32 | public static boolean canBeUploaded(String fileName, String extensionsToFilter) 33 | { 34 | String extension = "."; 35 | 36 | if (fileName != null) 37 | { 38 | int i = fileName.lastIndexOf('.'); 39 | if (i >= 0) 40 | { 41 | extension += fileName.substring(i + 1); 42 | return !extensionsToFilter.contains(extension); 43 | } 44 | return true; 45 | } 46 | return false; 47 | } 48 | 49 | /** 50 | * Returns a path that is now relative to the local project base path. 51 | *
52 | * Example: 53 | *
54 |      * - project base bath is: C:\Users\ifaur\workspace\sourcesync\
55 |      * - selected file/folder: C:\Users\ifaur\workspace\sourcesync\src\main\kotlin\Example.kt
56 |      * - result is: src\main\kotlin
57 |      * 
58 | */ 59 | public static Path relativeToProjectPath(VirtualFile virtualFile, IProjectStore projectStore) 60 | { 61 | return projectStore.getProjectBasePath().getParent().relativize(virtualFile.toNioPath().getParent()); 62 | } 63 | 64 | /** 65 | * Tries to create a file with the given absolute path, even if the parent directories do not exist. 66 | * 67 | * @param path absolute file path name to create 68 | * @return true if the path was created, false otherwise. If false is returned it might 69 | * be that the file already exists 70 | * @throws IOException if an I/O error occurred 71 | */ 72 | public static boolean createFile(String path) throws IOException 73 | { 74 | File fileToCreate = new File(path); 75 | if (fileToCreate.exists()) 76 | { 77 | return false; 78 | } 79 | // the file doesn't exist so try to create it 80 | String dirPath = fileToCreate.getParent(); 81 | // try to create the path 82 | new File(dirPath).mkdirs(); 83 | // try to create the file 84 | return fileToCreate.createNewFile(); 85 | } 86 | 87 | } 88 | -------------------------------------------------------------------------------- /src/main/java/org/wavescale/sourcesync/factory/ConnectionConfig.java: -------------------------------------------------------------------------------- 1 | package org.wavescale.sourcesync.factory; 2 | 3 | import java.io.File; 4 | import java.io.FileInputStream; 5 | import java.io.IOException; 6 | import java.io.ObjectInputStream; 7 | import java.util.HashMap; 8 | import java.util.Map; 9 | import org.wavescale.sourcesync.services.SyncRemoteConfigurationsService; 10 | import com.intellij.openapi.project.Project; 11 | import com.intellij.openapi.project.ProjectManager; 12 | 13 | /** 14 | * **************************************************************************** 15 | * Copyright (c) 2014-2017 Faur Ioan-Aurel. * 16 | * All rights reserved. This program and the accompanying materials * 17 | * are made available under the terms of the MIT License * 18 | * which accompanies this distribution, and is available at * 19 | * http://opensource.org/licenses/MIT * 20 | * * 21 | * For any issues or questions send an email at: fioan89@gmail.com * 22 | * ***************************************************************************** 23 | */ 24 | 25 | /** 26 | * Class for holding the name of connection configuration a project has been associated with. 27 | */ 28 | public class ConnectionConfig 29 | { 30 | private static final ConnectionConfig instance = new ConnectionConfig(); 31 | private static final String CONFIG_FILE = ".modulesconfig.ser"; 32 | String fileSeparator; 33 | private Map projectToConnection; 34 | private final String userHome; 35 | 36 | private ConnectionConfig() 37 | { 38 | projectToConnection = new HashMap<>(); 39 | userHome = System.getProperty("user.home"); 40 | fileSeparator = System.getProperty("file.separator"); 41 | } 42 | 43 | public static ConnectionConfig getInstance() 44 | { 45 | return instance; 46 | } 47 | 48 | /** 49 | * Finds and returns a config connection name associated with a given project name. 50 | * 51 | * @param projectName a string representing a project name. 52 | * @return a string representing a config connection name associated with the given 53 | * project name, or null if no connection was associated. 54 | */ 55 | private String getAssociationFor(String projectName) 56 | { 57 | return projectToConnection.get(projectName); 58 | } 59 | 60 | @SuppressWarnings("unchecked") 61 | public void migrate() 62 | { 63 | // try to load the persistence file. 64 | if (new File(userHome.concat(fileSeparator).concat(CONFIG_FILE)).exists()) 65 | { 66 | try 67 | { 68 | FileInputStream inputStream = new FileInputStream(userHome.concat(fileSeparator).concat(CONFIG_FILE)); 69 | ObjectInputStream in = new ObjectInputStream(inputStream); 70 | projectToConnection = (Map) in.readObject(); 71 | 72 | Project project = ProjectManager.getInstance().getOpenProjects()[0]; 73 | SyncRemoteConfigurationsService remoteSyncConfigurationsService = project.getService(SyncRemoteConfigurationsService.class); 74 | if (remoteSyncConfigurationsService.hasNoMainConnectionConfigured()) 75 | { 76 | if (getAssociationFor(project.getName()) != null) 77 | { 78 | remoteSyncConfigurationsService.setMainConnection(getAssociationFor(project.getName())); 79 | } 80 | } 81 | in.close(); 82 | inputStream.close(); 83 | } 84 | catch (IOException | ClassNotFoundException i) 85 | { 86 | i.printStackTrace(); 87 | } 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/main/kotlin/org/wavescale/sourcesync/configurations/BaseSyncConfiguration.kt: -------------------------------------------------------------------------------- 1 | package org.wavescale.sourcesync.configurations 2 | 3 | import com.intellij.credentialStore.CredentialAttributes 4 | import com.intellij.credentialStore.Credentials 5 | import com.intellij.credentialStore.generateServiceName 6 | import com.intellij.ide.passwordSafe.PasswordSafe 7 | import kotlinx.serialization.SerialName 8 | import kotlinx.serialization.Serializable 9 | 10 | @Serializable 11 | sealed class BaseSyncConfiguration : Cloneable { 12 | var name = "Unnamed" 13 | abstract val protocol: SyncConfigurationType 14 | var hostname = "localhost" 15 | var port = "22" 16 | 17 | var username = "username" 18 | 19 | @SerialName("authentication_type") 20 | var authenticationType = AuthenticationType.PASSWORD 21 | var password: String? 22 | get() = PasswordSafe.instance.getPassword(credentialsAttributesForPassword(protocol, name, username, hostname, port)) 23 | set(pass) = PasswordSafe.instance.set(credentialsAttributesForPassword(protocol, name, username, hostname, port), Credentials(username, pass)) 24 | 25 | @SerialName("private_key") 26 | var privateKey: String? = null 27 | var passphrase: String? 28 | get() = PasswordSafe.instance.getPassword(credentialsAttributesForPassphrase(protocol, name, username, hostname, port)) 29 | set(passPhrase) = PasswordSafe.instance.set(credentialsAttributesForPassphrase(protocol, name, username, hostname, port), Credentials(username, passPhrase)) 30 | 31 | 32 | @SerialName("remote_workspace_path") 33 | var workspaceBasePath = "/home" 34 | 35 | @SerialName("excluded_files") 36 | var excludedFiles = ".crt;.iml" 37 | 38 | @SerialName("preserve_timestamps") 39 | var preserveTimestamps = false 40 | 41 | private fun credentialsAttributesForPassword(protocol: SyncConfigurationType, name: String, username: String, hostname: String, port: String) = CredentialAttributes( 42 | generateServiceName("SourceSync - Password", "${protocol}://${name} - ${username}@${hostname}:${port}"), 43 | username 44 | ) 45 | 46 | private fun credentialsAttributesForPassphrase(protocol: SyncConfigurationType, name: String, username: String, hostname: String, port: String) = CredentialAttributes( 47 | generateServiceName("SourceSync - Passphrase", "${protocol}://${name} - ${username}@${hostname}:${port}"), 48 | username 49 | ) 50 | 51 | public abstract override fun clone(): BaseSyncConfiguration 52 | override fun equals(other: Any?): Boolean { 53 | if (this === other) return true 54 | if (javaClass != other?.javaClass) return false 55 | 56 | other as BaseSyncConfiguration 57 | 58 | if (name != other.name) return false 59 | if (protocol != other.protocol) return false 60 | if (hostname != other.hostname) return false 61 | if (port != other.port) return false 62 | if (username != other.username) return false 63 | if (authenticationType != other.authenticationType) return false 64 | if (privateKey != other.privateKey) return false 65 | if (workspaceBasePath != other.workspaceBasePath) return false 66 | if (excludedFiles != other.excludedFiles) return false 67 | return preserveTimestamps == other.preserveTimestamps 68 | } 69 | 70 | override fun hashCode(): Int { 71 | var result = name.hashCode() 72 | result = 31 * result + protocol.hashCode() 73 | result = 31 * result + hostname.hashCode() 74 | result = 31 * result + port.hashCode() 75 | result = 31 * result + username.hashCode() 76 | result = 31 * result + authenticationType.hashCode() 77 | result = 31 * result + (privateKey?.hashCode() ?: 0) 78 | result = 31 * result + workspaceBasePath.hashCode() 79 | result = 31 * result + excludedFiles.hashCode() 80 | result = 31 * result + preserveTimestamps.hashCode() 81 | return result 82 | } 83 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/wavescale/sourcesync/notifications/Notifier.kt: -------------------------------------------------------------------------------- 1 | package org.wavescale.sourcesync.notifications 2 | 3 | import com.intellij.ide.BrowserUtil 4 | import com.intellij.notification.NotificationAction 5 | import com.intellij.notification.NotificationGroupManager 6 | import com.intellij.notification.NotificationType 7 | import com.intellij.openapi.project.Project 8 | import org.wavescale.sourcesync.SourcesyncBundle 9 | 10 | private const val SOURCESYNC_GROUP_ID = "Sourcesync" 11 | 12 | class Notifier { 13 | companion object { 14 | @JvmStatic 15 | fun notifyError(project: Project, simpleMessage: String, detailedMessage: String) { 16 | if (NotificationGroupManager.getInstance().isGroupRegistered(SOURCESYNC_GROUP_ID).not()) { 17 | return 18 | } 19 | val notificationGroup = NotificationGroupManager.getInstance().getNotificationGroup(SOURCESYNC_GROUP_ID) 20 | notificationGroup 21 | .createNotification(simpleMessage, detailedMessage, NotificationType.ERROR) 22 | .notify(project) 23 | } 24 | 25 | @JvmStatic 26 | fun notifyToProDueToHighNumberOfUploads(project: Project) { 27 | if (NotificationGroupManager.getInstance().isGroupRegistered(SOURCESYNC_GROUP_ID).not()) { 28 | return 29 | } 30 | val notificationGroup = NotificationGroupManager.getInstance().getNotificationGroup(SOURCESYNC_GROUP_ID) 31 | val notification = notificationGroup.createNotification( 32 | SourcesyncBundle.message("upgrade.nudge.pro.tip.title"), 33 | SourcesyncBundle.message("upgrade.nudge.pro.tip.message"), 34 | NotificationType.INFORMATION 35 | ) 36 | 37 | notification.apply { 38 | addAction(NotificationAction.createSimple(SourcesyncBundle.message("upgrade.to.pro.version")) { 39 | BrowserUtil.browse("https://plugins.jetbrains.com/plugin/22318-source-synchronizer-pro") 40 | }) 41 | addAction(NotificationAction.createSimple(SourcesyncBundle.message("buy.me.a.coffee")) { 42 | BrowserUtil.browse("https://www.buymeacoffee.com/fioan89") 43 | }) 44 | setDisplayId(SourcesyncBundle.message("notification.group.sourcesync.donation")) 45 | isImportant = true 46 | isSuggestionType = true 47 | }.notify(project) 48 | } 49 | 50 | @JvmStatic 51 | fun notifyUpgradeToProDueToFolderUpload(project: Project) { 52 | if (NotificationGroupManager.getInstance().isGroupRegistered(SOURCESYNC_GROUP_ID).not()) { 53 | return 54 | } 55 | val notificationGroup = NotificationGroupManager.getInstance().getNotificationGroup(SOURCESYNC_GROUP_ID) 56 | val notification = notificationGroup.createNotification( 57 | SourcesyncBundle.message("upgrade.nudge.directory.upload.title"), 58 | SourcesyncBundle.message("upgrade.nudge.directory.upload.message"), 59 | NotificationType.INFORMATION 60 | ) 61 | 62 | notification.apply { 63 | addAction(NotificationAction.createSimple(SourcesyncBundle.message("upgrade.to.pro.version")) { 64 | BrowserUtil.browse("https://plugins.jetbrains.com/plugin/22318-source-synchronizer-pro") 65 | }) 66 | addAction(NotificationAction.createSimple(SourcesyncBundle.message("buy.me.a.coffee")) { 67 | BrowserUtil.browse("https://www.buymeacoffee.com/fioan89") 68 | }) 69 | 70 | setDisplayId(SourcesyncBundle.message("notification.group.sourcesync.donation")) 71 | isImportant = true 72 | isSuggestionType = true 73 | }.notify(project) 74 | } 75 | 76 | 77 | @JvmStatic 78 | fun notifyInfo(project: Project, simpleMessage: String) { 79 | if (NotificationGroupManager.getInstance().isGroupRegistered(SOURCESYNC_GROUP_ID).not()) { 80 | return 81 | } 82 | val notificationGroup = NotificationGroupManager.getInstance().getNotificationGroup(SOURCESYNC_GROUP_ID) 83 | notificationGroup.createNotification(simpleMessage, NotificationType.INFORMATION).notify(project) 84 | } 85 | } 86 | } -------------------------------------------------------------------------------- /src/main/java/org/wavescale/sourcesync/api/ConnectionConfiguration.java: -------------------------------------------------------------------------------- 1 | package org.wavescale.sourcesync.api; 2 | 3 | /** 4 | * **************************************************************************** 5 | * Copyright (c) 2014-2107 Faur Ioan-Aurel. * 6 | * All rights reserved. This program and the accompanying materials * 7 | * are made available under the terms of the MIT License * 8 | * which accompanies this distribution, and is available at * 9 | * http://opensource.org/licenses/MIT * 10 | * * 11 | * For any issues or questions send an email at: fioan89@gmail.com * 12 | * ***************************************************************************** 13 | */ 14 | 15 | import java.io.Serializable; 16 | 17 | /** 18 | * Abstract class to hold config info about different type of connections. 19 | */ 20 | 21 | public abstract class ConnectionConfiguration implements Serializable { 22 | /** 23 | * A string containing the ".ext" separated by ";" character, 24 | * representing a list of file types to be excluded from the sync. 25 | */ 26 | public String excludedFiles; 27 | protected String connectionName; 28 | protected String connectionType; 29 | protected String host; 30 | protected int port; 31 | protected int simultaneousJobs; 32 | protected String workspaceBasePath; 33 | protected String userName; 34 | protected String userPassword; 35 | protected boolean preserveTime; 36 | 37 | public ConnectionConfiguration(String connectionName) { 38 | this.connectionName = connectionName; 39 | this.excludedFiles = ".crt;.iml"; 40 | this.preserveTime = false; 41 | this.simultaneousJobs = 2; 42 | } 43 | 44 | public String getConnectionName() { 45 | return connectionName; 46 | } 47 | 48 | public void setConnectionName(String connectionName) { 49 | this.connectionName = connectionName; 50 | } 51 | 52 | 53 | /** 54 | * Gets a list of file types to be excluded from the sync. 55 | * 56 | * @return a string containing the ".ext" separated by ";" character, 57 | * representing a list of file types to be excluded from the sync. 58 | */ 59 | public String getExcludedFiles() { 60 | return excludedFiles; 61 | } 62 | 63 | /** 64 | * Sets a list of file types to be excluded from the sync. 65 | * 66 | * @param excludedFiles a string containing the ".ext" separated by ";" character, 67 | * representing a list of file types to be excluded from the sync. 68 | */ 69 | public void setExcludedFiles(String excludedFiles) { 70 | this.excludedFiles = excludedFiles; 71 | } 72 | 73 | public String getConnectionType() { 74 | return connectionType; 75 | } 76 | 77 | public void setConnectionType(String connectionType) { 78 | this.connectionType = connectionType; 79 | } 80 | 81 | public String getHost() { 82 | return host; 83 | } 84 | 85 | public void setHost(String host) { 86 | this.host = host.replace("scp://", "").replace("sftp://", "").replace("ftp://", "").replace("ftps://", ""); 87 | } 88 | 89 | public int getPort() { 90 | return port; 91 | } 92 | 93 | public void setPort(int port) { 94 | this.port = port; 95 | } 96 | 97 | public String getWorkspaceBasePath() { 98 | return workspaceBasePath; 99 | } 100 | 101 | public void setWorkspaceBasePath(String workspaceBasePath) { 102 | this.workspaceBasePath = workspaceBasePath; 103 | } 104 | 105 | public String getUserName() { 106 | return userName; 107 | } 108 | 109 | public void setUserName(String userName) { 110 | this.userName = userName; 111 | } 112 | 113 | public String getUserPassword() { 114 | return userPassword; 115 | } 116 | 117 | public void setUserPassword(String userPassword) { 118 | this.userPassword = userPassword; 119 | } 120 | 121 | public boolean isPreserveTime() { 122 | return this.preserveTime; 123 | } 124 | 125 | public void setPreserveTime(boolean shouldPreserveTimestamp) { 126 | this.preserveTime = shouldPreserveTimestamp; 127 | } 128 | 129 | public int getSimultaneousJobs() { 130 | return simultaneousJobs; 131 | } 132 | 133 | public void setSimultaneousJobs(int simultaneousJobs) { 134 | this.simultaneousJobs = simultaneousJobs; 135 | } 136 | 137 | 138 | } 139 | -------------------------------------------------------------------------------- /src/main/kotlin/org/wavescale/sourcesync/action/ActionLocalFileToRemote.kt: -------------------------------------------------------------------------------- 1 | package org.wavescale.sourcesync.action 2 | 3 | import com.intellij.openapi.actionSystem.ActionUpdateThread 4 | import com.intellij.openapi.actionSystem.AnAction 5 | import com.intellij.openapi.actionSystem.AnActionEvent 6 | import com.intellij.openapi.actionSystem.PlatformDataKeys 7 | import com.intellij.openapi.diagnostic.logger 8 | import com.intellij.openapi.progress.ProgressIndicator 9 | import com.intellij.openapi.progress.ProgressManager 10 | import com.intellij.openapi.progress.Task 11 | import com.intellij.openapi.project.ProjectManager 12 | import com.intellij.project.stateStore 13 | import com.intellij.ui.NewUI 14 | import org.wavescale.sourcesync.SourceSyncIcons 15 | import org.wavescale.sourcesync.SourcesyncBundle 16 | import org.wavescale.sourcesync.api.Utils 17 | import org.wavescale.sourcesync.configurations.ScpSyncConfiguration 18 | import org.wavescale.sourcesync.configurations.SshSyncConfiguration 19 | import org.wavescale.sourcesync.configurations.SyncConfigurationType 20 | import org.wavescale.sourcesync.notifications.Notifier 21 | import org.wavescale.sourcesync.services.SyncRemoteConfigurationsService 22 | import org.wavescale.sourcesync.synchronizer.SCPFileSynchronizer 23 | import org.wavescale.sourcesync.synchronizer.SFTPFileSynchronizer 24 | import org.wavescale.sourcesync.synchronizer.Synchronizer 25 | 26 | class ActionLocalFileToRemote : AnAction() { 27 | private val syncConfigurationsService = ProjectManager.getInstance().openProjects[0].getService(SyncRemoteConfigurationsService::class.java) 28 | override fun getActionUpdateThread() = ActionUpdateThread.BGT 29 | 30 | override fun actionPerformed(e: AnActionEvent) { 31 | // first check if there's a connection type associated to this module. 32 | // If not alert the user and get out 33 | val project = e.project ?: return 34 | 35 | val virtualFile = PlatformDataKeys.VIRTUAL_FILE.getData(e.dataContext) 36 | if (virtualFile == null) { 37 | Notifier.notifyInfo( 38 | project, 39 | SourcesyncBundle.message("no.file.selected.to.sync") 40 | ) 41 | return 42 | } 43 | 44 | if (virtualFile.isDirectory) { 45 | Notifier.notifyUpgradeToProDueToFolderUpload(project) 46 | return 47 | } 48 | 49 | val mainConfiguration = syncConfigurationsService.mainConnection() 50 | if (mainConfiguration == null) { 51 | Notifier.notifyError( 52 | project, 53 | SourcesyncBundle.message("no.remote.sync.connection.configured.title"), 54 | SourcesyncBundle.message("no.remote.sync.connection.configured.message") 55 | ) 56 | return 57 | } 58 | 59 | val fileSynchronizer: Synchronizer = when (mainConfiguration.protocol) { 60 | SyncConfigurationType.SCP -> { 61 | SCPFileSynchronizer(mainConfiguration as ScpSyncConfiguration, project) 62 | } 63 | 64 | SyncConfigurationType.SFTP -> { 65 | SFTPFileSynchronizer(mainConfiguration as SshSyncConfiguration, project) 66 | } 67 | } 68 | 69 | if (Utils.canBeUploaded(virtualFile.name, mainConfiguration.excludedFiles)) { 70 | val uploadLocation = Utils.relativeToProjectPath(virtualFile, project.stateStore) 71 | ProgressManager.getInstance().run(object : Task.Backgroundable(e.project, "Uploading", false) { 72 | override fun run(indicator: ProgressIndicator) { 73 | try { 74 | if (fileSynchronizer.connect()) { 75 | fileSynchronizer.syncFile(virtualFile.path, uploadLocation, indicator) 76 | } 77 | } finally { 78 | fileSynchronizer.disconnect() 79 | } 80 | 81 | 82 | } 83 | }) 84 | } else { 85 | logger.info("Skipping upload of ${virtualFile.name} because it matches the exclusion file pattern") 86 | } 87 | } 88 | 89 | override fun update(e: AnActionEvent) { 90 | super.update(e) 91 | if (NewUI.isEnabled()) { 92 | e.presentation.icon = SourceSyncIcons.ExpUI.SOURCESYNC 93 | } 94 | 95 | val mainConnectionName = syncConfigurationsService.mainConnectionName() 96 | if (mainConnectionName != null) { 97 | e.presentation.apply { 98 | text = "Sync this file to $mainConnectionName" 99 | isEnabled = true 100 | } 101 | } else { 102 | e.presentation.apply { 103 | text = "Sync this file to Remote target" 104 | isEnabled = false 105 | } 106 | } 107 | } 108 | 109 | companion object { 110 | val logger = logger() 111 | } 112 | 113 | } -------------------------------------------------------------------------------- /src/main/resources/META-INF/plugin.xml: -------------------------------------------------------------------------------- 1 | 2 | org.wavescale.sourcesync 3 | Source Synchronizer 4 | Ioan Faur 5 | 6 | 8 | 9 | com.intellij.modules.lang 10 | com.intellij.modules.vcs 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 24 | 25 | 26 | 27 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 43 | 44 | 45 | 46 | 49 | 50 | 51 | 52 | 53 | 54 | 57 | 59 | 61 | 63 | 64 | 65 | 66 | 67 | 68 | 71 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /src/main/kotlin/org/wavescale/sourcesync/action/ActionSelectedFilesToRemote.kt: -------------------------------------------------------------------------------- 1 | package org.wavescale.sourcesync.action 2 | 3 | import com.intellij.openapi.actionSystem.ActionUpdateThread 4 | import com.intellij.openapi.actionSystem.AnAction 5 | import com.intellij.openapi.actionSystem.AnActionEvent 6 | import com.intellij.openapi.actionSystem.PlatformDataKeys 7 | import com.intellij.openapi.diagnostic.logger 8 | import com.intellij.openapi.progress.ProgressIndicator 9 | import com.intellij.openapi.progress.ProgressManager 10 | import com.intellij.openapi.progress.Task 11 | import com.intellij.openapi.project.ProjectManager 12 | import com.intellij.project.stateStore 13 | import com.intellij.ui.NewUI 14 | import java.io.File 15 | import org.wavescale.sourcesync.SourceSyncIcons 16 | import org.wavescale.sourcesync.SourcesyncBundle 17 | import org.wavescale.sourcesync.api.Utils 18 | import org.wavescale.sourcesync.configurations.ScpSyncConfiguration 19 | import org.wavescale.sourcesync.configurations.SshSyncConfiguration 20 | import org.wavescale.sourcesync.configurations.SyncConfigurationType 21 | import org.wavescale.sourcesync.notifications.Notifier 22 | import org.wavescale.sourcesync.services.SyncRemoteConfigurationsService 23 | import org.wavescale.sourcesync.synchronizer.SCPFileSynchronizer 24 | import org.wavescale.sourcesync.synchronizer.SFTPFileSynchronizer 25 | import org.wavescale.sourcesync.synchronizer.Synchronizer 26 | 27 | class ActionSelectedFilesToRemote : AnAction() { 28 | private val syncConfigurationsService = ProjectManager.getInstance().openProjects[0].getService(SyncRemoteConfigurationsService::class.java) 29 | override fun getActionUpdateThread() = ActionUpdateThread.BGT 30 | 31 | override fun actionPerformed(e: AnActionEvent) { 32 | // first check if there's a connection type associated to this module. 33 | // If not alert the user and get out 34 | val project = e.project ?: return 35 | 36 | // get a list of selected virtual files 37 | val virtualFiles = PlatformDataKeys.VIRTUAL_FILE_ARRAY.getData(e.dataContext)!! 38 | if (virtualFiles.isEmpty()) { 39 | Notifier.notifyInfo( 40 | e.project!!, 41 | SourcesyncBundle.message("no.files.selected.to.sync") 42 | ) 43 | return 44 | } 45 | 46 | val mainConfiguration = syncConfigurationsService.mainConnection() 47 | if (mainConfiguration == null) { 48 | Notifier.notifyError( 49 | e.project!!, 50 | SourcesyncBundle.message("no.remote.sync.connection.configured.title"), 51 | SourcesyncBundle.message("no.remote.sync.connection.configured.message") 52 | ) 53 | return 54 | } 55 | 56 | val fileSynchronizer: Synchronizer = when (mainConfiguration.protocol) { 57 | SyncConfigurationType.SCP -> { 58 | SCPFileSynchronizer(mainConfiguration as ScpSyncConfiguration, project) 59 | } 60 | 61 | SyncConfigurationType.SFTP -> { 62 | SFTPFileSynchronizer(mainConfiguration as SshSyncConfiguration, project) 63 | } 64 | } 65 | var directoriesFound = false 66 | val (files, rest) = virtualFiles.filterNotNull().partition { File(it.path).isFile } 67 | rest.forEach { 68 | directoriesFound = true 69 | logger.info("Skipping upload of ${it.name} because it's a directory") 70 | } 71 | 72 | if (directoriesFound) { 73 | Notifier.notifyUpgradeToProDueToFolderUpload(project) 74 | } 75 | 76 | val (acceptedFiles, excludedFiles) = files.partition { Utils.canBeUploaded(it.name, mainConfiguration.excludedFiles) } 77 | excludedFiles.forEach { 78 | logger.info("Skipping upload of ${it.name} because it matches the exclusion file pattern") 79 | } 80 | ProgressManager.getInstance().run(object : Task.Backgroundable(e.project, "Uploading", false) { 81 | override fun run(indicator: ProgressIndicator) { 82 | try { 83 | if (fileSynchronizer.connect()) { 84 | fileSynchronizer.syncFiles(acceptedFiles.map { Pair(it.path, Utils.relativeToProjectPath(it, project.stateStore)) }.toSet(), indicator) 85 | } 86 | } finally { 87 | fileSynchronizer.disconnect() 88 | } 89 | } 90 | }) 91 | } 92 | 93 | override fun update(e: AnActionEvent) { 94 | super.update(e) 95 | if (NewUI.isEnabled()) { 96 | e.presentation.icon = SourceSyncIcons.ExpUI.SOURCESYNC 97 | } 98 | 99 | val mainConnectionName = syncConfigurationsService.mainConnectionName() 100 | if (mainConnectionName != null) { 101 | e.presentation.apply { 102 | text = "Sync selected files to $mainConnectionName" 103 | isEnabled = true 104 | } 105 | } else { 106 | e.presentation.apply { 107 | text = "Sync selected files to Remote target" 108 | isEnabled = false 109 | } 110 | } 111 | } 112 | 113 | companion object { 114 | val logger = logger() 115 | } 116 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/wavescale/sourcesync/action/OldUIConnectionConfigurationSelector.kt: -------------------------------------------------------------------------------- 1 | package org.wavescale.sourcesync.action 2 | 3 | import com.intellij.ide.DataManager 4 | import com.intellij.openapi.actionSystem.ActionManager 5 | import com.intellij.openapi.actionSystem.ActionUpdateThread 6 | import com.intellij.openapi.actionSystem.AnAction 7 | import com.intellij.openapi.actionSystem.AnActionEvent 8 | import com.intellij.openapi.actionSystem.CommonDataKeys 9 | import com.intellij.openapi.actionSystem.DataContext 10 | import com.intellij.openapi.actionSystem.DefaultActionGroup 11 | import com.intellij.openapi.actionSystem.Presentation 12 | import com.intellij.openapi.actionSystem.ex.ComboBoxAction 13 | import com.intellij.openapi.components.service 14 | import com.intellij.openapi.project.DumbAwareAction 15 | import org.wavescale.sourcesync.SourceSyncIcons 16 | import org.wavescale.sourcesync.SourcesyncBundle 17 | import org.wavescale.sourcesync.factory.ConnectionConfig 18 | import org.wavescale.sourcesync.services.SyncRemoteConfigurationsService 19 | import org.wavescale.sourcesync.services.SyncStatusService 20 | import org.wavescale.sourcesync.ui.ConnectionConfigurationDialog 21 | import java.awt.Graphics 22 | import java.awt.event.MouseAdapter 23 | import java.awt.event.MouseEvent 24 | import javax.swing.JComponent 25 | import javax.swing.SwingUtilities 26 | 27 | class OldUIConnectionConfigurationSelector : ComboBoxAction() { 28 | private val syncStatusService = service() 29 | 30 | override fun getActionUpdateThread() = ActionUpdateThread.BGT 31 | 32 | override fun createPopupActionGroup(button: JComponent, dataContext: DataContext): DefaultActionGroup { 33 | val allActionsGroup = DefaultActionGroup() 34 | allActionsGroup.add(getEditConnectionConfigurationsAction()) 35 | allActionsGroup.addSeparator(SourcesyncBundle.message("sourcesyncConfigurations")) 36 | val syncConfigurationsService = dataContext.getData(CommonDataKeys.PROJECT)?.service() 37 | syncConfigurationsService?.allConnectionNames()?.forEach { 38 | allActionsGroup.add(SourceSyncConfigAction(syncConfigurationsService, it)) 39 | } 40 | 41 | return allActionsGroup 42 | } 43 | 44 | override fun createComboBoxButton(presentation: Presentation): ComboBoxButton { 45 | return ConnectionConfigurationsComboBox(presentation) 46 | } 47 | 48 | private fun getEditConnectionConfigurationsAction(): AnAction { 49 | return ActionManager.getInstance().getAction("actionSourceSyncMenu") 50 | } 51 | 52 | override fun update(e: AnActionEvent) { 53 | val syncConfigurationsService = e.project?.service() 54 | val associationFor = syncConfigurationsService?.mainConnectionName() 55 | if (!associationFor.isNullOrBlank() && syncConfigurationsService.findFirstWithName(associationFor) == null) { 56 | ConnectionConfig.getInstance().apply { 57 | syncConfigurationsService.resetMainConnection() 58 | e.presentation.apply { 59 | isEnabled = true 60 | text = SourcesyncBundle.message("sourcesyncAddConfigurations") 61 | icon = null 62 | } 63 | } 64 | } 65 | if (associationFor.isNullOrBlank()) { 66 | e.presentation.apply { 67 | isEnabled = true 68 | text = SourcesyncBundle.message("sourcesyncAddConfigurations") 69 | icon = null 70 | } 71 | } else { 72 | e.presentation.apply { 73 | isEnabled = true 74 | text = syncConfigurationsService.mainConnectionName() 75 | icon = SourceSyncIcons.SOURCESYNC 76 | } 77 | } 78 | } 79 | 80 | 81 | inner class ConnectionConfigurationsComboBox(presentation: Presentation) : ComboBoxButton(presentation) { 82 | init { 83 | 84 | addMouseListener(object : MouseAdapter() { 85 | override fun mousePressed(e: MouseEvent) { 86 | if (SwingUtilities.isLeftMouseButton(e)) { 87 | e.consume() 88 | if (e.isShiftDown) { 89 | onShiftClick() 90 | } 91 | } 92 | } 93 | }) 94 | } 95 | 96 | override fun paint(g: Graphics) { 97 | super.paint(g) 98 | presentation.icon = if (syncStatusService.isAnySyncJobRunning()) { 99 | SourceSyncIcons.SOURCESYNC_RUNNING 100 | } else { 101 | SourceSyncIcons.SOURCESYNC 102 | } 103 | } 104 | 105 | // TODO remove the listener and override doShiftClick when is no longer experimental 106 | fun onShiftClick() { 107 | val context = DataManager.getInstance().getDataContext(this) 108 | val project = CommonDataKeys.PROJECT.getData(context) 109 | if (project != null) { 110 | ConnectionConfigurationDialog(project).show() 111 | } 112 | } 113 | } 114 | 115 | internal class SourceSyncConfigAction(private val syncConfigurationsService: SyncRemoteConfigurationsService, private val configuration: String) : DumbAwareAction() { 116 | 117 | init { 118 | val presentation = templatePresentation 119 | presentation.setText(configuration, false) 120 | presentation.icon = SourceSyncIcons.SOURCESYNC 121 | } 122 | 123 | override fun actionPerformed(e: AnActionEvent) { 124 | val presentation = templatePresentation 125 | presentation.setText(configuration, false) 126 | syncConfigurationsService.setMainConnection(configuration) 127 | } 128 | } 129 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/wavescale/sourcesync/action/ActionChangedFilesToRemote.kt: -------------------------------------------------------------------------------- 1 | package org.wavescale.sourcesync.action 2 | 3 | import com.intellij.openapi.actionSystem.ActionUpdateThread 4 | import com.intellij.openapi.actionSystem.AnAction 5 | import com.intellij.openapi.actionSystem.AnActionEvent 6 | import com.intellij.openapi.diagnostic.logger 7 | import com.intellij.openapi.progress.ProgressIndicator 8 | import com.intellij.openapi.progress.ProgressManager 9 | import com.intellij.openapi.progress.Task 10 | import com.intellij.openapi.project.ProjectManager 11 | import com.intellij.openapi.vcs.changes.ChangeListManager 12 | import com.intellij.openapi.vcs.changes.LocalChangeList 13 | import com.intellij.openapi.vfs.VirtualFile 14 | import com.intellij.project.stateStore 15 | import com.intellij.ui.NewUI 16 | import java.io.File 17 | import org.wavescale.sourcesync.SourceSyncIcons 18 | import org.wavescale.sourcesync.SourcesyncBundle 19 | import org.wavescale.sourcesync.api.Utils 20 | import org.wavescale.sourcesync.configurations.ScpSyncConfiguration 21 | import org.wavescale.sourcesync.configurations.SshSyncConfiguration 22 | import org.wavescale.sourcesync.configurations.SyncConfigurationType 23 | import org.wavescale.sourcesync.notifications.Notifier 24 | import org.wavescale.sourcesync.services.SyncRemoteConfigurationsService 25 | import org.wavescale.sourcesync.synchronizer.SCPFileSynchronizer 26 | import org.wavescale.sourcesync.synchronizer.SFTPFileSynchronizer 27 | import org.wavescale.sourcesync.synchronizer.Synchronizer 28 | 29 | class ActionChangedFilesToRemote : AnAction() { 30 | private val syncConfigurationsService = ProjectManager.getInstance().openProjects[0].getService(SyncRemoteConfigurationsService::class.java) 31 | override fun getActionUpdateThread() = ActionUpdateThread.BGT 32 | override fun actionPerformed(e: AnActionEvent) { 33 | // first check if there's a connection type associated to this module. 34 | // If not alert the user and get out 35 | val project = e.project ?: return 36 | 37 | // there's this possibility that the project might not be versioned, therefore no changes can be detected. 38 | val changeLists = ChangeListManager.getInstance(e.project!!).changeLists 39 | if (!hasModifiedFiles(changeLists)) { 40 | Notifier.notifyInfo( 41 | e.project!!, 42 | SourcesyncBundle.message("no.vcs.changes.to.sync"), 43 | ) 44 | return 45 | } 46 | 47 | // get a list of changed virtual files 48 | val changedFiles: MutableList = ArrayList() 49 | for (localChangeList in changeLists) { 50 | for (change in localChangeList.changes) { 51 | changedFiles.add(change.virtualFile) 52 | } 53 | } 54 | 55 | val mainConfiguration = syncConfigurationsService.mainConnection() 56 | if (mainConfiguration == null) { 57 | Notifier.notifyError( 58 | e.project!!, 59 | SourcesyncBundle.message("no.remote.sync.connection.configured.title"), 60 | SourcesyncBundle.message("no.remote.sync.connection.configured.message") 61 | ) 62 | return 63 | } 64 | 65 | val fileSynchronizer: Synchronizer = when (mainConfiguration.protocol) { 66 | SyncConfigurationType.SCP -> { 67 | SCPFileSynchronizer(mainConfiguration as ScpSyncConfiguration, project) 68 | } 69 | 70 | SyncConfigurationType.SFTP -> { 71 | SFTPFileSynchronizer(mainConfiguration as SshSyncConfiguration, project) 72 | } 73 | } 74 | 75 | var directoriesFound = false 76 | val (files, rest) = changedFiles.filterNotNull().partition { File(it.path).isFile } 77 | rest.forEach { 78 | directoriesFound = true 79 | ActionSelectedFilesToRemote.logger.info("Skipping upload of ${it.name} because it's a directory") 80 | } 81 | 82 | if (directoriesFound) { 83 | Notifier.notifyUpgradeToProDueToFolderUpload(project) 84 | } 85 | 86 | val (acceptedFiles, excludedFiles) = files.partition { Utils.canBeUploaded(it.name, mainConfiguration.excludedFiles) } 87 | excludedFiles.forEach { 88 | ActionSelectedFilesToRemote.logger.info("Skipping upload of ${it.name} because it matches the exclusion file pattern") 89 | } 90 | ProgressManager.getInstance().run(object : Task.Backgroundable(e.project, "Uploading", false) { 91 | override fun run(indicator: ProgressIndicator) { 92 | try { 93 | if (fileSynchronizer.connect()) { 94 | fileSynchronizer.syncFiles(acceptedFiles.map { Pair(it.path, Utils.relativeToProjectPath(it, project.stateStore)) }.toSet(), indicator) 95 | } 96 | } finally { 97 | fileSynchronizer.disconnect() 98 | } 99 | } 100 | }) 101 | 102 | } 103 | 104 | private fun hasModifiedFiles(changeLists: List): Boolean { 105 | for (changeList in changeLists) { 106 | val changes = changeList.changes 107 | for (change in changes) { 108 | if (change.virtualFile != null) { 109 | return true 110 | } 111 | } 112 | } 113 | return false 114 | } 115 | 116 | override fun update(e: AnActionEvent) { 117 | super.update(e) 118 | if (NewUI.isEnabled()) { 119 | e.presentation.icon = SourceSyncIcons.ExpUI.SOURCESYNC 120 | } 121 | 122 | val mainConnectionName = syncConfigurationsService.mainConnectionName() 123 | if (mainConnectionName != null) { 124 | e.presentation.apply { 125 | text = "Sync changed files to $mainConnectionName" 126 | isEnabled = true 127 | } 128 | } else { 129 | e.presentation.apply { 130 | text = "Sync changed files to Remote target" 131 | isEnabled = false 132 | } 133 | } 134 | } 135 | 136 | companion object { 137 | private val logger = logger() 138 | } 139 | } -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # GitHub Actions Workflow created for testing and preparing the plugin release in following steps: 2 | # - validate Gradle Wrapper, 3 | # - run 'test' and 'verifyPlugin' tasks, 4 | # - run Qodana inspections, 5 | # - run 'buildPlugin' task and prepare artifact for the further tests, 6 | # - run 'runPluginVerifier' task, 7 | # - create a draft release. 8 | # 9 | # Workflow is triggered on push and pull_request events. 10 | # 11 | # GitHub Actions reference: https://help.github.com/en/actions 12 | # 13 | 14 | name: Sourcesync Plugin Build 15 | on: 16 | # Trigger the workflow on pushes to only the 'master' branch (this avoids duplicate checks being run e.g. for dependabot pull requests) 17 | push: 18 | branches: [master] 19 | # Trigger the workflow on any pull request 20 | pull_request: 21 | 22 | jobs: 23 | 24 | # Run Gradle Wrapper Validation Action to verify the wrapper's checksum 25 | # Run verifyPlugin, IntelliJ Plugin Verifier, and test Gradle tasks 26 | # Build plugin and provide the artifact for the next workflow jobs 27 | build: 28 | name: Build 29 | runs-on: ubuntu-latest 30 | outputs: 31 | version: ${{ steps.properties.outputs.version }} 32 | changelog: ${{ steps.properties.outputs.changelog }} 33 | steps: 34 | 35 | # Free GitHub Actions Environment Disk Space 36 | - name: Maximize Build Space 37 | run: | 38 | sudo rm -rf /usr/share/dotnet 39 | sudo rm -rf /usr/local/lib/android 40 | sudo rm -rf /opt/ghc 41 | 42 | # Check out current repository 43 | - name: Fetch Sources 44 | uses: actions/checkout@v4 45 | 46 | # Validate wrapper 47 | - name: Gradle Wrapper Validation 48 | uses: gradle/wrapper-validation-action@v1.1.0 49 | 50 | # Setup Java 17 environment for the next steps 51 | - name: Setup Java 52 | uses: actions/setup-java@v4 53 | with: 54 | distribution: zulu 55 | java-version: 17 56 | cache: gradle 57 | 58 | # Set environment variables 59 | - name: Export Properties 60 | id: properties 61 | shell: bash 62 | run: | 63 | PROPERTIES="$(./gradlew properties --console=plain -q)" 64 | VERSION="$(echo "$PROPERTIES" | grep "^version:" | cut -f2- -d ' ')" 65 | NAME="$(echo "$PROPERTIES" | grep "^pluginName:" | cut -f2- -d ' ')" 66 | CHANGELOG="$(./gradlew getChangelog --unreleased --no-header --console=plain -q)" 67 | CHANGELOG="${CHANGELOG//'%'/'%25'}" 68 | CHANGELOG="${CHANGELOG//$'\n'/'%0A'}" 69 | CHANGELOG="${CHANGELOG//$'\r'/'%0D'}" 70 | 71 | echo "::set-output name=version::$VERSION" 72 | echo "::set-output name=name::$NAME" 73 | echo "::set-output name=changelog::$CHANGELOG" 74 | echo "::set-output name=pluginVerifierHomeDir::~/.pluginVerifier" 75 | 76 | ./gradlew listProductsReleases # prepare list of IDEs for Plugin Verifier 77 | 78 | # Run tests 79 | - name: Run Tests 80 | run: ./gradlew test 81 | 82 | # Collect Tests Result of failed tests 83 | - name: Collect Tests Result 84 | if: ${{ failure() }} 85 | uses: actions/upload-artifact@v4 86 | with: 87 | name: tests-result 88 | path: ${{ github.workspace }}/build/reports/tests 89 | 90 | # Cache Plugin Verifier IDEs 91 | - name: Setup Plugin Verifier IDEs Cache 92 | uses: actions/cache@v4 93 | with: 94 | path: ${{ steps.properties.outputs.pluginVerifierHomeDir }}/ides 95 | key: plugin-verifier-${{ hashFiles('build/listProductsReleases.txt') }} 96 | 97 | # Run Verify Plugin task and IntelliJ Plugin Verifier tool 98 | - name: Run Plugin Verification tasks 99 | run: ./gradlew runPluginVerifier -Dplugin.verifier.home.dir=${{ steps.properties.outputs.pluginVerifierHomeDir }} 100 | 101 | # Collect Plugin Verifier Result 102 | - name: Collect Plugin Verifier Result 103 | if: ${{ always() }} 104 | uses: actions/upload-artifact@v4 105 | with: 106 | name: pluginVerifier-result 107 | path: ${{ github.workspace }}/build/reports/pluginVerifier 108 | 109 | # Run Qodana inspections 110 | - name: Qodana - Code Inspection 111 | uses: JetBrains/qodana-action@v2023.3.0 112 | 113 | # Prepare plugin archive content for creating artifact 114 | - name: Prepare Plugin Artifact 115 | id: artifact 116 | shell: bash 117 | run: | 118 | cd ${{ github.workspace }}/build/distributions 119 | FILENAME=`ls *.zip` 120 | unzip "$FILENAME" -d content 121 | 122 | echo "::set-output name=filename::${FILENAME:0:-4}" 123 | 124 | # Store already-built plugin as an artifact for downloading 125 | - name: Upload artifact 126 | uses: actions/upload-artifact@v4 127 | with: 128 | name: ${{ steps.artifact.outputs.filename }} 129 | path: ./build/distributions/content/*/* 130 | 131 | # Prepare a draft release for GitHub Releases page for the manual verification 132 | # If accepted and published, release workflow would be triggered 133 | releaseDraft: 134 | name: Release Draft 135 | if: github.event_name != 'pull_request' 136 | needs: build 137 | runs-on: ubuntu-latest 138 | permissions: 139 | contents: write 140 | steps: 141 | 142 | # Check out current repository 143 | - name: Fetch Sources 144 | uses: actions/checkout@v4 145 | 146 | # Remove old release drafts by using the curl request for the available releases with draft flag 147 | - name: Remove Old Release Drafts 148 | env: 149 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 150 | run: | 151 | gh api repos/{owner}/{repo}/releases \ 152 | --jq '.[] | select(.draft == true) | .id' \ 153 | | xargs -I '{}' gh api -X DELETE repos/{owner}/{repo}/releases/{} 154 | 155 | # Create new release draft - which is not publicly visible and requires manual acceptance 156 | - name: Create Release Draft 157 | env: 158 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 159 | run: | 160 | gh release create v${{ needs.build.outputs.version }} \ 161 | --draft \ 162 | --title "v${{ needs.build.outputs.version }}" \ 163 | --notes "$(cat << 'EOM' 164 | ${{ needs.build.outputs.changelog }} 165 | EOM 166 | )" -------------------------------------------------------------------------------- /src/main/java/org/wavescale/sourcesync/factory/ConfigConnectionFactory.java: -------------------------------------------------------------------------------- 1 | package org.wavescale.sourcesync.factory; 2 | 3 | /** 4 | * **************************************************************************** 5 | * Copyright (c) 2014-2107 Faur Ioan-Aurel. * 6 | * All rights reserved. This program and the accompanying materials * 7 | * are made available under the terms of the MIT License * 8 | * which accompanies this distribution, and is available at * 9 | * http://opensource.org/licenses/MIT * 10 | * * 11 | * For any issues or questions send an email at: fioan89@gmail.com * 12 | * ***************************************************************************** 13 | */ 14 | 15 | import java.io.File; 16 | import java.io.FileInputStream; 17 | import java.io.IOException; 18 | import java.io.ObjectInputStream; 19 | import java.util.HashMap; 20 | import java.util.Map; 21 | import org.jetbrains.annotations.NotNull; 22 | import org.wavescale.sourcesync.api.ConnectionConfiguration; 23 | import org.wavescale.sourcesync.api.ConnectionConstants; 24 | import org.wavescale.sourcesync.api.PasswordlessSSH; 25 | import org.wavescale.sourcesync.configurations.AuthenticationType; 26 | import org.wavescale.sourcesync.configurations.ScpSyncConfiguration; 27 | import org.wavescale.sourcesync.configurations.SshSyncConfiguration; 28 | import org.wavescale.sourcesync.services.SyncRemoteConfigurationsService; 29 | import com.intellij.openapi.diagnostic.Logger; 30 | import com.intellij.openapi.project.ProjectManager; 31 | 32 | /** 33 | * Load connection settings from the persistence layout, instantiate them and keep them 34 | * during the runtime. 35 | */ 36 | public class ConfigConnectionFactory 37 | { 38 | private static final Logger logger = Logger.getInstance(ConfigConnectionFactory.class); 39 | private static final ConfigConnectionFactory CONFIG_CONNECTION_FACTORY = new ConfigConnectionFactory(); 40 | private static final String CONNECTIONS_FILE = ".connectionconfig.ser"; 41 | String fileSeparator; 42 | private String userHome; 43 | private Map connectionConfigurationMap; 44 | 45 | private SyncRemoteConfigurationsService remoteSyncConfigurationsService = ProjectManager.getInstance().getOpenProjects()[0].getService(SyncRemoteConfigurationsService.class); 46 | 47 | private ConfigConnectionFactory() 48 | { 49 | connectionConfigurationMap = new HashMap<>(); 50 | userHome = System.getProperty("user.home"); 51 | fileSeparator = System.getProperty("file.separator"); 52 | } 53 | 54 | public static ConfigConnectionFactory getInstance() 55 | { 56 | return CONFIG_CONNECTION_FACTORY; 57 | } 58 | 59 | @SuppressWarnings("unchecked") 60 | public void migrate() 61 | { 62 | // try to load the persistence data. 63 | if (new File(userHome.concat(fileSeparator).concat(CONNECTIONS_FILE)).exists()) 64 | { 65 | try (ObjectInputStream in = new ObjectInputStream(new FileInputStream(userHome.concat(fileSeparator).concat(CONNECTIONS_FILE)))) 66 | { 67 | connectionConfigurationMap = (Map) in.readObject(); 68 | if (remoteSyncConfigurationsService.hasNoConfiguration()) 69 | { 70 | connectionConfigurationMap.values().forEach(configuration -> { 71 | if (configuration.getConnectionType().equals(ConnectionConstants.CONN_TYPE_SCP)) 72 | { 73 | remoteSyncConfigurationsService.add(toScpSyncConfigurationState(configuration)); 74 | } 75 | else if (configuration.getConnectionType().equals(ConnectionConstants.CONN_TYPE_SFTP)) 76 | { 77 | remoteSyncConfigurationsService.add(toSshSyncConfigurationState(configuration)); 78 | } 79 | }); 80 | } 81 | } 82 | catch (IOException | ClassNotFoundException e) 83 | { 84 | logger.warn("Could not load connections because", e); 85 | } 86 | } 87 | } 88 | 89 | @NotNull 90 | private static ScpSyncConfiguration toScpSyncConfigurationState(ConnectionConfiguration configuration) 91 | { 92 | ScpSyncConfiguration c = new ScpSyncConfiguration(); 93 | c.setName(configuration.getConnectionName()); 94 | c.setHostname(configuration.getHost()); 95 | c.setPort(String.valueOf(configuration.getPort())); 96 | 97 | c.setUsername(configuration.getUserName()); 98 | if (((PasswordlessSSH) configuration).isPasswordlessSSHSelected()) 99 | { 100 | c.setAuthenticationType(AuthenticationType.KEY_PAIR); 101 | c.setPrivateKey(((PasswordlessSSH) configuration).getCertificatePath()); 102 | if (((PasswordlessSSH) configuration).isPasswordlessWithPassphrase()) 103 | { 104 | c.setPassphrase(configuration.getUserPassword()); 105 | } 106 | } 107 | else 108 | { 109 | c.setAuthenticationType(AuthenticationType.PASSWORD); 110 | c.setPassword(configuration.getUserPassword()); 111 | } 112 | 113 | c.setExcludedFiles(configuration.getExcludedFiles()); 114 | c.setPreserveTimestamps(configuration.isPreserveTime()); 115 | return c; 116 | } 117 | 118 | @NotNull 119 | private static SshSyncConfiguration toSshSyncConfigurationState(ConnectionConfiguration configuration) 120 | { 121 | SshSyncConfiguration c = new SshSyncConfiguration(); 122 | c.setName(configuration.getConnectionName()); 123 | c.setHostname(configuration.getHost()); 124 | c.setPort(String.valueOf(configuration.getPort())); 125 | 126 | c.setUsername(configuration.getUserName()); 127 | if (((PasswordlessSSH) configuration).isPasswordlessSSHSelected()) 128 | { 129 | c.setAuthenticationType(AuthenticationType.KEY_PAIR); 130 | c.setPrivateKey(((PasswordlessSSH) configuration).getCertificatePath()); 131 | if (((PasswordlessSSH) configuration).isPasswordlessWithPassphrase()) 132 | { 133 | c.setPassphrase(configuration.getUserPassword()); 134 | } 135 | } 136 | else 137 | { 138 | c.setAuthenticationType(AuthenticationType.PASSWORD); 139 | c.setPassword(configuration.getUserPassword()); 140 | } 141 | 142 | c.setExcludedFiles(configuration.getExcludedFiles()); 143 | c.setPreserveTimestamps(configuration.isPreserveTime()); 144 | return c; 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Source Synchronizer Changelog 4 | 5 | ## [Unreleased] 6 | 7 | ## [3.0.3] - 2023-12-13 8 | 9 | ### Removed 10 | 11 | - support for IntelliJ 2023.1 platform 12 | 13 | ### Fixed 14 | 15 | - SCP upload for files that don't have an existing remote directory will now fail with an error instead of silently uploading into a file with parent folder's name 16 | - support for New UI on IJ 2023.3 17 | 18 | ## [3.0.2] - 2023-07-20 19 | 20 | ### Added 21 | 22 | - [**Sourcesync Pro**](https://plugins.jetbrains.com/plugin/22318-source-synchronizer-pro) announcement 23 | 24 | ### Changed 25 | 26 | - the plugin no longer relies on private/internal IntelliJ API's (ported from **Sourcesync Pro**) 27 | 28 | ### Fixed 29 | 30 | - NPE when notification group is not yet available 31 | 32 | ## [3.0.1] - 2023-06-30 33 | 34 | ### Fixed 35 | 36 | - migration on New UI disables the sync menu if it fails to migrate the main connection 37 | 38 | ## [3.0.0] - 2023-05-19 39 | 40 | ### Added 41 | 42 | - passwords and passphrases are now stored in the much safer IntelliJ Credential Store. 43 | - improved SCP upload fail messages 44 | 45 | ### Changed 46 | 47 | - redesigned Sync Connection Configurations editor with an improved look and feel similar to Run Configurations editor 48 | - simplified the user experience when it comes to configuring remote connections, especially the authentication form 49 | 50 | ### Removed 51 | 52 | - support for FTP&FTPS protocols 53 | - the ability to configure concurrent sync jobs 54 | 55 | ## [2.0.4] - 2023-04-30 56 | 57 | ### Fixed 58 | 59 | - context menu takes a long time to open when using the New UI 60 | 61 | ### Changed 62 | 63 | - removed deprecated API usages 64 | 65 | ## [2.0.3] - 2023-03-01 66 | 67 | ### Added 68 | 69 | - support for experimental new UI 70 | 71 | ### Changed 72 | 73 | - simplified and improved the error reporting 74 | 75 | ### Fixed 76 | 77 | - path location issues when uploading files with scp from Windows to Unix 78 | - sync menus are now disabled when no remote target is selected 79 | 80 | ## [2.0.2] - 2022-12-07 81 | 82 | ### Added 83 | 84 | - support for latest OpenSSH private key format 85 | - support for latest IntelliJ 2022.3 86 | 87 | ## [2.0.1] 88 | 89 | ### Fixed 90 | 91 | - upload issues when using SFTP from local Windows to remote Linux 92 | - behavior for SSH keys component, now they properly enable or disable if users want to authenticate with SSH keys 93 | - plugin icon shown in marketplace 94 | 95 | ### Changed 96 | 97 | - project's base location label from "Root path" to "Workspace base path". This is a breaking change, users 98 | will have to reconfigure the connections again. 99 | 100 | ## [2.0.0] 101 | 102 | ### Added 103 | 104 | - support for semver 105 | - build with Kotlin&Gradle 106 | - support for Java 11 107 | - new menu icons 108 | 109 | ### Changed 110 | 111 | - migrated UI layout from JGoodie's `FormLayout` to java.awt + IntelliJ layouts 112 | - migrated most of the dialogs and panels to programmatic code. Reduces the UI Designer footprint 113 | - connection configuration and selection dialogs can now be done from a single place, a combo 114 | box placed in the toolbar. Similar to run/edit configurations. 115 | 116 | ### Removed 117 | 118 | - support for builds before IntelliJ IDEA 2021.1 119 | - removed usages of scheduled to be removed API 120 | - support for builds with Java 1.6 121 | - context menus to create and select connection configurations 122 | 123 | ### Fixed 124 | 125 | - fixed issues with resource location due to trailing "/" 126 | - remove all project associations when there is no Sourcesync connection available 127 | 128 | ## [1.9.0] 129 | 130 | ### Added 131 | 132 | - support for OS X 10.11 133 | - support for passphrase keys for sftp connections 134 | - support for passwordless ssh for scp connections 135 | 136 | ### Changed 137 | 138 | - the configuration window, the add target and module selection to centered dialogs 139 | - `All previous configurations will be lost` 140 | 141 | ### Fixed 142 | 143 | - support for passwordless ssh for sftp connections 144 | - issues with known_hosts file on Windows machines 145 | - issue with hidden files and directories not showing through private key file chooser 146 | - issue with private key file chooser forcing you to select the public key instead of the private one 147 | - issues with configuration and target window not getting on top of the IDE 148 | 149 | ## [1.8.0] 150 | 151 | ### Added 152 | 153 | - support for IntelliJ IDEA 15.x 154 | - support for PyCharm 5.x 155 | 156 | ### Changed 157 | 158 | - Set the configuration window to be always on top 159 | 160 | ### Fixed 161 | 162 | - upload of files over FTPS connections using explicit TLS security 163 | 164 | ## [1.5.0] 165 | 166 | ### Added 167 | 168 | - support for shortcuts 169 | 170 | ### Fixed 171 | 172 | - NPE when no default file was selected. 173 | - exception due to context switching when using shortcuts. 174 | 175 | ## [1.4.0] 176 | 177 | ### Added 178 | 179 | - a file sync manager, which forces `Sourcesync` to reuse existing opened connections during the command. 180 | 181 | ### Changed 182 | 183 | - build with java 1.6 support 184 | 185 | ### Fixed 186 | 187 | - Sync selected jobs no longer has problems with "Allow simultaneous sync jobs" option. 188 | 189 | ## [1.3.0] 190 | 191 | ### Added 192 | 193 | - sync selected and changed files into the Changes View Popup-Menu 194 | 195 | ### Fixed 196 | 197 | - force OK button to save connection preferences 198 | - a few visual bugs (the `Allow ... number of connections` was not visible until resize) 199 | 200 | ## [1.2.0] 201 | 202 | ### Added 203 | 204 | - option to limit the number of upload threads 205 | 206 | ### Fixed 207 | 208 | - support for `PyCharm` 209 | 210 | [Unreleased]: https://github.com/fioan89/sourcesync/compare/v3.0.3...HEAD 211 | [3.0.3]: https://github.com/fioan89/sourcesync/compare/v3.0.2...v3.0.3 212 | [3.0.2]: https://github.com/fioan89/sourcesync/compare/v3.0.1...v3.0.2 213 | [3.0.1]: https://github.com/fioan89/sourcesync/compare/v3.0.0...v3.0.1 214 | [3.0.0]: https://github.com/fioan89/sourcesync/compare/v2.0.4...v3.0.0 215 | [2.0.4]: https://github.com/fioan89/sourcesync/compare/v2.0.3...v2.0.4 216 | [2.0.3]: https://github.com/fioan89/sourcesync/compare/v2.0.2...v2.0.3 217 | [2.0.2]: https://github.com/fioan89/sourcesync/compare/v2.0.1...v2.0.2 218 | [2.0.1]: https://github.com/fioan89/sourcesync/compare/v2.0.0...v2.0.1 219 | [2.0.0]: https://github.com/fioan89/sourcesync/compare/v1.9.0...v2.0.0 220 | [1.9.0]: https://github.com/fioan89/sourcesync/compare/v1.8.0...v1.9.0 221 | [1.8.0]: https://github.com/fioan89/sourcesync/compare/v1.5.0...v1.8.0 222 | [1.5.0]: https://github.com/fioan89/sourcesync/compare/v1.4.0...v1.5.0 223 | [1.4.0]: https://github.com/fioan89/sourcesync/compare/v1.3.0...v1.4.0 224 | [1.3.0]: https://github.com/fioan89/sourcesync/compare/v1.2.0...v1.3.0 225 | [1.2.0]: https://github.com/fioan89/sourcesync/commits/v1.2.0 226 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sourcesync Plugin 2 | 3 | [!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/fioan89) 4 | [![Donate](https://www.paypalobjects.com/en_US/i/btn/btn_donate_SM.gif)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=W3SKYN2L99GMQ) 5 | [![Sourcesync Plugin Build](https://github.com/fioan89/sourcesync/actions/workflows/build.yml/badge.svg)](https://github.com/fioan89/sourcesync/actions/workflows/build.yml) 6 | 7 | 8 | A fast, one-way file synchronization tool for your projects. 9 | 10 | Local changes can be transferred on the remote infrastructure using the **SCP** or **SSH** protocols. 11 | Other notable features include: 12 | 13 | * **password** and **key pair** authentication 14 | * support for **key pairs** with and without *passphrases* 15 | * timestamp preserving 16 | * file filtering 17 | * user-friendly UI with support for IntelliJ's **New UI** feature 18 | * synchronization of single or multi-selection files as well as VCS changes 19 | 20 | Upgrade to **Sourcesync Pro** for more features and a more streamlined way of transferring code. 21 | 22 | | Features | Sourcesync | Sourcesync Pro | 23 | |:---------------------------------------------------------------:|:-------------------------:|:----------------------------------------:| 24 | | License | **Free** & **opensource** | Starts at **$5.99 USD per user/month** | 25 | | SCP File Upload | Yes | Yes | 26 | | SCP Folder Upload | **No** | Yes - Recursive Upload | 27 | | SSH/SFTP File Upload | Yes | Yes | 28 | | SSH/SFTP Folder Upload | **No** | Yes - Recursive Upload | 29 | | Auto Transfer On File Save | **No** | Yes | 30 | | Support For Comparing Local vs Remote Files | **No** | Yes | 31 | | SCP/SSH Password Authentication | Yes | Yes | 32 | | SCP/SSH Key-Pair Authentication With & Without Passphrase | Yes | Yes | 33 | | SCP/SSH timestamp preserving | Modification Time | Permissions, Access & Modification times | 34 | | File Filtering | Yes | Yes | 35 | | User-friendly UI with support for IntelliJ's **New UI** feature | Yes | Yes | 36 | | Customizable keyboard shortcuts | Yes | Yes | 37 | | VCS Changes Upload | Yes | Yes | 38 | | Multi-selection File Upload | Yes | Yes | 39 | | Multi-selection Folder Upload | **No** | Yes | 40 | | Editor File Upload | Yes | Yes | 41 | 42 | 43 | 44 | ## How to install 45 | 46 | Sourcesync plugin can be found at http://plugins.jetbrains.com/plugin/7374?pr=idea_ce 47 | 48 | Details about how to install a plugin from JetBrains plugin repository can be found at: 49 | http://www.jetbrains.com/idea/webhelp/installing-updating-and-uninstalling-repository-plugins.html 50 | 51 | ## Getting started 52 | 53 | 1. Launch the IDE and install the plugin. 54 | 2. Restart the IDE. 55 | 3. Configure one or more remote sync configurations to target the remote environment where 56 | you would like to transfer your changes. To create or edit your sync configurations you would have to: 57 | 1. locate the **Sourcesync Configurations** combo box in the IntelliJ's main toolbar panel. 58 | ![sync_selector_on_main_toolbar](https://github.com/fioan89/sourcesync/assets/1479167/930d2f7c-7a2b-40fb-bed3-2f67852d1697) 59 | 60 | 2. Click the combo box drop down icon and then hit the **Edit Sourcesync Configurations** button. 61 | 3. In the new **Sourcesync Remote Configurations** dialog, click **+** on the toolbar or press `Alt + Insert`. 62 | The list shows the **SCP&SSH** templates available for configuration. 63 | ![sync_configuration_dialog](https://github.com/fioan89/sourcesync/assets/1479167/9dddbcb3-44ab-4f71-a702-34257f11db9f) 64 | 65 | 4. Specify the sync configuration name in the **Name** field. This name will be shown in the list of the available sync configurations. 66 | 5. Fill in the connection details like host, username and password or certificate, depending on the desired authentication type. 67 | 6. Specify the remote base path (excluding the project name) where your files will be transferred. 68 | 7. Apply the changes and close the dialog. 69 | 4. From the **Sourcesync Configurations** combo box select a sync configuration as primary target 70 | ![sync_configuration_selector](https://github.com/fioan89/sourcesync/assets/1479167/4bda6dda-3706-441b-9d35-e42b04e24b10) 71 | 72 | 6. Select one or more files, right click, and in the context menu select **Sync selected files to target name**. Alternatively, press `Ctrl + Shift + F2` 73 | 7. Alternatively only the file under edit (focused editor) could be transferred by right click in the editor, and in the context 74 | menu select **Sync this file to target name** or just press `Ctrl + Shift + F1` 75 | 7. A third option is to sync all **Git** changed files by right click in the editor or **Project** toolbar, and then select **Sync changed files to target name**. Alternatively, press `Ctrl + Shift + F3` 76 | 77 | ## FAQ 78 | 79 | 1. Where are my files transferred? 80 | 81 | Files are transferred on the remote host selected as the main target in the **Sourcesync Configurations** combo box. **Sourcesync** keeps the remote project structure similar 82 | to the local one, except the project's base path which will be replaced on the remote target with the **Workspace** configuration value. 83 | 84 | For example, say the **Workspace** remote path is configured to `/home/ifaur/workspace`, and your local project is placed in `C:\\Users\\ifaur\\IdeaProjects\\my-awesome-project` 85 | then a local file placed in `src\\main\\kotlin\\com\\mypackage\\MyFile.kt` will be transferred to `/home/ifaur/workspace/my-awesome-project/src/main/kotlin/com/mypackage/MyFile.kt` 86 | 87 | 2. Where are the sync configurations stored? 88 | 89 | **Sourcesync** keeps sync configurations per project, and it stores its data in the project's `.idea/sourcesync.xml` 90 | 91 | 3. Where are passwords and certificate passphrases stored? 92 | 93 | **Sourcesync** makes use of IntelliJ's own credential store framework to securely save sensitive data. **IntelliJ IDEA** does not have its own password store. It uses either the native password management system or KeePass. 94 | 95 | 4. There are errors when trying to load or persist sync configurations. What do I do next? 96 | 97 | You can simply remove `.idea/sourcesync.xml` from the project's folder and restart IntelliJ. You will have to reconfigure your sync targets. 98 | 99 | ## License 100 | 101 | **Sourcesync** is licensed under MIT License. Please take a look at the *LICENSE* file for more informations. 102 | 103 | ## Issues 104 | 105 | Bugs can be reported at https://github.com/fioan89/sourcesync/issues 106 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit 84 | 85 | APP_NAME="Gradle" 86 | APP_BASE_NAME=${0##*/} 87 | 88 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 89 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | 142 | # Increase the maximum file descriptors if we can. 143 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 144 | case $MAX_FD in #( 145 | max*) 146 | MAX_FD=$( ulimit -H -n ) || 147 | warn "Could not query maximum file descriptor limit" 148 | esac 149 | case $MAX_FD in #( 150 | '' | soft) :;; #( 151 | *) 152 | ulimit -n "$MAX_FD" || 153 | warn "Could not set maximum file descriptor limit to $MAX_FD" 154 | esac 155 | fi 156 | 157 | # Collect all arguments for the java command, stacking in reverse order: 158 | # * args from the command line 159 | # * the main class name 160 | # * -classpath 161 | # * -D...appname settings 162 | # * --module-path (only if needed) 163 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 164 | 165 | # For Cygwin or MSYS, switch paths to Windows format before running java 166 | if "$cygwin" || "$msys" ; then 167 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 168 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 169 | 170 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 171 | 172 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 173 | for arg do 174 | if 175 | case $arg in #( 176 | -*) false ;; # don't mess with options #( 177 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 178 | [ -e "$t" ] ;; #( 179 | *) false ;; 180 | esac 181 | then 182 | arg=$( cygpath --path --ignore --mixed "$arg" ) 183 | fi 184 | # Roll the args list around exactly as many times as the number of 185 | # args, so each arg winds up back in the position where it started, but 186 | # possibly modified. 187 | # 188 | # NB: a `for` loop captures its iteration list before it begins, so 189 | # changing the positional parameters here affects neither the number of 190 | # iterations, nor the values presented in `arg`. 191 | shift # remove old arg 192 | set -- "$@" "$arg" # push replacement arg 193 | done 194 | fi 195 | 196 | # Collect all arguments for the java command; 197 | # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of 198 | # shell script including quotes and variable substitutions, so put them in 199 | # double quotes to make sure that they get re-expanded; and 200 | # * put everything else in single quotes, so that it's not re-expanded. 201 | 202 | set -- \ 203 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 204 | -classpath "$CLASSPATH" \ 205 | org.gradle.wrapper.GradleWrapperMain \ 206 | "$@" 207 | 208 | # Use "xargs" to parse quoted args. 209 | # 210 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 211 | # 212 | # In Bash we could simply go: 213 | # 214 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 215 | # set -- "${ARGS[@]}" "$@" 216 | # 217 | # but POSIX shell has neither arrays nor command substitution, so instead we 218 | # post-process each arg (as a line of input to sed) to backslash-escape any 219 | # character that might be a shell metacharacter, then use eval to reverse 220 | # that process (while maintaining the separation between arguments), and wrap 221 | # the whole thing up as a single "set" statement. 222 | # 223 | # This will of course break if any of these variables contains a newline or 224 | # an unmatched quote. 225 | # 226 | 227 | eval "set -- $( 228 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 229 | xargs -n1 | 230 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 231 | tr '\n' ' ' 232 | )" '"$@"' 233 | 234 | exec "$JAVACMD" "$@" 235 | -------------------------------------------------------------------------------- /src/main/kotlin/org/wavescale/sourcesync/action/NewUIConnectionConfigurationSelector.kt: -------------------------------------------------------------------------------- 1 | package org.wavescale.sourcesync.action 2 | 3 | import com.intellij.ide.ui.laf.darcula.ui.ToolbarComboWidgetUiSizes 4 | import com.intellij.openapi.actionSystem.ActionGroup 5 | import com.intellij.openapi.actionSystem.ActionManager 6 | import com.intellij.openapi.actionSystem.ActionUpdateThread 7 | import com.intellij.openapi.actionSystem.AnAction 8 | import com.intellij.openapi.actionSystem.AnActionEvent 9 | import com.intellij.openapi.actionSystem.DefaultActionGroup 10 | import com.intellij.openapi.actionSystem.Presentation 11 | import com.intellij.openapi.actionSystem.Separator 12 | import com.intellij.openapi.actionSystem.ToggleAction 13 | import com.intellij.openapi.actionSystem.Toggleable 14 | import com.intellij.openapi.actionSystem.ex.CustomComponentAction 15 | import com.intellij.openapi.actionSystem.impl.ActionButtonWithText 16 | import com.intellij.openapi.components.service 17 | import com.intellij.openapi.project.DumbAware 18 | import com.intellij.openapi.project.Project 19 | import com.intellij.openapi.ui.popup.JBPopup 20 | import com.intellij.openapi.ui.popup.JBPopupFactory 21 | import com.intellij.openapi.ui.popup.ListPopup 22 | import com.intellij.ui.RetrievableIcon 23 | import com.intellij.ui.components.JBList 24 | import com.intellij.ui.icons.IconReplacer 25 | import com.intellij.ui.popup.PopupFactoryImpl 26 | import com.intellij.util.ui.JBDimension 27 | import com.intellij.util.ui.JBInsets 28 | import com.intellij.util.ui.JBUI 29 | import java.awt.Component 30 | import java.awt.Graphics 31 | import java.awt.Insets 32 | import javax.swing.Icon 33 | import javax.swing.JComponent 34 | import javax.swing.SwingConstants 35 | import org.wavescale.sourcesync.SourceSyncIcons 36 | import org.wavescale.sourcesync.SourcesyncBundle 37 | import org.wavescale.sourcesync.factory.ConnectionConfig 38 | import org.wavescale.sourcesync.services.SyncRemoteConfigurationsService 39 | import org.wavescale.sourcesync.services.SyncStatusService 40 | 41 | /*** 42 | * Inspired from IntelliJ RedesignedRunConfigurationSelector. 43 | */ 44 | class NewUIConnectionConfigurationSelector : CustomTogglePopupAction(), CustomComponentAction, DumbAware { 45 | private val syncStatusService = service() 46 | override fun getActionUpdateThread() = ActionUpdateThread.BGT 47 | override fun displayTextInToolbar() = true 48 | override fun getActionGroup(e: AnActionEvent): ActionGroup? { 49 | val project = e.project ?: return null 50 | return createActionGroup(project) 51 | } 52 | 53 | private fun createActionGroup(project: Project): ActionGroup { 54 | val syncConfigurationsService = project.service() 55 | return DefaultActionGroup().apply { 56 | add(Separator.create(SourcesyncBundle.message("sourcesyncConfigurations"))) 57 | syncConfigurationsService.allConnectionNames().forEach { 58 | add(SourceSyncConfigAction(syncConfigurationsService, it)) 59 | } 60 | 61 | add(Separator.create()) 62 | add(ActionManager.getInstance().getAction("actionSourceSyncMenu")) 63 | } 64 | } 65 | 66 | override fun createPopup( 67 | actionGroup: ActionGroup, 68 | e: AnActionEvent, 69 | disposeCallback: () -> Unit 70 | ): ListPopup { 71 | return object : PopupFactoryImpl.ActionGroupPopup( 72 | null, 73 | actionGroup, 74 | e.dataContext, 75 | false, 76 | false, 77 | true, 78 | false, 79 | disposeCallback, 80 | 30, 81 | null, 82 | null 83 | ) { 84 | 85 | init { 86 | (list as? JBList<*>)?.setExpandableItemsEnabled(false) 87 | } 88 | 89 | override fun shouldBeShowing(value: Any?) = true 90 | } 91 | } 92 | 93 | override fun createCustomComponent(presentation: Presentation, place: String): JComponent { 94 | return object : ActionButtonWithText(this, presentation, place, { 95 | JBUI.size(16, JBUI.CurrentTheme.RunWidget.toolbarHeight()) 96 | }) { 97 | override fun getMargins(): Insets = JBInsets(0, 10, 0, 6) 98 | override fun iconTextSpace(): Int = ToolbarComboWidgetUiSizes.gapAfterLeftIcons 99 | override fun shallPaintDownArrow() = true 100 | override fun getDownArrowIcon(): Icon = PreparedIcon(super.getDownArrowIcon()) 101 | 102 | override fun updateUI() { 103 | super.updateUI() 104 | updateFont() 105 | } 106 | 107 | fun updateFont() { 108 | font = JBUI.CurrentTheme.RunWidget.configurationSelectorFont() 109 | } 110 | 111 | }.also { 112 | it.foreground = JBUI.CurrentTheme.RunWidget.FOREGROUND 113 | it.setHorizontalTextAlignment(SwingConstants.LEFT) 114 | it.updateFont() 115 | } 116 | } 117 | 118 | override fun update(e: AnActionEvent) { 119 | super.update(e) 120 | 121 | val syncConfigurationsService = e.project?.service() 122 | val associationFor = syncConfigurationsService?.mainConnectionName() 123 | if (!associationFor.isNullOrBlank() && syncConfigurationsService.findFirstWithName(associationFor) == null) { 124 | ConnectionConfig.getInstance().apply { 125 | syncConfigurationsService.resetMainConnection() 126 | e.presentation.apply { 127 | isEnabled = true 128 | text = SourcesyncBundle.message("sourcesyncAddConfigurations") 129 | icon = null 130 | } 131 | } 132 | } 133 | if (associationFor.isNullOrBlank()) { 134 | e.presentation.apply { 135 | isEnabled = true 136 | text = SourcesyncBundle.message("sourcesyncAddConfigurations") 137 | icon = null 138 | } 139 | } else { 140 | e.presentation.apply { 141 | isEnabled = true 142 | text = syncConfigurationsService.mainConnectionName() 143 | 144 | icon = if (syncStatusService.isAnySyncJobRunning()) { 145 | SourceSyncIcons.ExpUI.SOURCESYNC_RUNNING 146 | } else { 147 | SourceSyncIcons.ExpUI.SOURCESYNC 148 | } 149 | } 150 | } 151 | } 152 | } 153 | 154 | class SourceSyncConfigAction(private val syncConfigurationsService: SyncRemoteConfigurationsService, private val configuration: String) : AnAction() { 155 | init { 156 | val presentation = templatePresentation 157 | presentation.setText(configuration, false) 158 | presentation.icon = SourceSyncIcons.ExpUI.SOURCESYNC 159 | } 160 | 161 | override fun getActionUpdateThread() = ActionUpdateThread.BGT 162 | 163 | override fun actionPerformed(e: AnActionEvent) { 164 | syncConfigurationsService.setMainConnection(configuration) 165 | } 166 | } 167 | 168 | internal const val MINIMAL_POPUP_WIDTH = 270 169 | 170 | abstract class CustomTogglePopupAction() : ToggleAction() { 171 | 172 | override fun isSelected(e: AnActionEvent): Boolean { 173 | return Toggleable.isSelected(e.presentation) 174 | } 175 | 176 | override fun setSelected(e: AnActionEvent, state: Boolean) { 177 | if (!state) return 178 | val component = e.inputEvent?.component as? JComponent ?: return 179 | e.project ?: return 180 | val popup = createPopup(e) 181 | popup?.showUnderneathOf(component) 182 | 183 | } 184 | 185 | private fun createPopup(e: AnActionEvent): JBPopup? { 186 | val presentation = e.presentation 187 | val actionGroup = getActionGroup(e) ?: return null 188 | val disposeCallback = { Toggleable.setSelected(presentation, false) } 189 | val popup = createPopup(actionGroup, e, disposeCallback) 190 | popup.setMinimumSize(JBDimension(MINIMAL_POPUP_WIDTH, 0)) 191 | return popup 192 | } 193 | 194 | open fun createPopup( 195 | actionGroup: ActionGroup, 196 | e: AnActionEvent, 197 | disposeCallback: () -> Unit 198 | ) = JBPopupFactory.getInstance().createActionGroupPopup(null, actionGroup, e.dataContext, false, false, false, disposeCallback, 30, null) 199 | 200 | abstract fun getActionGroup(e: AnActionEvent): ActionGroup? 201 | } 202 | 203 | private class PreparedIcon(private val width: Int, private val height: Int, private val iconFn: () -> Icon) : RetrievableIcon { 204 | constructor(icon: Icon) : this(icon.iconWidth, icon.iconHeight, { icon }) 205 | 206 | override fun paintIcon(c: Component?, g: Graphics, x: Int, y: Int) { 207 | iconFn().paintIcon(c, g, x, y) 208 | } 209 | 210 | override fun getIconWidth(): Int = width 211 | 212 | override fun getIconHeight(): Int = height 213 | 214 | override fun retrieveIcon(): Icon = iconFn() 215 | 216 | override fun replaceBy(replacer: IconReplacer): Icon { 217 | return PreparedIcon(width, height) { replacer.replaceIcon(iconFn()) } 218 | } 219 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/wavescale/sourcesync/synchronizer/SFTPFileSynchronizer.kt: -------------------------------------------------------------------------------- 1 | /** 2 | * **************************************************************************** 3 | * Copyright (c) 2014-2022 Faur Ioan-Aurel. * 4 | * All rights reserved. This program and the accompanying materials * 5 | * are made available under the terms of the MIT License * 6 | * which accompanies this distribution, and is available at * 7 | * http://opensource.org/licenses/MIT * 8 | * * 9 | * For any issues or questions send an email at: fioan89@gmail.com * 10 | * ***************************************************************************** 11 | */ 12 | package org.wavescale.sourcesync.synchronizer 13 | 14 | import com.intellij.openapi.components.service 15 | import com.intellij.openapi.diagnostic.logger 16 | import com.intellij.openapi.progress.ProgressIndicator 17 | import com.intellij.openapi.project.Project 18 | import com.jcraft.jsch.ChannelSftp 19 | import com.jcraft.jsch.JSch 20 | import com.jcraft.jsch.JSchException 21 | import com.jcraft.jsch.Session 22 | import com.jcraft.jsch.SftpProgressMonitor 23 | import java.io.File 24 | import java.io.FileInputStream 25 | import java.io.IOException 26 | import java.nio.file.Path 27 | import java.nio.file.Paths 28 | import org.wavescale.sourcesync.SourcesyncBundle 29 | import org.wavescale.sourcesync.api.Utils 30 | import org.wavescale.sourcesync.configurations.AuthenticationType 31 | import org.wavescale.sourcesync.configurations.SshSyncConfiguration 32 | import org.wavescale.sourcesync.notifications.Notifier 33 | import org.wavescale.sourcesync.services.StatsService 34 | import org.wavescale.sourcesync.services.SyncStatusService 35 | 36 | 37 | /** 38 | * Build a file synchronizer from general info contained by **configuration** param. 39 | */ 40 | class SFTPFileSynchronizer(private val configuration: SshSyncConfiguration, val project: Project) : Synchronizer { 41 | private val syncStatusService = service() 42 | private val statsService = service() 43 | 44 | private val jsch: JSch = JSch() 45 | private var session: Session? = null 46 | 47 | private var isConnected: Boolean = false 48 | override fun connect(): Boolean { 49 | return if (!isConnected) { 50 | try { 51 | initSession() 52 | session!!.connect() 53 | isConnected = true 54 | true 55 | } catch (e: JSchException) { 56 | syncStatusService.removeRunningSync(configuration.name) 57 | Notifier.notifyError( 58 | project, 59 | SourcesyncBundle.message("ssh.upload.fail.text"), 60 | "Can't open SSH connection to ${configuration.hostname}. Reason: ${e.message}", 61 | ) 62 | false 63 | } 64 | } else true 65 | } 66 | 67 | @Throws(JSchException::class) 68 | private fun initSession() { 69 | syncStatusService.addRunningSync(configuration.name) 70 | session = jsch.getSession(configuration.username, configuration.hostname, configuration.port.toInt()) 71 | session!!.setConfig("StrictHostKeyChecking", "no") 72 | if (configuration.authenticationType == AuthenticationType.KEY_PAIR) { 73 | session!!.setConfig("PreferredAuthentications", "publickey") 74 | try { 75 | Utils.createFile(SSH_KNOWN_HOSTS) 76 | } catch (e: IOException) { 77 | syncStatusService.removeRunningSync(configuration.name) 78 | Notifier.notifyError( 79 | project, 80 | SourcesyncBundle.message("ssh.upload.fail.text"), 81 | "Could not identify nor create the SSH known hosts file at ${SCPFileSynchronizer.SSH_KNOWN_HOSTS}. Reason: ${e.message}", 82 | ) 83 | return 84 | } 85 | jsch.setKnownHosts(SSH_KNOWN_HOSTS) 86 | // add private key and passphrase if exists 87 | if (configuration.passphrase.isNullOrEmpty().not()) { 88 | jsch.addIdentity(configuration.privateKey, configuration.passphrase) 89 | } else { 90 | jsch.addIdentity(configuration.privateKey) 91 | } 92 | } else { 93 | session!!.setPassword(configuration.password) 94 | } 95 | } 96 | 97 | override fun disconnect() { 98 | try { 99 | session?.disconnect() 100 | } finally { 101 | isConnected = false 102 | syncStatusService.removeRunningSync(configuration.name) 103 | } 104 | } 105 | 106 | override fun syncFile(sourcePath: String, uploadLocation: Path, indicator: ProgressIndicator) { 107 | val preserveTimestamp = configuration.preserveTimestamps 108 | val channelSftp: ChannelSftp 109 | try { 110 | channelSftp = session!!.openChannel("sftp") as ChannelSftp 111 | channelSftp.connect() 112 | } catch (e: JSchException) { 113 | syncStatusService.removeRunningSync(configuration.name) 114 | Notifier.notifyError( 115 | project, 116 | SourcesyncBundle.message("ssh.upload.fail.text"), 117 | "An error was encountered while trying to open a SSH connection to ${configuration.hostname}. Reason: ${e.message}", 118 | ) 119 | return 120 | } 121 | 122 | if (!channelSftp.absoluteDirExists(configuration.workspaceBasePath)) { 123 | syncStatusService.removeRunningSync(configuration.name) 124 | Notifier.notifyError( 125 | project, 126 | SourcesyncBundle.message("ssh.upload.fail.text"), 127 | "Remote project base path ${configuration.workspaceBasePath} does not exist or is not a directory. Please make sure the value is a valid absolute directory path on ${configuration.hostname}", 128 | ) 129 | return 130 | } 131 | 132 | channelSftp.cd(configuration.workspaceBasePath) 133 | 134 | if (!channelSftp.localDirExistsOnRemote(uploadLocation.toString())) { 135 | logger.info("Upload path $uploadLocation does not exist or is not a directory. Going to create it.") 136 | val exists = channelSftp.mkLocalDirsOnRemote(uploadLocation.toString()) 137 | if (!exists) { 138 | syncStatusService.removeRunningSync(configuration.name) 139 | Notifier.notifyError( 140 | project, 141 | SourcesyncBundle.message("ssh.upload.fail.text"), 142 | "Upload path $uploadLocation could not be created on ${configuration.hostname}" 143 | ) 144 | return 145 | } 146 | } 147 | if (!channelSftp.cdLocalDirsOnRemote(uploadLocation.toString())) { 148 | syncStatusService.removeRunningSync(configuration.name) 149 | Notifier.notifyError( 150 | project, 151 | SourcesyncBundle.message("ssh.upload.fail.text"), 152 | "Could not change directory to $uploadLocation" 153 | ) 154 | return 155 | } 156 | 157 | // upload file 158 | val toUpload = File(sourcePath) 159 | val progressMonitor: SftpProgressMonitor = SftpMonitor(toUpload.length(), indicator) 160 | try { 161 | channelSftp.put(FileInputStream(toUpload), toUpload.name, progressMonitor, ChannelSftp.OVERWRITE) 162 | if (preserveTimestamp) { 163 | val sftpATTRS = channelSftp.lstat(toUpload.name) 164 | val lastAcc = sftpATTRS.aTime 165 | // this is a messed method: if lastModified is greater than Integer.MAX_VALUE 166 | // then timestamp will not be ok. 167 | sftpATTRS.setACMODTIME(lastAcc, java.lang.Long.valueOf(toUpload.lastModified() / 1000).toInt()) 168 | channelSftp.setStat(toUpload.name, sftpATTRS) 169 | } 170 | } catch (e: Exception) { 171 | syncStatusService.removeRunningSync(configuration.name) 172 | Notifier.notifyError( 173 | project, 174 | SourcesyncBundle.message("ssh.upload.fail.text"), 175 | "Upload to ${configuration.hostname} failed. Reason: ${e.message}" 176 | ) 177 | } 178 | channelSftp.disconnect() 179 | } 180 | 181 | private inner class SftpMonitor(totalLength: Long, private val indicator: ProgressIndicator) : SftpProgressMonitor { 182 | val totalLength: Double 183 | var totalUploaded: Long 184 | 185 | init { 186 | this.totalLength = totalLength + 0.0 187 | totalUploaded = 0 188 | } 189 | 190 | override fun init(opcode: Int, src: String, dest: String, max: Long) { 191 | val remoteFile = File(dest) 192 | if (SftpProgressMonitor.PUT == opcode) { 193 | indicator.text = "Uploading...[" + remoteFile.name + "]" 194 | indicator.isIndeterminate = false 195 | } 196 | } 197 | 198 | override fun count(count: Long): Boolean { 199 | totalUploaded += count 200 | indicator.fraction = totalUploaded / totalLength 201 | // false will kill the upload 202 | return true 203 | } 204 | 205 | override fun end() { 206 | indicator.fraction = 1.0 207 | statsService.registerSuccessfulUpload() 208 | if (statsService.eligibleForDonations()) { 209 | Notifier.notifyToProDueToHighNumberOfUploads(project) 210 | } 211 | } 212 | } 213 | 214 | companion object { 215 | val SSH_KNOWN_HOSTS = Paths.get(System.getProperty("user.home"), ".ssh", "known_hosts").toString() 216 | 217 | private val logger = logger() 218 | } 219 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/wavescale/sourcesync/ui/ConnectionConfigurationComponent.kt: -------------------------------------------------------------------------------- 1 | package org.wavescale.sourcesync.ui 2 | 3 | import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory 4 | import com.intellij.openapi.project.Project 5 | import com.intellij.openapi.ui.ComboBox 6 | import com.intellij.ui.DocumentAdapter 7 | import com.intellij.ui.SimpleListCellRenderer 8 | import com.intellij.ui.dsl.builder.AlignX 9 | import com.intellij.ui.dsl.builder.COLUMNS_LARGE 10 | import com.intellij.ui.dsl.builder.COLUMNS_MEDIUM 11 | import com.intellij.ui.dsl.builder.COLUMNS_TINY 12 | import com.intellij.ui.dsl.builder.RowLayout 13 | import com.intellij.ui.dsl.builder.TopGap 14 | import com.intellij.ui.dsl.builder.bindItem 15 | import com.intellij.ui.dsl.builder.bindSelected 16 | import com.intellij.ui.dsl.builder.bindText 17 | import com.intellij.ui.dsl.builder.columns 18 | import com.intellij.ui.dsl.builder.panel 19 | import com.intellij.ui.dsl.builder.toNonNullableProperty 20 | import com.intellij.ui.dsl.builder.toNullableProperty 21 | import com.intellij.ui.layout.ComponentPredicate 22 | import com.intellij.util.ui.JBUI 23 | import org.wavescale.sourcesync.SourcesyncBundle 24 | import org.wavescale.sourcesync.configurations.AuthenticationType 25 | import org.wavescale.sourcesync.configurations.BaseSyncConfiguration 26 | import javax.swing.JLabel 27 | import javax.swing.event.DocumentEvent 28 | 29 | class ConnectionConfigurationComponent(private val project: Project, private val model: BaseSyncConfiguration, onModification: () -> Unit) { 30 | private val original = model.clone() 31 | private lateinit var cb: ComboBox 32 | 33 | var displayName = model.name 34 | private var _password = model.password 35 | private var _passPhrase = model.passphrase 36 | 37 | 38 | val component = panel { 39 | row { 40 | label(SourcesyncBundle.message("sync.editor.name.label")) 41 | textField().columns(COLUMNS_MEDIUM).bindText(model::name).applyToComponent { 42 | document.addDocumentListener(object : DocumentAdapter() { 43 | override fun textChanged(e: DocumentEvent) { 44 | displayName = text 45 | onModification() 46 | } 47 | }) 48 | } 49 | 50 | }.layout(RowLayout.LABEL_ALIGNED) 51 | 52 | separator() 53 | 54 | row { 55 | label(SourcesyncBundle.message("sync.editor.host.label")) 56 | textField().columns(COLUMNS_LARGE).bindText(model::hostname).applyToComponent { 57 | document.addDocumentListener(object : DocumentAdapter() { 58 | override fun textChanged(e: DocumentEvent) { 59 | onModification() 60 | } 61 | }) 62 | } 63 | 64 | cell(JLabel("")).resizableColumn().align(AlignX.FILL) 65 | 66 | label(SourcesyncBundle.message("sync.editor.port.label")) 67 | textField().columns(COLUMNS_TINY).bindText(model::port).applyToComponent { 68 | document.addDocumentListener(object : DocumentAdapter() { 69 | override fun textChanged(e: DocumentEvent) { 70 | onModification() 71 | } 72 | }) 73 | } 74 | }.layout(RowLayout.LABEL_ALIGNED) 75 | 76 | row { 77 | label(SourcesyncBundle.message("sync.editor.username.label")) 78 | textField().bindText(model::username).columns(COLUMNS_MEDIUM).applyToComponent { 79 | document.addDocumentListener(object : DocumentAdapter() { 80 | override fun textChanged(e: DocumentEvent) { 81 | onModification() 82 | } 83 | }) 84 | } 85 | 86 | cell(JLabel("")).resizableColumn().align(AlignX.FILL) 87 | 88 | label(SourcesyncBundle.message("sync.editor.authentication.type.label")) 89 | cb = comboBox(AuthenticationType.values().asList(), SimpleListCellRenderer.create("") { it.prettyName }) 90 | .columns(12) 91 | .bindItem(model::authenticationType.toNullableProperty()) 92 | .onChanged { 93 | onModification() 94 | } 95 | .component 96 | }.layout(RowLayout.LABEL_ALIGNED) 97 | 98 | rowsRange { 99 | row { 100 | label(SourcesyncBundle.message("sync.editor.password.label")) 101 | passwordField() 102 | .bindText(this@ConnectionConfigurationComponent::_password.toNonNullableProperty("")) 103 | .columns(COLUMNS_MEDIUM) 104 | .resizableColumn() 105 | .applyToComponent { 106 | document.addDocumentListener(object : DocumentAdapter() { 107 | override fun textChanged(e: DocumentEvent) { 108 | onModification() 109 | } 110 | }) 111 | } 112 | }.layout(RowLayout.LABEL_ALIGNED) 113 | }.visibleIf(object : ComponentPredicate() { 114 | override fun invoke() = cb.selectedItem == AuthenticationType.PASSWORD 115 | override fun addListener(listener: (Boolean) -> Unit) { 116 | cb.addActionListener { 117 | listener(cb.selectedItem == AuthenticationType.PASSWORD) 118 | } 119 | } 120 | }) 121 | 122 | rowsRange { 123 | row { 124 | label(SourcesyncBundle.message("sync.editor.private.key.label")) 125 | textFieldWithBrowseButton( 126 | SourcesyncBundle.message("sync.editor.private.key.dialog.title"), 127 | project, 128 | FileChooserDescriptorFactory.createSingleFileDescriptor() 129 | ) 130 | .bindText(model::privateKey.toNonNullableProperty("")).columns(COLUMNS_MEDIUM) 131 | .columns(COLUMNS_LARGE) 132 | .resizableColumn() 133 | .applyToComponent { 134 | textField.document.addDocumentListener(object : DocumentAdapter() { 135 | override fun textChanged(e: DocumentEvent) { 136 | onModification() 137 | } 138 | }) 139 | } 140 | }.layout(RowLayout.LABEL_ALIGNED) 141 | 142 | row { 143 | label(SourcesyncBundle.message("sync.editor.passphrase.label")) 144 | passwordField() 145 | .bindText(this@ConnectionConfigurationComponent::_passPhrase.toNonNullableProperty("")) 146 | .columns(COLUMNS_MEDIUM) 147 | .resizableColumn() 148 | .applyToComponent { 149 | document.addDocumentListener(object : DocumentAdapter() { 150 | override fun textChanged(e: DocumentEvent) { 151 | onModification() 152 | } 153 | }) 154 | } 155 | }.layout(RowLayout.LABEL_ALIGNED) 156 | }.visibleIf(object : ComponentPredicate() { 157 | override fun invoke() = cb.selectedItem == AuthenticationType.KEY_PAIR 158 | override fun addListener(listener: (Boolean) -> Unit) { 159 | cb.addActionListener { 160 | listener(cb.selectedItem == AuthenticationType.KEY_PAIR) 161 | } 162 | } 163 | }) 164 | 165 | row { 166 | label(SourcesyncBundle.message("sync.editor.workspace.label")) 167 | textField().columns(COLUMNS_LARGE).bindText(model::workspaceBasePath).applyToComponent { 168 | toolTipText = SourcesyncBundle.message("sync.editor.workspace.tooltip") 169 | document.addDocumentListener(object : DocumentAdapter() { 170 | override fun textChanged(e: DocumentEvent) { 171 | onModification() 172 | } 173 | }) 174 | } 175 | }.topGap(TopGap.MEDIUM).layout(RowLayout.LABEL_ALIGNED) 176 | 177 | row { 178 | label(SourcesyncBundle.message("sync.editor.skip.extensions.label")) 179 | textField().columns(12).bindText(model::excludedFiles).applyToComponent { 180 | document.addDocumentListener(object : DocumentAdapter() { 181 | override fun textChanged(e: DocumentEvent) { 182 | onModification() 183 | } 184 | }) 185 | } 186 | }.layout(RowLayout.INDEPENDENT) 187 | row { 188 | checkBox(SourcesyncBundle.message("sync.editor.timestamps.label")) 189 | .bindSelected(model::preserveTimestamps) 190 | .onChanged { 191 | onModification() 192 | } 193 | }.layout(RowLayout.INDEPENDENT) 194 | 195 | }.apply { 196 | border = JBUI.Borders.empty(15, 5, 0, 15) 197 | } 198 | 199 | val isModified: Boolean 200 | get() { 201 | component.apply() 202 | return original != model || original.password != _password || original.passphrase != _passPhrase 203 | } 204 | 205 | val snapshot: BaseSyncConfiguration 206 | get() { 207 | component.apply() 208 | return model.clone().apply { 209 | this.password = _password 210 | this.passphrase = _passPhrase 211 | } 212 | } 213 | } 214 | 215 | fun Collection.hasModifications(): Boolean { 216 | return this.map { it.isModified }.fold(false) { acc, isModified -> acc || isModified } 217 | } 218 | 219 | fun Collection.toConfigurationSet(): Set { 220 | return this.map { it.snapshot }.toSet() 221 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/wavescale/sourcesync/ui/ConnectionConfigurationDialog.kt: -------------------------------------------------------------------------------- 1 | package org.wavescale.sourcesync.ui 2 | 3 | import com.intellij.CommonBundle 4 | import com.intellij.icons.AllIcons 5 | import com.intellij.openapi.application.EDT 6 | import com.intellij.openapi.components.service 7 | import com.intellij.openapi.project.Project 8 | import com.intellij.openapi.ui.DialogWrapper 9 | import com.intellij.ui.IdeBorderFactory 10 | import com.intellij.ui.JBSplitter 11 | import com.intellij.ui.SideBorder 12 | import com.intellij.ui.ToolbarDecorator 13 | import com.intellij.ui.components.JBScrollPane 14 | import com.intellij.ui.components.JBViewport 15 | import com.intellij.ui.dsl.builder.Align 16 | import com.intellij.ui.dsl.builder.panel 17 | import com.intellij.ui.util.minimumWidth 18 | import com.intellij.ui.util.preferredWidth 19 | import com.intellij.util.ui.JBUI 20 | import com.intellij.util.ui.tree.TreeUtil 21 | import java.awt.BorderLayout 22 | import java.awt.event.ActionEvent 23 | import java.awt.event.KeyEvent 24 | import javax.swing.Action 25 | import javax.swing.JComponent 26 | import javax.swing.JPanel 27 | import javax.swing.KeyStroke 28 | import javax.swing.ScrollPaneConstants 29 | import javax.swing.border.Border 30 | import javax.swing.tree.DefaultMutableTreeNode 31 | import javax.swing.tree.TreeNode 32 | import javax.swing.tree.TreeSelectionModel 33 | import kotlin.math.max 34 | import kotlinx.coroutines.CoroutineScope 35 | import kotlinx.coroutines.Dispatchers 36 | import kotlinx.coroutines.launch 37 | import kotlinx.coroutines.withContext 38 | import org.wavescale.sourcesync.SourcesyncBundle 39 | import org.wavescale.sourcesync.configurations.ScpSyncConfiguration 40 | import org.wavescale.sourcesync.configurations.SshSyncConfiguration 41 | import org.wavescale.sourcesync.configurations.SyncConfigurationType 42 | import org.wavescale.sourcesync.configurations.SyncConfigurationType.SCP 43 | import org.wavescale.sourcesync.configurations.SyncConfigurationType.SFTP 44 | import org.wavescale.sourcesync.services.SyncRemoteConfigurationsService 45 | import org.wavescale.sourcesync.ui.tree.SyncConfigurationTreeRenderer 46 | import org.wavescale.sourcesync.ui.tree.SyncConnectionsTree 47 | import org.wavescale.sourcesync.ui.tree.SyncConnectionsTreeModel 48 | 49 | class ConnectionConfigurationDialog(val project: Project) : DialogWrapper(project, true) { 50 | private val cs = CoroutineScope(Dispatchers.IO) 51 | private var syncRemoteConfigurationsService = project.service() 52 | 53 | private val splitter = JBSplitter(false, "SourcesyncConnectionConfiguration.dividerProportion", 0.3f, 0.5f) 54 | 55 | private val rootNode = DefaultMutableTreeNode("Root") 56 | private val treeModel = SyncConnectionsTreeModel(rootNode) 57 | private val tree = SyncConnectionsTree(treeModel).apply { 58 | selectionModel.selectionMode = TreeSelectionModel.SINGLE_TREE_SELECTION 59 | selectionModel.addTreeSelectionListener { event -> 60 | val node = event.path.lastPathComponent as DefaultMutableTreeNode 61 | when (node.userObject) { 62 | is ConnectionConfigurationComponent -> splitter.secondComponent = (node.userObject as ConnectionConfigurationComponent).component 63 | } 64 | } 65 | } 66 | 67 | private val applyAction = ApplyAction() 68 | 69 | init { 70 | init() 71 | } 72 | 73 | override fun init() { 74 | super.init() 75 | title = SourcesyncBundle.message("connectionConfigurationDialogTitle") 76 | } 77 | 78 | private fun onConfigModifications() { 79 | cs.launch { 80 | applyAction.isEnabled = treeModel.getAllComponents().hasModifications() 81 | withContext(Dispatchers.EDT) { 82 | tree.updateUI() 83 | } 84 | } 85 | } 86 | 87 | @Suppress("UnstableApiUsage") 88 | override fun createCenterPanel(): JComponent { 89 | val leftPanel = leftSidePanel() 90 | leftPanel.border = IdeBorderFactory.createBorder(SideBorder.RIGHT) 91 | 92 | splitter.firstComponent = leftPanel 93 | splitter.secondComponent = JPanel() 94 | splitter.setHonorComponentsMinimumSize(true) 95 | splitter.putClientProperty(IS_VISUAL_PADDING_COMPENSATED_ON_COMPONENT_LEVEL_KEY, true) 96 | 97 | val d = splitter.preferredSize 98 | d.width = max(d.width, 860) 99 | d.height = max(d.height, 450) 100 | splitter.preferredSize = d 101 | 102 | initTree() 103 | return splitter 104 | } 105 | 106 | private fun initTree() { 107 | tree.apply { 108 | isRootVisible = false 109 | showsRootHandles = true 110 | } 111 | tree.cellRenderer = SyncConfigurationTreeRenderer() 112 | 113 | val firstNode = addRemoteSynConfigurations(rootNode) 114 | TreeUtil.installActions(tree) 115 | tree.registerKeyboardAction({ clickDefaultButton() }, KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), JComponent.WHEN_FOCUSED) 116 | tree.emptyText.appendText(SourcesyncBundle.message("status.text.no.sync.configurations.added")) 117 | tree.expandAll() 118 | 119 | if (firstNode != null) { 120 | tree.selectNode(firstNode) 121 | } 122 | } 123 | 124 | private fun addRemoteSynConfigurations(root: DefaultMutableTreeNode): TreeNode? { 125 | var firstNodeToSelectLater: TreeNode? = null 126 | SyncConfigurationType.values().forEach { type -> 127 | val connections = syncRemoteConfigurationsService.findAllOfType(type) 128 | if (connections.isNotEmpty()) { 129 | val typeNode = DefaultMutableTreeNode(type.prettyName) 130 | root.add(typeNode) 131 | connections.forEach { connection -> 132 | val connectionComponentNode = DefaultMutableTreeNode(ConnectionConfigurationComponent(project, connection, this::onConfigModifications)) 133 | typeNode.add(connectionComponentNode) 134 | 135 | if (firstNodeToSelectLater == null) { 136 | firstNodeToSelectLater = connectionComponentNode 137 | } 138 | } 139 | } 140 | } 141 | treeModel.reload() 142 | 143 | return firstNodeToSelectLater 144 | } 145 | 146 | override fun createContentPaneBorder(): Border = JBUI.Borders.empty() 147 | 148 | private fun leftSidePanel(): JComponent { 149 | 150 | val toolbarDecorator = ToolbarDecorator.createDecorator(tree) 151 | return panel { 152 | row { 153 | cell(JPanel(BorderLayout())) 154 | .resizableColumn() 155 | .align(Align.FILL) 156 | .applyToComponent { 157 | add(JBScrollPane(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED).apply { 158 | add(JBViewport().apply { 159 | add(tree) 160 | }) 161 | }) 162 | add( 163 | toolbarDecorator.disableUpDownActions() 164 | .setAddAction { button -> 165 | AddSyncRemoteConfigurationPopUp.create( 166 | SyncConfigurationType.values().toList(), 167 | this@ConnectionConfigurationDialog::addRemoteSyncConfiguration 168 | ).show(button.preferredPopupPoint) 169 | } 170 | .setAddIcon(AllIcons.General.Add) 171 | .setRemoveAction { 172 | val targetNode = tree.selectionPath?.lastPathComponent as DefaultMutableTreeNode 173 | val parentNode = targetNode.parent as DefaultMutableTreeNode 174 | 175 | treeModel.removeNodeFromParent(targetNode) 176 | if (parentNode.childCount == 0) { 177 | treeModel.removeNodeFromParent(parentNode) 178 | } 179 | splitter.secondComponent = JPanel() 180 | splitter.updateUI() 181 | }.createPanel() 182 | ) 183 | border = JBUI.Borders.empty() 184 | } 185 | }.resizableRow() 186 | }.apply { 187 | minimumWidth = 320 188 | preferredWidth = 350 189 | } 190 | } 191 | 192 | private fun addRemoteSyncConfiguration(syncConfigurationType: SyncConfigurationType) { 193 | val connectionTypeNode: DefaultMutableTreeNode 194 | val connectionNodeToAdd: DefaultMutableTreeNode 195 | when (syncConfigurationType) { 196 | SFTP -> { 197 | connectionTypeNode = treeModel.getOrCreateNodeFor(SFTP) 198 | connectionNodeToAdd = DefaultMutableTreeNode(ConnectionConfigurationComponent(project, SshSyncConfiguration(), this::onConfigModifications)) 199 | } 200 | 201 | SCP -> { 202 | connectionTypeNode = treeModel.getOrCreateNodeFor(SCP) 203 | connectionNodeToAdd = DefaultMutableTreeNode(ConnectionConfigurationComponent(project, ScpSyncConfiguration(), this::onConfigModifications)) 204 | } 205 | } 206 | connectionTypeNode.add(connectionNodeToAdd) 207 | treeModel.reload() 208 | tree.selectNode(connectionNodeToAdd) 209 | } 210 | 211 | override fun createActions(): Array { 212 | return arrayOf(okAction, applyAction, cancelAction) 213 | } 214 | 215 | override fun doOKAction() { 216 | applyChanges() 217 | super.doOKAction() 218 | } 219 | 220 | private fun applyChanges() { 221 | syncRemoteConfigurationsService.clear() 222 | syncRemoteConfigurationsService.addAll(treeModel.getAllComponents().toConfigurationSet()) 223 | } 224 | 225 | private inner class ApplyAction : DialogWrapperAction(CommonBundle.getApplyButtonText()) { 226 | 227 | init { 228 | isEnabled = false 229 | } 230 | 231 | override fun doAction(e: ActionEvent?) { 232 | applyChanges() 233 | isEnabled = false 234 | } 235 | } 236 | 237 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/wavescale/sourcesync/synchronizer/SCPFileSynchronizer.kt: -------------------------------------------------------------------------------- 1 | /** 2 | * **************************************************************************** 3 | * Copyright (c) 2014-2022 Faur Ioan-Aurel. * 4 | * All rights reserved. This program and the accompanying materials * 5 | * are made available under the terms of the MIT License * 6 | * which accompanies this distribution, and is available at * 7 | * http://opensource.org/licenses/MIT * 8 | * * 9 | * For any issues or questions send an email at: fioan89@gmail.com * 10 | * ***************************************************************************** 11 | */ 12 | package org.wavescale.sourcesync.synchronizer 13 | 14 | import com.intellij.openapi.components.service 15 | import com.intellij.openapi.progress.ProgressIndicator 16 | import com.intellij.openapi.project.Project 17 | import com.jcraft.jsch.ChannelExec 18 | import com.jcraft.jsch.JSch 19 | import com.jcraft.jsch.JSchException 20 | import com.jcraft.jsch.Session 21 | import java.io.File 22 | import java.io.FileInputStream 23 | import java.io.IOException 24 | import java.io.InputStream 25 | import java.nio.file.Path 26 | import java.nio.file.Paths 27 | import org.wavescale.sourcesync.SourcesyncBundle 28 | import org.wavescale.sourcesync.api.Utils 29 | import org.wavescale.sourcesync.configurations.AuthenticationType 30 | import org.wavescale.sourcesync.configurations.ScpSyncConfiguration 31 | import org.wavescale.sourcesync.notifications.Notifier 32 | import org.wavescale.sourcesync.services.StatsService 33 | import org.wavescale.sourcesync.services.SyncStatusService 34 | 35 | class SCPFileSynchronizer(private val configuration: ScpSyncConfiguration, val project: Project) : Synchronizer { 36 | private val syncStatusService = service() 37 | private val statsService = service() 38 | private val jsch: JSch = JSch() 39 | private var session: Session? = null 40 | 41 | private var isConnected: Boolean = false 42 | 43 | override fun connect(): Boolean { 44 | return if (!isConnected) { 45 | try { 46 | initSession() 47 | session!!.connect() 48 | isConnected = true 49 | true 50 | } catch (e: JSchException) { 51 | syncStatusService.removeRunningSync(configuration.name) 52 | Notifier.notifyError( 53 | project, 54 | SourcesyncBundle.message("scp.upload.fail.title"), 55 | "Can't open SCP connection to ${configuration.hostname}. Reason: ${e.message}", 56 | ) 57 | false 58 | } 59 | } else true 60 | } 61 | 62 | @Throws(JSchException::class) 63 | private fun initSession() { 64 | syncStatusService.addRunningSync(configuration.name) 65 | session = jsch.getSession( 66 | configuration.username, configuration.hostname, 67 | configuration.port.toInt() 68 | ) 69 | session!!.setConfig("StrictHostKeyChecking", "no") 70 | if (configuration.authenticationType == AuthenticationType.KEY_PAIR) { 71 | session!!.setConfig("PreferredAuthentications", "publickey") 72 | try { 73 | Utils.createFile(SSH_KNOWN_HOSTS) 74 | } catch (e: IOException) { 75 | syncStatusService.removeRunningSync(configuration.name) 76 | Notifier.notifyError( 77 | project, 78 | SourcesyncBundle.message("scp.upload.fail.title"), 79 | "Could not identify nor create the SSH known hosts file at $SSH_KNOWN_HOSTS. Reason: ${e.message}", 80 | ) 81 | return 82 | } 83 | jsch.setKnownHosts(SSH_KNOWN_HOSTS) 84 | // add private key and passphrase if exists 85 | if (configuration.passphrase.isNullOrEmpty().not()) { 86 | jsch.addIdentity(configuration.privateKey, configuration.passphrase) 87 | } else { 88 | jsch.addIdentity(configuration.privateKey) 89 | } 90 | } else { 91 | session!!.setPassword(configuration.password) 92 | } 93 | } 94 | 95 | override fun disconnect() { 96 | try { 97 | session?.disconnect() 98 | } finally { 99 | isConnected = false 100 | syncStatusService.removeRunningSync(configuration.name) 101 | } 102 | } 103 | 104 | /** 105 | * Uploads the given file to the remote target. 106 | * 107 | * @param src a `String` representing a file path to be uploaded. This is a relative path 108 | * to project base path. 109 | * @param uploadLocation a `String` representing a location path on the remote target 110 | * where the source will be uploaded. 111 | */ 112 | override fun syncFile(src: String, uploadLocation: Path, indicator: ProgressIndicator) { 113 | val srcAsFile = File(src) 114 | var remotePath = Paths 115 | .get(configuration.workspaceBasePath) 116 | .resolve(uploadLocation) 117 | .pathStringLike(configuration.workspaceBasePath) 118 | 119 | var command = "scp " + (if (configuration.preserveTimestamps) "-p" else "") + " -t -C " 120 | if (srcAsFile.isFile) { 121 | remotePath = Paths 122 | .get(remotePath) 123 | .resolve(srcAsFile.name) 124 | .pathStringLike(configuration.workspaceBasePath) 125 | command += remotePath 126 | } 127 | try { 128 | val channel = session!!.openChannel("exec") 129 | (channel as ChannelExec).setCommand(command) 130 | 131 | // get I/O streams for remote scp 132 | val out = channel.getOutputStream() 133 | val inputStream = channel.getInputStream() 134 | channel.connect() 135 | if (checkAck(inputStream, this::onChannelConnectError) != 0) { 136 | return 137 | } 138 | indicator.isIndeterminate = false 139 | indicator.text = "Uploading...[" + srcAsFile.name + "]" 140 | if (configuration.preserveTimestamps) { 141 | command = "T " + srcAsFile.lastModified() / 1000 + " 0" 142 | // The access time should be sent here, 143 | // but it is not accessible with JavaAPI ;-< 144 | command += " " + (srcAsFile.lastModified() / 1000) + " 0\n" 145 | out.write(command.toByteArray()) 146 | out.flush() 147 | if (checkAck(inputStream, this::onPreservingTimestampsError) != 0) { 148 | return 149 | } 150 | } 151 | // send "C0644 filesize filename", where filename should not include '/' 152 | val filesize = srcAsFile.length() 153 | command = "C0644 $filesize " 154 | command += Paths.get(src).fileName.toString() 155 | command += "\n" 156 | out.write(command.toByteArray()) 157 | out.flush() 158 | if (checkAck(inputStream, this::onSendingFileModesError) != 0) { 159 | return 160 | } 161 | 162 | // send content of finalSourcePath 163 | val fis = FileInputStream(src) 164 | var totalUploaded = 0.0 165 | val buf = ByteArray(1024) 166 | while (true) { 167 | val len = fis.read(buf, 0, buf.size) 168 | if (len <= 0) break 169 | out.write(buf, 0, len) //out.flush(); 170 | totalUploaded += len.toDouble() 171 | indicator.fraction = totalUploaded / filesize 172 | } 173 | fis.close() 174 | // send '\0' 175 | buf[0] = 0 176 | out.write(buf, 0, 1) 177 | out.flush() 178 | if (checkAck(inputStream, this::onSendFileContentError) != 0) { 179 | return 180 | } 181 | out.close() 182 | channel.disconnect() 183 | statsService.registerSuccessfulUpload() 184 | if (statsService.eligibleForDonations()) { 185 | Notifier.notifyToProDueToHighNumberOfUploads(project) 186 | } 187 | } catch (e: Exception) { 188 | syncStatusService.removeRunningSync(configuration.name) 189 | Notifier.notifyError( 190 | project, 191 | SourcesyncBundle.message("scp.upload.fail.title"), 192 | "Upload to ${configuration.hostname} failed. Reason: ${e.message}", 193 | ) 194 | } 195 | } 196 | 197 | private fun onChannelConnectError(errorCode: AckError, reason: String) { 198 | when (errorCode) { 199 | AckError.ERROR -> Notifier.notifyError( 200 | project, 201 | SourcesyncBundle.message("scp.upload.fail.title"), 202 | SourcesyncBundle.message("scp.upload.fail.channel.connect.error.message", configuration.hostname, reason) 203 | ) 204 | 205 | AckError.FATAL_ERROR -> Notifier.notifyError( 206 | project, 207 | SourcesyncBundle.message("scp.upload.fail.title"), 208 | SourcesyncBundle.message("scp.upload.fail.channel.connect.fatal.error.message", configuration.hostname, reason) 209 | ) 210 | 211 | AckError.UNKNOWN -> Unit 212 | } 213 | } 214 | 215 | private fun onPreservingTimestampsError(errorCode: AckError, reason: String) { 216 | when (errorCode) { 217 | AckError.ERROR -> Notifier.notifyError( 218 | project, 219 | SourcesyncBundle.message("scp.upload.fail.title"), 220 | SourcesyncBundle.message("scp.upload.fail.preserve.timestamps.error.message", reason) 221 | ) 222 | 223 | AckError.FATAL_ERROR -> Notifier.notifyError( 224 | project, 225 | SourcesyncBundle.message("scp.upload.fail.title"), 226 | SourcesyncBundle.message("scp.upload.fail.preserve.timestamps.fatal.error.message", reason) 227 | ) 228 | 229 | AckError.UNKNOWN -> Unit 230 | } 231 | } 232 | 233 | private fun onSendingFileModesError(errorCode: AckError, reason: String) { 234 | when (errorCode) { 235 | AckError.ERROR -> Notifier.notifyError( 236 | project, 237 | SourcesyncBundle.message("scp.upload.fail.title"), 238 | SourcesyncBundle.message("scp.upload.fail.file.mode.error.message", configuration.hostname, reason) 239 | ) 240 | 241 | AckError.FATAL_ERROR -> Notifier.notifyError( 242 | project, 243 | SourcesyncBundle.message("scp.upload.fail.title"), 244 | SourcesyncBundle.message("scp.upload.fail.file.mode.fatal.error.message", configuration.hostname, reason) 245 | ) 246 | 247 | AckError.UNKNOWN -> Unit 248 | } 249 | } 250 | 251 | private fun onSendFileContentError(errorCode: AckError, reason: String) { 252 | when (errorCode) { 253 | AckError.ERROR -> Notifier.notifyError( 254 | project, 255 | SourcesyncBundle.message("scp.upload.fail.title"), 256 | SourcesyncBundle.message("scp.upload.fail.file.content.error.message", configuration.hostname, reason) 257 | ) 258 | 259 | AckError.FATAL_ERROR -> Notifier.notifyError( 260 | project, 261 | SourcesyncBundle.message("scp.upload.fail.title"), 262 | SourcesyncBundle.message("scp.upload.fail.file.content.fatal.error.message", configuration.hostname, reason) 263 | ) 264 | 265 | AckError.UNKNOWN -> Unit 266 | } 267 | } 268 | 269 | @Throws(IOException::class) 270 | /** 271 | * Reads the server response (ack) for a client command 272 | */ 273 | private fun checkAck(inStream: InputStream, onError: (errorCode: AckError, reason: String) -> Unit): Int { 274 | val b = inStream.read() 275 | // b may be 0 for success, 276 | // 1 for error, 277 | // 2 for fatal error, 278 | // -1 279 | if (b == 0) return b 280 | if (b == -1) return b 281 | 282 | val sb = StringBuilder() 283 | var c: Int 284 | do { 285 | c = inStream.read() 286 | sb.append(c.toChar()) 287 | } while (c != '\n'.code) 288 | syncStatusService.removeRunningSync(configuration.name) 289 | onError(AckError.from(b), sb.toString()) 290 | return b 291 | } 292 | 293 | private enum class AckError(private val code: Int) { 294 | ERROR(1), FATAL_ERROR(2), UNKNOWN(3); 295 | 296 | companion object { 297 | infix fun from(value: Int) = AckError.values().firstOrNull { it.code == value } ?: UNKNOWN 298 | } 299 | } 300 | 301 | companion object { 302 | val SSH_KNOWN_HOSTS = Paths.get(System.getProperty("user.home"), ".ssh", "known_hosts").toString() 303 | } 304 | } --------------------------------------------------------------------------------