[kotlin] KTIJ-35016 K2 Move: support file suffixes for expect/actual

Files in different source sets by convention should have different names.
Mostly this is due to JVM limitations: file facade classes with the same
name can lead to clashes.

Move changes:
— File destination field is treated as file name in the common source set for expect/actual move
— Iff expect/actual move setting is enabled, platform file suffixes are added in different source sets

UI changes:
— Show expect/actual setting only for declaration movement with expects/actuals
— Add an explanation for the file destination in case of expect/actual move
— Add a warning for platform file suffix used for common file name
— Disable dialog button when trying to move multiple declarations to the same file

GitOrigin-RevId: 9c294d146366f55eed10fffc8e23cbefb8ebe0cf
This commit is contained in:
Pavel Kirpichenkov
2025-11-05 21:17:30 +00:00
committed by intellij-monorepo-bot
parent c9d485e340
commit ed4d01ff3b
43 changed files with 512 additions and 99 deletions
@@ -48,6 +48,9 @@ visibility.combo.title=&Visibility:
moveInstanceMethod.select.an.instance.parameter=Select an &instance expression:
choose.destination.package=Choose Destination Package
create.explicit.package.warning=Implicit package ''{0}'' will become explicit
move.added.kmp.suffixes.warning.0=Platform suffix ''.{0}'' will be copied across multiple source sets, consider removing it
move.added.kmp.suffixes.info=Multiplatform file suffixes will be added automatically
tooltip.text.move.kmp.target.file=Base file name in the common source set. Platform source sets will receive suffixes in accordance with the multiplatform conventions.
interface.name.prompt=&Interface name:
package.for.new.interface=&Package for new interface:
pull.up.members.to=P&ull up members of {0} to:
@@ -889,8 +889,8 @@ parameter.name.is.invalid=Parameter name ''{0}'' is invalid
parameter.type.is.invalid=Parameter type ''{0}'' is invalid
parameter.types.are.not.denotable=Cannot extract method since following types are not denotable in the target scope:
refactoring.move.non.kotlin.file=Target must be a Kotlin file
refactoring.class.destination=Class destination
refactoring.file.destination=File destination
refactoring.class.destination=Class destination:
refactoring.file.destination=File destination:
refactoring.cannot.find.target.class=Cannot find target class
return.type.is.invalid=Return type is invalid
searching.usages.of.0.parameter=Searching usages of ''{0}''parameter
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
</content>
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="FacetManager">
<facet type="kotlin-language" name="Kotlin">
<configuration version="3" platform="Common (experimental) " useProjectSettings="false">
<compilerSettings />
<compilerArguments>
<option name="multiPlatform" value="true" />
</compilerArguments>
</configuration>
</facet>
</component>
</module>
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="module" module-name="commonMain" scope="COMPILE" />
</component>
<component name="FacetManager">
<facet type="kotlin-language" name="Kotlin">
<configuration version="3" platform="JavaScript " useProjectSettings="false">
<implements>commonMain</implements>
<compilerSettings />
<compilerArguments>
<option name="multiPlatform" value="true" />
</compilerArguments>
</configuration>
</facet>
</component>
</module>
@@ -0,0 +1,6 @@
package bar
fun existingInTargetPackageJs() {
}
actual fun foo(): Int = 1
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="module" module-name="commonMain" scope="COMPILE" />
</component>
<component name="FacetManager">
<facet type="kotlin-language" name="Kotlin">
<configuration version="3" platform="JVM 1.8" useProjectSettings="false">
<implements>commonMain</implements>
<compilerSettings />
<compilerArguments>
<option name="multiPlatform" value="true" />
</compilerArguments>
</configuration>
</facet>
</component>
</module>
@@ -0,0 +1,6 @@
package bar
actual fun fo<caret>o(): Int = 0
fun regularJvm() {
}
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
</content>
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="FacetManager">
<facet type="kotlin-language" name="Kotlin">
<configuration version="3" platform="Common (experimental) " useProjectSettings="false">
<compilerSettings />
<compilerArguments>
<option name="multiPlatform" value="true" />
</compilerArguments>
</configuration>
</facet>
</component>
</module>
@@ -0,0 +1,6 @@
package foo
expect fun foo(): Int
fun regularCommon() {
}
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="module" module-name="commonMain" scope="COMPILE" />
</component>
<component name="FacetManager">
<facet type="kotlin-language" name="Kotlin">
<configuration version="3" platform="JavaScript " useProjectSettings="false">
<implements>commonMain</implements>
<compilerSettings />
<compilerArguments>
<option name="multiPlatform" value="true" />
</compilerArguments>
</configuration>
</facet>
</component>
</module>
@@ -0,0 +1,6 @@
package foo
actual fun foo(): Int = 1
fun regularJs() {
}
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="module" module-name="commonMain" scope="COMPILE" />
</component>
<component name="FacetManager">
<facet type="kotlin-language" name="Kotlin">
<configuration version="3" platform="JVM 1.8" useProjectSettings="false">
<implements>commonMain</implements>
<compilerSettings />
<compilerArguments>
<option name="multiPlatform" value="true" />
</compilerArguments>
</configuration>
</facet>
</component>
</module>
@@ -0,0 +1,6 @@
package foo
actual fun fo<caret>o(): Int = 0
fun reg<caret>ularJvm() {
}
@@ -0,0 +1,8 @@
{
"mainFile": "jvmMain/src/foo/base.jvm.kt",
"targetPackage": "bar",
"withRuntime": "true",
"modulesWithCommonRuntime": ["commonMain"],
"modulesWithRuntime": ["jvmMain"],
"modulesWithJsRuntime": ["jsMain"]
}
@@ -58,6 +58,8 @@ jvm_library(
"//plugins/kotlin/base/facet",
"//platform/observable",
"//java/openapi:java",
"@lib//:kotlinc-analysis-api-platform-interface",
"//plugins/kotlin/base/external-build-system:kotlin-base-externalSystem",
]
)
@@ -115,6 +117,8 @@ jvm_library(
"//plugins/kotlin/base/facet",
"//platform/observable",
"//java/openapi:java",
"@lib//:kotlinc-analysis-api-platform-interface",
"//plugins/kotlin/base/external-build-system:kotlin-base-externalSystem",
]
)
### auto-generated section `build intellij.kotlin.refactorings.move` end
@@ -68,5 +68,7 @@
<orderEntry type="module" module-name="intellij.kotlin.base.facet" />
<orderEntry type="module" module-name="intellij.platform.ide.observable" />
<orderEntry type="module" module-name="intellij.java" />
<orderEntry type="library" name="kotlinc.analysis-api-platform-interface" level="project" />
<orderEntry type="module" module-name="intellij.kotlin.base.externalSystem" />
</component>
</module>
@@ -1,6 +1,7 @@
<idea-plugin visibility="internal" package="org.jetbrains.kotlin.idea.k2.refactoring.move">
<dependencies>
<module name="intellij.kotlin.searching"/>
<module name="intellij.kotlin.base.externalSystem"/>
</dependencies>
<extensions defaultExtensionNs="com.intellij">
<refactoring.moveHandler
@@ -0,0 +1,18 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package org.jetbrains.kotlin.idea.k2.refactoring.move.ui
import com.intellij.openapi.Disposable
import com.intellij.openapi.observable.properties.ObservableBooleanProperty
import com.intellij.openapi.observable.properties.ObservableProperty
open class ConstantObservableProperty<T>(private val value: T) : ObservableProperty<T> {
final override fun get(): T = value
final override fun afterChange(listener: (T) -> Unit) {}
final override fun afterChange(parentDisposable: Disposable?, listener: (T) -> Unit) {}
}
class ConstantBooleanObservableProperty(value: Boolean) : ConstantObservableProperty<Boolean>(value), ObservableBooleanProperty {
override fun afterSet(parentDisposable: Disposable?, listener: () -> Unit) {}
override fun afterReset(parentDisposable: Disposable?, listener: () -> Unit) {}
}
@@ -4,7 +4,8 @@ package org.jetbrains.kotlin.idea.k2.refactoring.move.ui
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.observable.properties.AtomicBooleanProperty
import com.intellij.openapi.observable.properties.MutableBooleanProperty
import com.intellij.openapi.observable.properties.ObservableBooleanProperty
import com.intellij.openapi.observable.properties.ObservableProperty
import com.intellij.openapi.observable.util.transform
import com.intellij.openapi.project.Project
import com.intellij.openapi.roots.ProjectFileIndex
import com.intellij.openapi.util.NlsContexts
@@ -33,15 +34,13 @@ import org.jetbrains.kotlin.name.FqName
import org.jetbrains.kotlin.psi.KtElement
import org.jetbrains.kotlin.psi.KtFile
import org.jetbrains.kotlin.psi.KtNamedDeclaration
import org.jetbrains.kotlin.psi.psiUtil.hasActualModifier
import org.jetbrains.kotlin.psi.psiUtil.isExpectDeclaration
import org.jetbrains.kotlin.psi.psiUtil.parentsWithSelf
import org.jetbrains.kotlin.utils.addToStdlib.firstIsInstanceOrNull
/**
* @see K2MoveDescriptor
*/
sealed class K2MoveModel(observableUiSettings: ObservableUiSettings) : K2MoveModelObservableSettings {
sealed class K2MoveModel(private val observableUiSettings: ObservableUiSettings) : K2MoveModelObservableSettings {
init {
observableUiSettings.registerK2MoveModelSettings(this)
}
@@ -76,6 +75,10 @@ sealed class K2MoveModel(observableUiSettings: ObservableUiSettings) : K2MoveMod
}
open fun buildPanel(panel: Panel): Unit = with(panel) {
val mppDeclarationSelectedObservable = observableUiSettings.mppDeclarationsSelectedObservable.transform { isSelected ->
isSelected && inSourceRoot
}
row {
panel {
searchForText.createComboBox(this)
@@ -83,21 +86,25 @@ sealed class K2MoveModel(observableUiSettings: ObservableUiSettings) : K2MoveMod
}.align(AlignY.TOP + AlignX.LEFT)
panel {
searchInComments.createComboBox(this)
mppDeclarations.createComboBox(this, inSourceRoot && this@K2MoveModel is Declarations)
mppDeclarations.createComboBox(
panel = this,
visible = mppDeclarationSelectedObservable,
enabled = mppDeclarationSelectedObservable,
)
}.align(AlignY.TOP + AlignX.RIGHT)
}
}
override val mppDeclarationsObservable: ObservableBooleanProperty
override val mppDeclarationsSettingObservable: ObservableProperty<Boolean>
get() = mppDeclarations.observableProperty
override val searchForTextObservable: ObservableBooleanProperty
override val searchForTextSettingObservable: ObservableProperty<Boolean>
get() = searchForText.observableProperty
override val searchInCommentsObservable: ObservableBooleanProperty
override val searchInCommentsSettingObservable: ObservableProperty<Boolean>
get() = searchInComments.observableProperty
override val searchReferencesObservable: ObservableBooleanProperty
override val searchReferencesSettingObservable: ObservableProperty<Boolean>
get() = searchReferences.observableProperty
enum class Setting(private val text: @NlsContexts.Checkbox String) {
@@ -143,19 +150,36 @@ sealed class K2MoveModel(observableUiSettings: ObservableUiSettings) : K2MoveMod
};
abstract var state: Boolean
// lazy prevents service access from constructor
// lazy to avoid service access from constructor
internal val observableProperty: MutableBooleanProperty by lazy { AtomicBooleanProperty(state) }
fun createComboBox(panel: Panel, enabled: Boolean = true) {
fun createComboBox(
panel: Panel,
enabled: Boolean = true,
visible: Boolean = true,
) {
createComboBox(
panel = panel,
enabled = ConstantBooleanObservableProperty(enabled),
visible = ConstantBooleanObservableProperty(visible)
)
}
fun createComboBox(
panel: Panel,
enabled: ObservableProperty<Boolean>,
visible: ObservableProperty<Boolean>,
) {
panel.row {
val checkBox = checkBox(text).enabled(enabled)
if (enabled) {
checkBox.bindSelected(::state)
// bind doesn't register state changes before dialog confirmation, but the UI should be updated on all state changes
checkBox.onChanged { observableProperty.set(it.isSelected) }
} else {
checkBox.selected(false)
}
val checkBox = checkBox(text).enabledIf(enabled).visibleIf(visible)
checkBox.onChanged { observableProperty.set(it.isSelected) }
// reset only is intentional
enabled.afterChange { isEnabled -> if (!isEnabled) checkBox.selected(false) }
checkBox.bindSelected(::state)
// value set in bindSelected doesn't trigger listeners
val initial = enabled.get() && state
checkBox.selected(initial)
observableProperty.set(initial)
}.layout(RowLayout.PARENT_GRID)
}
}
@@ -226,7 +250,7 @@ sealed class K2MoveModel(observableUiSettings: ObservableUiSettings) : K2MoveMod
&& name == fileName
}
if (!fileName.isValidKotlinFile()) return false
val files = source.elements.map { it.containingFile }
val files = source.elements.map { it.containingFile }.toSet()
return files.size != 1 || !(files.single() as KtFile).isTargetFile() || target.isMoveToExplicitPackage()
}
@@ -258,7 +282,7 @@ sealed class K2MoveModel(observableUiSettings: ObservableUiSettings) : K2MoveMod
val searchForReferences = if (inSourceRoot) searchReferences.state else false
val searchForText = searchForText.state
val searchInComments = searchInComments.state
if (mppDeclarations.state && declarations.any { it.isExpectDeclaration() || it.hasActualModifier() }) {
if (mppDeclarations.state && declarations.any { it.isExpectOrActual() }) {
val descriptors = declarations.flatMap { elem ->
ExpectActualUtils.withExpectedActuals(elem).filterIsInstance<KtNamedDeclaration>()
}.groupBy { elem ->
@@ -340,7 +364,7 @@ sealed class K2MoveModel(observableUiSettings: ObservableUiSettings) : K2MoveMod
|| declarationsFromFiles.isEmpty()
|| (targetContainer is PsiDirectory && targetContainer.getPackage() == null) -> {
// this move can contain foreign language files
val source = K2MoveSourceModel.FileSource(elementsToMove.toFileElements().toSet())
val source = K2MoveSourceModel.FileSource(elementsToMove.toFileElements().toSet(), observableUiSettings)
val target = if (targetContainer is PsiDirectory) {
val pkg = targetContainer.getFqNameWithImplicitPrefixOrRoot()
K2MoveTargetModel.SourceDirectory(
@@ -360,7 +384,7 @@ sealed class K2MoveModel(observableUiSettings: ObservableUiSettings) : K2MoveMod
}
targetContainer is KtFile || targetContainer.isSingleClassContainer() || isSingleFileMove(elementsToMove) -> {
val source = K2MoveSourceModel.ElementSource(declarationsFromFiles.toSet())
val source = K2MoveSourceModel.ElementSource(declarationsFromFiles.toSet(), observableUiSettings)
val targetFile = targetContainer?.containingFile
val target = if (targetFile is KtFile) {
K2MoveTargetModel.File(targetFile, observableUiSettings)
@@ -2,6 +2,9 @@
package org.jetbrains.kotlin.idea.k2.refactoring.move.ui
import com.intellij.openapi.actionSystem.ex.ActionUtil
import com.intellij.openapi.observable.properties.AtomicBooleanProperty
import com.intellij.openapi.observable.properties.MutableBooleanProperty
import com.intellij.openapi.observable.properties.ObservableBooleanProperty
import com.intellij.platform.backend.presentation.TargetPresentation
import com.intellij.psi.PsiElement
import com.intellij.psi.PsiFileSystemItem
@@ -13,6 +16,7 @@ import com.intellij.ui.dsl.builder.BottomGap
import com.intellij.ui.dsl.builder.Panel
import com.intellij.ui.dsl.builder.TopGap
import com.intellij.ui.list.createTargetPresentationRenderer
import org.jetbrains.annotations.ApiStatus
import org.jetbrains.kotlin.idea.base.resources.KotlinBundle
import org.jetbrains.kotlin.idea.k2.refactoring.move.descriptor.K2MoveSourceDescriptor
import org.jetbrains.kotlin.idea.refactoring.memberInfo.AbstractKotlinMemberInfoModel
@@ -23,15 +27,25 @@ import org.jetbrains.kotlin.psi.KtDeclarationContainer
import org.jetbrains.kotlin.psi.KtNamedDeclaration
import javax.swing.JComponent
sealed interface K2MoveSourceModel<T : PsiElement> {
val elements: Set<T>
sealed class K2MoveSourceModel<T : PsiElement>(
observableUiSettings: ObservableUiSettings
): K2SourceModelObservableSettings {
abstract val elements: Set<T>
fun toDescriptor(): K2MoveSourceDescriptor<T>?
abstract fun toDescriptor(): K2MoveSourceDescriptor<T>?
fun buildPanel(panel: Panel, onError: (String?, JComponent) -> Unit, revalidateButtons: () -> Unit)
abstract fun buildPanel(panel: Panel, onError: (String?, JComponent) -> Unit, revalidateButtons: () -> Unit)
class FileSource(fsItems: Set<PsiFileSystemItem>) : K2MoveSourceModel<PsiFileSystemItem> {
init {
observableUiSettings.registerK2SourceModelSettings(this)
}
class FileSource(
fsItems: Set<PsiFileSystemItem>,
observableUiSettings: ObservableUiSettings,
) : K2MoveSourceModel<PsiFileSystemItem>(observableUiSettings) {
override var elements: Set<PsiFileSystemItem> = fsItems
override val mppDeclarationsSelectedObservable: ObservableBooleanProperty = ConstantBooleanObservableProperty(false)
override fun toDescriptor(): K2MoveSourceDescriptor.FileSource = K2MoveSourceDescriptor.FileSource(elements)
@@ -47,9 +61,8 @@ sealed interface K2MoveSourceModel<T : PsiElement> {
}
panel.group(RefactoringBundle.message("move.files.group")) {
lateinit var list: JBList<TargetPresentation>
row {
list = cell(JBList(CollectionListModel(presentableFiles)).apply {
cell(JBList(CollectionListModel(presentableFiles)).apply {
cellRenderer = createTargetPresentationRenderer { it }
}).align(Align.FILL).component
}.resizableRow()
@@ -57,9 +70,14 @@ sealed interface K2MoveSourceModel<T : PsiElement> {
}
}
class ElementSource(declarations: Set<KtNamedDeclaration>) : K2MoveSourceModel<KtNamedDeclaration> {
class ElementSource(
declarations: Set<KtNamedDeclaration>,
observableUiSettings: ObservableUiSettings,
) : K2MoveSourceModel<KtNamedDeclaration>(observableUiSettings) {
override var elements: Set<KtNamedDeclaration> = declarations
private set
override val mppDeclarationsSelectedObservable: MutableBooleanProperty =
AtomicBooleanProperty(hasExpectOrActualElements())
private lateinit var memberSelectionPanel: KotlinMemberSelectionPanel
@@ -83,6 +101,7 @@ sealed interface K2MoveSourceModel<T : PsiElement> {
val table = memberSelectionPanel.table
table.addMemberInfoChangeListener {
elements = table.selectedMemberInfos.map { it.member }.toSet()
mppDeclarationsSelectedObservable.set(hasExpectOrActualElements())
if (elements.isEmpty()) {
onError(KotlinBundle.message("text.no.elements.to.move.are.selected"), memberSelectionPanel.table)
} else {
@@ -116,6 +135,9 @@ sealed interface K2MoveSourceModel<T : PsiElement> {
isChecked = elementsToMove.contains(declaration)
}
}
private fun hasExpectOrActualElements(): Boolean =
elements.any { it.isExpectOrActual() }
}
}
@@ -6,9 +6,11 @@ import com.intellij.ide.util.DirectoryChooser
import com.intellij.ide.util.TreeJavaClassChooserDialog
import com.intellij.openapi.application.ModalityState
import com.intellij.openapi.application.ReadAction
import com.intellij.openapi.observable.properties.AtomicBooleanProperty
import com.intellij.openapi.observable.properties.MutableBooleanProperty
import com.intellij.openapi.observable.properties.PropertyGraph
import com.intellij.openapi.observable.properties.*
import com.intellij.openapi.observable.util.and
import com.intellij.openapi.observable.util.equalsTo
import com.intellij.openapi.observable.util.operation
import com.intellij.openapi.observable.util.transform
import com.intellij.openapi.roots.ProjectFileIndex
import com.intellij.openapi.ui.TextFieldWithBrowseButton
import com.intellij.openapi.util.registry.Registry
@@ -33,37 +35,42 @@ import org.jetbrains.kotlin.idea.core.util.toPsiDirectory
import org.jetbrains.kotlin.idea.k2.refactoring.move.descriptor.K2MoveTargetDescriptor
import org.jetbrains.kotlin.idea.refactoring.ui.KotlinDestinationFolderComboBox
import org.jetbrains.kotlin.idea.refactoring.ui.KotlinFileChooserDialog
import org.jetbrains.kotlin.idea.util.sourceRoot
import org.jetbrains.kotlin.name.FqName
import org.jetbrains.kotlin.psi.KtClassOrObject
import org.jetbrains.kotlin.psi.KtFile
import javax.swing.JComponent
import javax.swing.event.DocumentEvent
sealed interface K2MoveTargetModel {
val directory: PsiDirectory
sealed class K2MoveTargetModel(
protected val observableUiSettings: ObservableUiSettings
) : K2TargetModelObservableSettings {
init {
observableUiSettings.registerK2TargetModelSettings(this)
}
val pkgName: FqName
abstract val directory: PsiDirectory
val explicitPkgMoveFqName: FqName?
abstract val pkgName: FqName
val observableUiSettings: ObservableUiSettings
abstract val explicitPkgMoveFqName: FqName?
/**
* Creates a [K2MoveTargetDescriptor] from this model.
* @param kmpSourceRoot source KMP directory that corresponds to the base [directory] in a different source set.
* `null` for non expect-actual moves.
*/
fun toDescriptor(kmpSourceRoot: PsiDirectory? = null): K2MoveTargetDescriptor
abstract fun toDescriptor(kmpSourceRoot: PsiDirectory? = null): K2MoveTargetDescriptor
fun buildPanel(panel: Panel, onError: (String?, JComponent) -> Unit, revalidateButtons: () -> Unit)
abstract fun buildPanel(panel: Panel, onError: (String?, JComponent) -> Unit, revalidateButtons: () -> Unit)
@ApiStatus.Internal
abstract class SourceDirectoryChooser(
override var pkgName: FqName,
override var directory: PsiDirectory,
override val explicitPkgMoveFqName: FqName?,
override val observableUiSettings: ObservableUiSettings,
) : K2MoveTargetModel {
observableUiSettings: ObservableUiSettings,
) : K2MoveTargetModel(observableUiSettings) {
private val initialDirectory = directory
protected lateinit var pkgChooser: PackageNameReferenceEditorCombo
@@ -163,20 +170,24 @@ sealed interface K2MoveTargetModel {
override fun buildPanel(panel: Panel, onError: (String?, JComponent) -> Unit, revalidateButtons: () -> Unit) {
panel.installPkgChooser(onError, revalidateButtons)
}
override val sourceSetSuffix: ObservableProperty<String?>
get() = ConstantObservableProperty(null)
}
@ApiStatus.Internal
abstract class FileChooser(
fileName: String,
@set:ApiStatus.Internal
var fileName: String,
pkg: FqName,
directory: PsiDirectory,
explicitPkgMoveFqName: FqName?,
observableUiSettings: ObservableUiSettings,
) : SourceDirectoryChooser(pkg, directory, explicitPkgMoveFqName, observableUiSettings) {
var fileName: String = fileName
protected set
private var selectedFile: KtFile? = null
private val mppSuffixObservableProperty: AtomicProperty<String?> =
AtomicProperty(findFileSuffix(fileName))
override fun updateDirectory(onError: (String?, JComponent) -> Unit, revalidateButtons: () -> Unit) {
super.updateDirectory(onError, revalidateButtons)
@@ -220,10 +231,21 @@ sealed interface K2MoveTargetModel {
} else {
onError(null, fileChooser)
}
mppSuffixObservableProperty.set(findFileSuffix(fileName))
revalidateButtons()
}
})
}
override val sourceSetSuffix: ObservableProperty<String?>
get() = mppSuffixObservableProperty
private fun findFileSuffix(fileName: String): String? {
val sourceRootVirtualFile = directory.sourceRoot ?: return null
val sourceRootPsiDir = sourceRootVirtualFile.toPsiDirectory(directory.project) ?: return null
val suffix = findSourceSetNameStem(sourceRootPsiDir)
return if (fileName.endsWith(".$suffix.kt")) suffix else null
}
}
class File(
@@ -274,7 +296,8 @@ sealed interface K2MoveTargetModel {
return if (destinationTargetType == MoveTargetType.CLASS && selectedClass != null) {
K2MoveTargetDescriptor.ClassOrObject(selectedClass)
} else {
K2MoveTargetDescriptor.File(fileName, pkgName, kmpSourceRoot ?: directory)
val fileNameWithKmpSuffixHandled = findSuffixedFileName(fileName, kmpSourceRoot)
K2MoveTargetDescriptor.File(fileNameWithKmpSuffixHandled, pkgName, kmpSourceRoot ?: directory)
}
}
@@ -322,43 +345,92 @@ sealed interface K2MoveTargetModel {
})
}
private fun Panel.installTargetFileSelector(
onError: (String?, JComponent) -> Unit,
revalidateButtons: () -> Unit,
installTitleElement: Row.() -> Unit,
) {
val iconInfoObservableProperty = observableUiSettings.mppDeclarationsSettingObservable
.and(observableUiSettings.sourceSetSuffix.transform { it.isNullOrBlank() })
val iconWarningObservableProperty = observableUiSettings.mppDeclarationsSettingObservable
.and(observableUiSettings.sourceSetSuffix.transform { !it.isNullOrBlank() })
row {
panel {
row {
installTitleElement()
icon(AllIcons.General.ContextHelp).align(AlignX.RIGHT).gap(RightGap.SMALL).applyToComponent {
toolTipText = RefactoringBundle.message("tooltip.text.move.kmp.target.file")
}.visibleIf(observableUiSettings.mppDeclarationsSettingObservable)
}
}
installFileChooser(onError, revalidateButtons)
}.layout(RowLayout.LABEL_ALIGNED)
row("") {
icon(AllIcons.General.Warning).align(AlignX.LEFT).gap(RightGap.SMALL)
.visibleIf(iconWarningObservableProperty)
icon(AllIcons.General.Information).align(AlignX.LEFT).gap(RightGap.SMALL)
.visibleIf(iconInfoObservableProperty)
val warningLabel = label("").bindText(
operation(iconInfoObservableProperty, iconWarningObservableProperty) { info, warning ->
when {
info -> RefactoringBundle.message("move.added.kmp.suffixes.info")
warning -> RefactoringBundle.message(
"move.added.kmp.suffixes.warning.0",
observableUiSettings.sourceSetSuffix.get().orEmpty(),
)
else -> ""
}
}
).visibleIf(observableUiSettings.mppDeclarationsSettingObservable)
// placeholder to avoid resizing on changes in the settings
label("").visibleIf(observableUiSettings.mppDeclarationsSettingObservable.equalsTo(false))
.applyToComponent { preferredSize = warningLabel.component.preferredSize }
}
}
private fun Panel.installTargetSelectionRadioGroup(
onError: (String?, JComponent) -> Unit,
revalidateButtons: () -> Unit,
) {
buttonsGroup(indent = false) {
installTargetFileSelector(onError, revalidateButtons) {
radioButton(KotlinBundle.message("refactoring.file.destination"), MoveTargetType.FILE)
.onChanged {
destinationTargetType = MoveTargetType.FILE
fileChooser.isEnabled = true
pkgChooser.isEnabled = true
destinationChooser.isEnabled = true
classChooser.isEnabled = false
revalidateButtons()
}
}
row {
radioButton(KotlinBundle.message("refactoring.class.destination"), MoveTargetType.CLASS)
.onChanged {
destinationTargetType = MoveTargetType.CLASS
fileChooser.isEnabled = false
pkgChooser.isEnabled = false
destinationChooser.isEnabled = false
classChooser.isEnabled = true
revalidateButtons()
}
installClassTargetChooser(onError, revalidateButtons)
}.layout(RowLayout.LABEL_ALIGNED)
}.bind(::destinationTargetType.toMutableProperty())
}
override fun buildPanel(
panel: Panel,
onError: (String?, JComponent) -> Unit,
revalidateButtons: () -> Unit
) {
panel.installPkgChooser(onError, revalidateButtons)
if (Registry.`is`("kotlin.move.show.move.to.class")) {
panel.buttonsGroup(indent = true) {
panel.row {
radioButton(KotlinBundle.message("refactoring.file.destination"), MoveTargetType.FILE)
.onChanged {
destinationTargetType = MoveTargetType.FILE
fileChooser.isEnabled = true
pkgChooser.isEnabled = true
destinationChooser.isEnabled = true
classChooser.isEnabled = false
revalidateButtons()
}
installFileChooser(onError, revalidateButtons)
}
panel.row {
radioButton(KotlinBundle.message("refactoring.class.destination"), MoveTargetType.CLASS)
.onChanged {
destinationTargetType = MoveTargetType.CLASS
fileChooser.isEnabled = false
pkgChooser.isEnabled = false
destinationChooser.isEnabled = false
classChooser.isEnabled = true
revalidateButtons()
}
installClassTargetChooser(onError, revalidateButtons)
}
}.bind(::destinationTargetType.toMutableProperty())
panel.installTargetSelectionRadioGroup(onError, revalidateButtons)
} else {
panel.row {
installFileChooser(onError, revalidateButtons)
panel.installTargetFileSelector(onError, revalidateButtons) {
label(KotlinBundle.message("label.text.file"))
}
}
}
@@ -2,32 +2,58 @@
package org.jetbrains.kotlin.idea.k2.refactoring.move.ui
import com.intellij.openapi.observable.properties.ObservableBooleanProperty
import com.intellij.openapi.observable.properties.ObservableProperty
interface ObservableUiSettings : K2MoveModelObservableSettings {
interface ObservableUiSettings : K2MoveModelObservableSettings, K2SourceModelObservableSettings, K2TargetModelObservableSettings {
fun registerK2MoveModelSettings(settings: K2MoveModelObservableSettings)
fun registerK2SourceModelSettings(settings: K2SourceModelObservableSettings)
fun registerK2TargetModelSettings(settings: K2TargetModelObservableSettings)
}
interface K2MoveModelObservableSettings {
val searchReferencesObservable: ObservableBooleanProperty
val searchForTextObservable: ObservableBooleanProperty
val searchInCommentsObservable: ObservableBooleanProperty
val mppDeclarationsObservable: ObservableBooleanProperty
val searchReferencesSettingObservable: ObservableProperty<Boolean>
val searchForTextSettingObservable: ObservableProperty<Boolean>
val searchInCommentsSettingObservable: ObservableProperty<Boolean>
val mppDeclarationsSettingObservable: ObservableProperty<Boolean>
}
interface K2SourceModelObservableSettings {
val mppDeclarationsSelectedObservable: ObservableProperty<Boolean>
}
interface K2TargetModelObservableSettings {
val sourceSetSuffix: ObservableProperty<String?>
}
internal class ObservableUiSettingsImpl : ObservableUiSettings {
private lateinit var k2MoveModelSettings: K2MoveModelObservableSettings
private lateinit var k2TargetModelSettings: K2TargetModelObservableSettings
private lateinit var k2SourceModelSettings: K2SourceModelObservableSettings
override val searchReferencesObservable: ObservableBooleanProperty
get() = k2MoveModelSettings.searchReferencesObservable
override val searchForTextObservable: ObservableBooleanProperty
get() = k2MoveModelSettings.searchForTextObservable
override val searchInCommentsObservable: ObservableBooleanProperty
get() = k2MoveModelSettings.searchInCommentsObservable
override val mppDeclarationsObservable: ObservableBooleanProperty
get() = k2MoveModelSettings.mppDeclarationsObservable
override val searchReferencesSettingObservable: ObservableProperty<Boolean>
get() = k2MoveModelSettings.searchReferencesSettingObservable
override val searchForTextSettingObservable: ObservableProperty<Boolean>
get() = k2MoveModelSettings.searchForTextSettingObservable
override val searchInCommentsSettingObservable: ObservableProperty<Boolean>
get() = k2MoveModelSettings.searchInCommentsSettingObservable
override val mppDeclarationsSettingObservable: ObservableProperty<Boolean>
get() = k2MoveModelSettings.mppDeclarationsSettingObservable
override val mppDeclarationsSelectedObservable: ObservableProperty<Boolean>
get() = k2SourceModelSettings.mppDeclarationsSelectedObservable
override val sourceSetSuffix: ObservableProperty<String?>
get() = k2TargetModelSettings.sourceSetSuffix
override fun registerK2MoveModelSettings(settings: K2MoveModelObservableSettings) {
k2MoveModelSettings = settings
}
override fun registerK2SourceModelSettings(settings: K2SourceModelObservableSettings) {
k2SourceModelSettings = settings
}
override fun registerK2TargetModelSettings(settings: K2TargetModelObservableSettings) {
k2TargetModelSettings = settings
}
}
@@ -7,13 +7,18 @@ import com.intellij.psi.PsiDirectory
import com.intellij.psi.PsiElement
import com.intellij.psi.PsiFileSystemItem
import com.intellij.psi.util.parentOfType
import org.jetbrains.kotlin.analysis.api.projectStructure.KaModuleProvider
import org.jetbrains.kotlin.idea.KotlinLanguage
import org.jetbrains.kotlin.idea.base.externalSystem.KotlinBuildSystemFacade
import org.jetbrains.kotlin.idea.base.util.module
import org.jetbrains.kotlin.idea.core.getImplicitPackagePrefix
import org.jetbrains.kotlin.name.FqName
import org.jetbrains.kotlin.psi.KtClassOrObject
import org.jetbrains.kotlin.psi.KtElement
import org.jetbrains.kotlin.psi.KtFile
import org.jetbrains.kotlin.psi.KtNamedDeclaration
import org.jetbrains.kotlin.psi.psiUtil.hasActualModifier
import org.jetbrains.kotlin.psi.psiUtil.isExpectDeclaration
internal fun String.isValidKotlinFile(): Boolean {
return endsWith(KotlinLanguage.INSTANCE.associatedFileType?.defaultExtension ?: return false) || endsWith(".kts")
@@ -65,3 +70,29 @@ internal fun findExplicitPkgMoveFqName(elementsToMove: List<PsiElement>): FqName
internal fun K2MoveTargetModel.isMoveToExplicitPackage(): Boolean =
pkgName == explicitPkgMoveFqName
internal fun KtNamedDeclaration.isExpectOrActual(): Boolean =
isExpectDeclaration() || hasActualModifier()
internal fun findSourceSetNameStem(kmpSourceRoot: PsiDirectory): String? {
val project = kmpSourceRoot.project
val kaModule = KaModuleProvider.getModule(project, kmpSourceRoot, useSiteModule = null)
val dependsOnDependencies = kaModule.directDependsOnDependencies
if (dependsOnDependencies.isEmpty()) return null
val workspaceModule = kmpSourceRoot.module ?: return null
val sourceSet = KotlinBuildSystemFacade.getInstance().findSourceSet(workspaceModule)
val fullSourceSetName = sourceSet?.name ?: workspaceModule.name.split(".").last()
val sourceSetNameStem = kmpSourceSetDefaultSuffixes.firstOrNull { fullSourceSetName.endsWith(it) }
?.let { sourceSetNameSuffix -> fullSourceSetName.removeSuffix(sourceSetNameSuffix) }
?: fullSourceSetName
return sourceSetNameStem
}
internal fun findSuffixedFileName(baseFileName: String, kmpSourceRoot: PsiDirectory?): String {
if (kmpSourceRoot == null) return baseFileName
val sourceSetSuffix = findSourceSetNameStem(kmpSourceRoot) ?: return baseFileName
val baseWithoutKt = baseFileName.substringBeforeLast(".kt")
return "$baseWithoutKt.$sourceSetSuffix.kt"
}
private val kmpSourceSetDefaultSuffixes = listOf("Main", "Test")
@@ -41,8 +41,8 @@ abstract class AbstractK2CheckDescriptorMultiModuleMoveTest : KotlinMultiFileTes
config: JsonObject
) {
allowAnalysisOnEdt {
val elementAtCaret = elementsAtCaret.single()
val editor = elementAtCaret.findExistingEditor()
val firstElementAtCaret = elementsAtCaret.first()
val editor = firstElementAtCaret.findExistingEditor()
val moveModel = K2MoveModel.create(elementsAtCaret.toTypedArray<PsiElement>(), null, editor)
?: error("Failed to create move model")
configureMoveModel(moveModel)
@@ -54,6 +54,20 @@ class K2CheckDescriptorMultiModuleMoveTest : AbstractK2CheckDescriptorMultiModul
)
}
@TestMetadata("moveActualAndRegularFunctionConventionalSourceSetNames")
@Throws(Exception::class)
fun testMoveActualAndRegularFunctionConventionalSourceSetNames() {
doTest(
"moveActualAndRegularFunctionConventionalSourceSetNames/moveActualAndRegularFunctionConventionalSourceSetNames.test",
configureMoveModel = { moveModel ->
setAllMoveSettingsOn(moveModel)
setNewTargetPackageInSameRoot(moveModel, FqName("bar"))
setTargetFile(moveModel, "target.kt")
},
checkMoveDescriptor = { moveOperationDescriptor -> checkNoMoveOutsideSourceRoot(moveOperationDescriptor) },
)
}
@TestMetadata("moveActualClass")
@Throws(Exception::class)
fun testMoveActualClass() {
@@ -112,17 +126,28 @@ class K2CheckDescriptorMultiModuleMoveTest : AbstractK2CheckDescriptorMultiModul
targetModel.directory = packageSubdirectory
}
/**
* Imitates target file change through the file chooser.
*/
private fun setTargetFile(moveModel: K2MoveModel, fileName: String) {
val moveTarget = moveModel.target
if (moveTarget !is K2MoveTargetModel.FileChooser)
throw AssertionError("Unexpected move target model: ${moveTarget::class.simpleName}")
moveTarget.fileName = fileName
}
private fun checkNoMoveOutsideSourceRoot(moveOperationDescriptor: K2MoveOperationDescriptor<*>) {
moveOperationDescriptor.moveDescriptors.forEach { descriptor ->
val element = descriptor.source.elements.singleOrNull() ?: error("Single source element to move is expected")
val elementSourceDir = element.containingFile.sourceRoot ?: error("Can't find source root for element")
val targetRoot = descriptor.target.baseDirectory.sourceRoot ?: error("Can't find source root for target")
assert(elementSourceDir == targetRoot) {
"""Element was unexpectedly moved outside of its source root:
descriptor.source.elements.forEach { element ->
val elementSourceDir = element.containingFile.sourceRoot ?: error("Can't find source root for element")
val targetRoot = descriptor.target.baseDirectory.sourceRoot ?: error("Can't find source root for target")
assert(elementSourceDir == targetRoot) {
"""Element was unexpectedly moved outside of its source root:
|${element.text}
|Element root: ${elementSourceDir.path}
|Target root: ${targetRoot.path}
|""".trimMargin()
}
}
}
}