diff --git a/CHANGELOG.md b/CHANGELOG.md
index 41dc35a0..ebc3823f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,4 @@
+- Added Move Kotlin File and Directory refactoring: move the active `.kt` file from **Refactor → Move Kotlin File...**, or right-click a folder and choose **Move Kotlin Directory...** to recursively move its Kotlin files while retaining the selected directory and nested structure below a selected source root/package; matching package directives and supported Kotlin code references are updated, and **Undo Last Refactoring** restores the original locations and contents.
- Improved Kotlin Inline Variable: local `var` declarations with one initializer and no subsequent writes can now be inlined from either a declaration or selected usage; assignments and increment/decrement writes are rejected before changing the source with the conflicting expression identified.
- Improved Kotlin Introduce Type Alias: selecting a complete generic type now creates a concrete alias, while selecting only its type constructor creates a parameterized alias that preserves nested and qualified type structures and each same-file usage's own type arguments; star projections and local type parameters that cannot form a valid top-level alias are rejected.
- Improved Kotlin hierarchy-aware refactorings: Pull Members Up, Push Members Down, and compatible IDEA refactoring paths now discover Kotlin override chains and direct supertypes across all source files in the active Maven or Gradle build; Java/library and full IDEA-index conflict scenarios remain unavailable.
diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml
index 0377183e..def966ab 100644
--- a/CoreImpl/pom.xml
+++ b/CoreImpl/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.231-SNAPSHOT
+ 0.11.245-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-core-impl
diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml
index e17d2cc2..e6ce9bbf 100644
--- a/KotlinCompilerCliBase/pom.xml
+++ b/KotlinCompilerCliBase/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.231-SNAPSHOT
+ 0.11.245-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-compiler-cli-base
diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml
index fee74bee..99a782c5 100644
--- a/KotlinFixesImpl/pom.xml
+++ b/KotlinFixesImpl/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.231-SNAPSHOT
+ 0.11.245-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-fixes-impl
diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml
index 645015f7..718cc9bd 100644
--- a/KotlinFormatter/pom.xml
+++ b/KotlinFormatter/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.231-SNAPSHOT
+ 0.11.245-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-formatter
diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml
index e2fa4b61..f8d6137f 100644
--- a/KotlinHighlighting/pom.xml
+++ b/KotlinHighlighting/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.231-SNAPSHOT
+ 0.11.245-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-highlighting
diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml
index d5c9a59e..d611fb63 100644
--- a/KotlinIcons/pom.xml
+++ b/KotlinIcons/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.231-SNAPSHOT
+ 0.11.245-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-icons
diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml
index 37cb1163..99876de5 100644
--- a/KotlinIntentionUtils/pom.xml
+++ b/KotlinIntentionUtils/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.231-SNAPSHOT
+ 0.11.245-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-intention-utils
diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml
index 12190e6d..b313aaa6 100644
--- a/KotlinRefactoring/pom.xml
+++ b/KotlinRefactoring/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.231-SNAPSHOT
+ 0.11.245-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-refactoring
@@ -741,16 +741,19 @@
-
+ declaration processor because no standalone listeners are subscribed). -->
copy-kotlin-refactoring-move-k2-declarations
initialize
@@ -768,6 +771,7 @@
org/jetbrains/kotlin/idea/k2/refactoring/move/descriptor/K2MoveTargetDescriptor.kt
org/jetbrains/kotlin/idea/k2/refactoring/move/descriptor/K2MoveOperationDescriptor.kt
org/jetbrains/kotlin/idea/k2/refactoring/move/processor/K2MoveDeclarationsRefactoringProcessor.kt
+ org/jetbrains/kotlin/idea/k2/refactoring/move/processor/K2MoveFilesOrDirectoriesRefactoringProcessor.kt
org/jetbrains/kotlin/idea/k2/refactoring/move/processor/moveUtil.kt
org/jetbrains/kotlin/idea/k2/refactoring/move/processor/moveUsageUtil.kt
org/jetbrains/kotlin/idea/k2/refactoring/move/processor/moveConflictUtil.kt
@@ -1928,46 +1932,60 @@ if (k2MoveProcessor.exists()) {
log.info("Patched K2MoveDeclarationsRefactoringProcessor.kt: decoupled from BaseRefactoringProcessor.run() pipeline and IDE-only listener/UI paths")
}
-// 17. K2MoveOperationDescriptor.kt (E9.7): drop the Files-move variant (out of scope — see the
-// pom.xml comment on copy-kotlin-refactoring-move-k2-declarations) and Kotlin Multiplatform
-// expect/actual fan-out (this plugin does not support Kotlin Multiplatform, consistent with
-// ExpectActualUtils being dropped everywhere else it was pulled in, e.g. CodeInliner.kt).
+// 16a. K2MoveFilesOrDirectoriesRefactoringProcessor.kt (F4.1): retain the real K2MoveFilesHandler
+// semantics but remove the outer processor. The outer class is inseparable from IDEA's generic
+// MoveFilesOrDirectoriesProcessor (indexed ReferencesSearch, writable IntelliJ VFS, command/
+// listener lifecycle, and modal UI). In NetBeans, KaMoveFileComputer calls this handler-shaped
+// adapter while KotlinRefactoringTransaction performs physical moves and undo. This preserves
+// IDEA's Kotlin-specific package eligibility, conflict collection, package-directive rewrite,
+// declaration mapping, and K2 usage retargeting without pretending the unavailable platform
+// lifecycle is portable.
+def k2MoveFilesProcessor = new File(srcDir, 'org/jetbrains/kotlin/idea/k2/refactoring/move/processor/K2MoveFilesOrDirectoriesRefactoringProcessor.kt')
+if (k2MoveFilesProcessor.exists()) {
+ def text = k2MoveFilesProcessor.text
+ text = text.replace('import com.intellij.openapi.application.runWriteAction\n', '')
+ text = text.replace('import com.intellij.openapi.util.Ref\n', '')
+ text = text.replace('import com.intellij.refactoring.move.moveFilesOrDirectories.MoveFileHandler\n', '')
+ text = text.replace('import com.intellij.refactoring.move.moveFilesOrDirectories.MoveFilesOrDirectoriesProcessor\n', '')
+ text = text.replace('import org.jetbrains.kotlin.idea.core.getImplicitPackagePrefix\n', '')
+ text = text.replace('import org.jetbrains.kotlin.idea.k2.refactoring.move.descriptor.K2MoveOperationDescriptor\n', '')
+
+ def processorStart = text.indexOf('internal class K2MoveFilesOrDirectoriesRefactoringProcessor(')
+ def handlerStart = text.indexOf('class K2MoveFilesHandler')
+ if (processorStart >= 0 && handlerStart > processorStart) {
+ text = text.substring(0, processorStart) + text.substring(handlerStart)
+ }
+ text = text.replace('class K2MoveFilesHandler : MoveFileHandler() {',
+ 'class K2MoveFilesHandler {')
+ text = text.replace(' override fun canProcessElement(', ' fun canProcessElement(')
+ text = text.replace(' override fun findUsages(', ' fun findUsages(')
+ text = text.replace(' override fun detectConflicts(', ' fun detectConflicts(')
+ text = text.replace(' override fun prepareMovedFile(', ' fun prepareMovedFile(')
+ text = text.replace(' override fun updateMovedFile(', ' fun updateMovedFile(')
+ text = text.replace(' override fun retargetUsages(', ' fun retargetUsages(')
+ k2MoveFilesProcessor.text = text
+ log.info("Patched K2MoveFilesOrDirectoriesRefactoringProcessor.kt: retained lifecycle-free K2MoveFilesHandler only")
+}
+
+// 17. K2MoveOperationDescriptor.kt (E9.7/F4.1): preserve the Files descriptor so its upstream
+// source/target data model stays available to the lifecycle-free F4.1 adapter. The descriptor's
+// refactoringProcessor() would construct the full IDEA MoveFilesOrDirectoriesProcessor, which
+// is intentionally not compiled standalone: NetBeans owns physical VFS moves, command/undo and
+// UI. Keep the real file-move descriptor but fail explicitly if an accidental caller invokes the
+// unavailable IDEA lifecycle. Also remove Kotlin Multiplatform expect/actual fan-out, which this
+// plugin does not support (consistent with the rest of the refactoring ports).
def k2MoveOpDescriptor = new File(srcDir, 'org/jetbrains/kotlin/idea/k2/refactoring/move/descriptor/K2MoveOperationDescriptor.kt')
if (k2MoveOpDescriptor.exists()) {
def text = k2MoveOpDescriptor.text
text = text.replace('import org.jetbrains.kotlin.idea.k2.refactoring.move.processor.K2MoveFilesOrDirectoriesRefactoringProcessor\n', '')
text = text.replace('import org.jetbrains.kotlin.idea.search.ExpectActualUtils\n', '')
-
text = text.replace(
-''' class Files(
- project: Project,
- moveDescriptors: List,
- searchForText: Boolean,
- searchInComments: Boolean,
- searchReferences: Boolean,
- dirStructureMatchesPkg: Boolean,
- isMoveToExplicitPackage: Boolean,
- moveCallBack: MoveCallback? = null,
- ) : K2MoveOperationDescriptor(
- project,
- moveDescriptors,
- searchForText,
- searchInComments,
- searchReferences,
- dirStructureMatchesPkg,
- isMoveToExplicitPackage,
- moveCallBack
- ) {
- override val sourceElements: List get() = moveDescriptors.flatMap { it.source.elements }
-
- override fun refactoringProcessor(): BaseRefactoringProcessor {
+''' override fun refactoringProcessor(): BaseRefactoringProcessor {
return K2MoveFilesOrDirectoriesRefactoringProcessor(this)
- }
- }
-
- class Declarations(''',
-''' class Declarations('''
+ }''',
+''' override fun refactoringProcessor(): BaseRefactoringProcessor =
+ error("The standalone F4.1 adapter drives K2MoveFilesHandler directly; NetBeans owns the file-move lifecycle.")'''
)
text = text.replace(
@@ -1998,7 +2016,7 @@ if (k2MoveOpDescriptor.exists()) {
)
k2MoveOpDescriptor.text = text
- log.info("Patched K2MoveOperationDescriptor.kt: dropped Files-move variant and multiplatform expect/actual fan-out")
+ log.info("Patched K2MoveOperationDescriptor.kt: retained Files data descriptor, blocked IDEA lifecycle, dropped MPP fan-out")
}
// 18. K2MoveTargetDescriptor.kt (E9.7): DirectoryUtil/VfsUtilCore (com.intellij.ide.util /
@@ -2105,14 +2123,13 @@ if (k2MoveProcessorOrder.exists()) {
// org.jetbrains.kotlin.idea.base.projectStructure.kaModuleBridge.kt (KotlinRefactoring local
// sources). No text changes needed beyond what that bridge already resolves; this entry exists
// to document why moveConflictUtil.kt compiles without further patching.
-// 20. moveUsageUtil.kt (E9.7): TextOccurrencesUtil (searchInCommentsAndStrings/searchForText —
+// 20. moveUsageUtil.kt (E9.7/F4.1): TextOccurrencesUtil (searchInCommentsAndStrings/searchForText —
// opt-in, default-off "also update text mentions" scan) is not on our classpath and would
// otherwise need a whole-project text-search engine to implement for real; simplify
// findNonCodeUsages to always report no non-code usages (matches behaviour when the user
-// leaves those checkboxes off, which IDEA itself defaults to). KtFile.findUsages (the
-// whole-file-move overload, calling the now-removed K2MoveRenameUsageInfo.findExternalUsages)
-// is dead code for Move Declaration — only K2MoveDeclarationsRefactoringProcessor.getUsages()
-// is ever called, which uses the KtNamedDeclaration.findUsages overload below it.
+// leaves those checkboxes off, which IDEA itself defaults to). F4.1 restores the whole-file
+// overload and backs it with the real KotlinMoveUsageSearchService scan, instead of IDEA's
+// unavailable Java/light-class ReferencesSearch path.
def moveUsageUtilFile = new File(srcDir, 'org/jetbrains/kotlin/idea/k2/refactoring/move/processor/moveUsageUtil.kt')
if (moveUsageUtilFile.exists()) {
def text = moveUsageUtilFile.text
@@ -2133,9 +2150,17 @@ if (moveUsageUtilFile.exists()) {
K2MoveRenameUsageInfo.findExternalUsages(decl) + decl.findNonCodeUsages(searchInCommentsAndStrings, searchForText, newPkgName)
}
}''',
-'''// KtFile.findUsages (whole-file-move overload) intentionally omitted: file/directory moves are
-// out of scope for Move Declaration (see pom.xml), and it depended on the now-removed
-// K2MoveRenameUsageInfo.findExternalUsages (Java-interop-only, dropped in patch #14).'''
+'''internal fun KtFile.findUsages(
+ searchInCommentsAndStrings: Boolean,
+ searchForText: Boolean,
+ newPkgName: FqName
+): List {
+ markInternalUsages(this, this)
+ return topLevelDeclarationsToUpdate.flatMap { declaration ->
+ org.jetbrains.kotlin.idea.k2.refactoring.move.processor.usages.findMoveDeclarationUsages(declaration) +
+ declaration.findNonCodeUsages(searchInCommentsAndStrings, searchForText, newPkgName)
+ }
+}'''
)
text = text.replace(
@@ -2234,18 +2259,19 @@ private fun KtNamedDeclaration.findNonCodeUsages(
text = text.replace('import com.intellij.refactoring.rename.RenameUtil\n', '')
moveUsageUtilFile.text = text
- log.info("Patched moveUsageUtil.kt: dropped whole-file-move overload, wired findMoveDeclarationUsages, dropped TextOccurrencesUtil-based non-code-usage search")
+ log.info("Patched moveUsageUtil.kt: restored standalone whole-file Kotlin usage search and dropped TextOccurrencesUtil-based non-code search")
}
-// 21. moveUtil.kt (E9.7): K2ChangePackageDescriptor (Change Package is a separate, out-of-scope
-// feature — see pom.xml) and forcedTargetPackage/getPossiblyForcedPackageFqName (dead code:
-// only used by K2MoveFilesOrDirectoriesRefactoringProcessor, also out of scope) are removed.
+// 21. moveUtil.kt (E9.7/F4.1): K2ChangePackageDescriptor remains out of scope until F4.2. F4.1
+// restores forcedTargetPackage/getPossiblyForcedPackageFqName, but stores it directly on the
+// session/disk PsiDirectory rather than IDEA's sourceRoot project-model abstraction. NetBeans
+// has no implicit package prefixes, so the field safely scopes the chosen destination package
+// through conflict detection and the K2 handler's package rewrite.
def moveUtilFile = new File(srcDir, 'org/jetbrains/kotlin/idea/k2/refactoring/move/processor/moveUtil.kt')
if (moveUtilFile.exists()) {
def text = moveUtilFile.text
text = text.replace('import org.jetbrains.kotlin.idea.k2.refactoring.move.descriptor.K2ChangePackageDescriptor\n', '')
text = text.replace('import org.jetbrains.kotlin.idea.util.sourceRoot\n', '')
- text = text.replace('import com.intellij.openapi.util.Key\n', '')
text = text.replace('import com.intellij.openapi.vfs.VirtualFile\n', '')
text = text.replace(
@@ -2268,12 +2294,18 @@ internal fun PsiDirectory.getPossiblyForcedPackageFqName(): FqName {
val forcedPkg = parentsWithSelf.filterIsInstance().map { it.forcedTargetPackage }.firstNotNullOfOrNull { it }
return forcedPkg ?: getFqNameWithImplicitPrefixOrRoot()
}''',
-'''// forcedTargetPackage/getPossiblyForcedPackageFqName intentionally omitted: only used by
-// K2MoveFilesOrDirectoriesRefactoringProcessor (file/directory moves), which is out of scope.'''
+'''private var PsiDirectory.forcedTargetPackage: FqName? by UserDataProperty(Key.create("FORCED_TARGET_PACKAGE"))
+
+internal fun PsiDirectory.getPossiblyForcedPackageFqName(): FqName {
+ val forcedPkg = parentsWithSelf.filterIsInstance()
+ .map { it.forcedTargetPackage }
+ .firstNotNullOfOrNull { it }
+ return forcedPkg ?: getFqNameWithImplicitPrefixOrRoot()
+}'''
)
moveUtilFile.text = text
- log.info("Patched moveUtil.kt: dropped Change Package support and dead forced-target-package caching")
+ log.info("Patched moveUtil.kt: kept standalone forced-target package support; Change Package remains deferred to F4.2")
}
// 22. implicitPackagePrefixConflict.kt (E9.7): getImplicitPackagePrefix() always returns null now
diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaMoveFileComputer.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaMoveFileComputer.kt
new file mode 100644
index 00000000..0202ac56
--- /dev/null
+++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaMoveFileComputer.kt
@@ -0,0 +1,277 @@
+/*******************************************************************************
+ * Copyright 2026 nbplugins contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ *******************************************************************************/
+package io.github.nbplugins.kotlin.refactoring
+
+import com.intellij.psi.PsiDirectory
+import com.intellij.psi.PsiElement
+import com.intellij.usageView.UsageInfo
+import com.intellij.util.containers.MultiMap
+import org.jetbrains.kotlin.idea.k2.refactoring.move.KotlinMoveUsageSearchService
+import org.jetbrains.kotlin.idea.k2.refactoring.move.MoveImport
+import org.jetbrains.kotlin.idea.k2.refactoring.move.processor.K2MoveFilesHandler
+import org.jetbrains.kotlin.idea.k2.refactoring.move.processor.findMoveFileUsages
+import org.jetbrains.kotlin.idea.k2.refactoring.move.processor.prepareMovedFileForPackage
+import org.jetbrains.kotlin.name.FqName
+import org.jetbrains.kotlin.resolve.ImportPath
+import org.jetbrains.kotlin.psi.KtFile
+import org.jetbrains.kotlin.psi.KtNamedDeclaration
+import org.jetbrains.kotlin.psi.KtPsiFactory
+
+/**
+ * Discovers whether a Kotlin file can participate in the Move File refactoring.
+ *
+ * The computer deliberately separates discovery from physical filesystem mutation: NetBeans owns
+ * the latter through its refactoring transaction, while the later apply phase drives the ported K2
+ * usage/conflict logic against writable session PSI.
+ *
+ * @param ktFile writable K2-session PSI for the file selected for movement
+ */
+class KaMoveFileComputer(
+ private val ktFile: KtFile,
+) {
+
+ /** Represents the result of validating a selected Kotlin source file. */
+ sealed class Outcome {
+ /** The selected PSI has no physical path and cannot be moved by NetBeans. */
+ data object NotApplicable : Outcome()
+
+ /** Discovery failed unexpectedly. */
+ data class Error(val error: Throwable) : Outcome()
+
+ /** Discovery succeeded and [result] describes the movable source file. */
+ data class Ready(val result: KaMoveFileResult) : Outcome()
+ }
+
+ /**
+ * Validates the selected file and describes its package-update eligibility.
+ *
+ * @return [Outcome.Ready] for a physical Kotlin source file, [Outcome.NotApplicable] when its
+ * session PSI has no physical path, or [Outcome.Error] if discovery fails
+ */
+ fun compute(): Outcome = try {
+ val path = ktFile.virtualFile?.path ?: return Outcome.NotApplicable
+ Outcome.Ready(
+ KaMoveFileResult(
+ sourceFilePath = path,
+ fileName = ktFile.name,
+ packageName = ktFile.packageFqName.asString(),
+ packageMayBeUpdated = packageMatchesDirectory(path, ktFile.packageFqName.asString()),
+ )
+ )
+ } catch (error: Throwable) {
+ Outcome.Error(error)
+ }
+
+ /**
+ * Applies the ported IDEA K2 file-move semantics to [files] before NetBeans persists their
+ * physical move.
+ *
+ * The caller supplies session-owned writable PSI for the files and a real disk-backed
+ * [targetDirectory]. The adapter performs the same K2 sequence as IDEA's
+ * `MoveFilesOrDirectoriesProcessor`: find Kotlin usages, collect Kotlin conflicts, rewrite
+ * eligible package directives, create the old-to-new declaration map, and retarget Kotlin
+ * references. It deliberately does not move VFS entries; NetBeans must perform that irreversible
+ * operation through its transaction only after this method reports [ApplyOutcome.Success].
+ *
+ * @param files writable K2 PSI files selected for the move
+ * @param targetDirectory real target directory used for conflict resolution
+ * @param targetPackage package selected by NetBeans for the destination
+ * @param updateReferences whether Kotlin external references should be retargeted
+ * @return conflicts without mutation, a success containing every changed PSI file, or an error
+ */
+ fun apply(
+ files: Collection,
+ targetDirectory: PsiDirectory,
+ targetPackage: FqName,
+ updateReferences: Boolean,
+ ): ApplyOutcome = apply(
+ files.associateWith { MoveTarget(targetDirectory, targetPackage) },
+ updateReferences,
+ )
+
+ /**
+ * Applies K2 Move File semantics for sources whose final directories and packages differ.
+ *
+ * The directory Move command supplies one [MoveTarget] per source. K2 usage discovery occurs
+ * before any source is mutated and retargeting receives one aggregate old-to-new map, which is
+ * essential when two moved descendants refer to one another across relative directories.
+ *
+ * @param targets physical source PSI mapped to its final directory and declared package
+ * @param updateReferences whether supported external Kotlin references should be retargeted
+ * @return conflicts without mutation, changed texts with original snapshots, or an error
+ */
+ fun apply(targets: Map, updateReferences: Boolean): ApplyOutcome {
+ return try {
+ require(targets.isNotEmpty()) { "Move File requires at least one Kotlin file." }
+ val files = targets.keys.toList()
+ val handler = K2MoveFilesHandler()
+ // ProjectFileIndex and JavaDirectoryService cannot reconstruct the source-root-relative
+ // directory of this session's LightVirtualFiles. Calling K2MoveFilesHandler.findUsages()
+ // or needsUpdate() would therefore suppress valid work before its actual K2 semantic
+ // branch. Use the same package/directory criterion against the real path, then retain
+ // the upstream handler for package rewrite, conflict detection, declaration mapping,
+ // and reference retargeting.
+ val eligibleFiles = files.filter { file ->
+ file.virtualFile?.path?.let { path -> packageMatchesDirectory(path, file.packageFqName.asString()) } == true
+ }
+ eligibleFiles.forEach(handler::markRequiresUpdate)
+ val usages = if (updateReferences) {
+ eligibleFiles.flatMap { file ->
+ file.findMoveFileUsages(
+ searchInCommentsAndStrings = false,
+ searchForText = false,
+ targetPackage = targets.getValue(file).targetPackage,
+ )
+ }.distinct()
+ } else {
+ emptyList()
+ }
+ val conflicts = MultiMap()
+ files.groupBy { targets.getValue(it).targetDirectory }.forEach { (directory, groupedFiles) ->
+ handler.detectConflicts(conflicts, groupedFiles.toTypedArray(), usages.toTypedArray(), directory)
+ }
+ if (!conflicts.isEmpty) {
+ ApplyOutcome.Conflicts(conflicts.values().toList())
+ } else {
+ val movedImportTargets = files.flatMap { file ->
+ file.declarations.mapNotNull { declaration ->
+ (declaration as? KtNamedDeclaration)?.name?.let { name ->
+ MoveImport(file.packageFqName, name) to targets.getValue(file).targetPackage
+ }
+ }
+ }.toMap()
+ // K2 retargeting intentionally excludes import directives because mutating an import
+ // path through bindToElement() destabilizes standalone PSI. Discover direct imports
+ // separately, then rewrite their full directives below with KtPsiFactory.
+ val importOnlyUsageFiles = KotlinMoveUsageSearchService.getInstance()
+ ?.findImportingFiles(files.first().project, movedImportTargets)
+ .orEmpty()
+ val usageFiles = (filesWithUsages(usages) + importOnlyUsageFiles).distinct()
+ val originalTexts = (files + usageFiles).distinct().mapNotNull { file ->
+ file.virtualFile?.path?.let { path -> path to file.text }
+ }.toMap()
+ val oldToNew = linkedMapOf()
+ val changedFiles = linkedMapOf()
+ files.forEach { file ->
+ val target = targets.getValue(file)
+ handler.prepareMovedFileForPackage(
+ file = file,
+ targetPackage = target.targetPackage,
+ rewritePackage = file in eligibleFiles,
+ oldToNewMap = oldToNew,
+ )
+ file.virtualFile?.path?.let { path -> changedFiles[path] = file.text }
+ }
+ if (updateReferences) handler.retargetUsages(usages, oldToNew)
+ usageFiles.filterNot { it in files }.forEach { file ->
+ rewriteMovedImports(file, movedImportTargets)
+ file.virtualFile?.path?.let { path -> changedFiles[path] = file.text }
+ }
+ ApplyOutcome.Success(changedFiles, originalTexts)
+ }
+ } catch (error: Throwable) {
+ ApplyOutcome.Error(error)
+ }
+ }
+
+ /** A source's physical K2 target directory and final declared package. */
+ data class MoveTarget(val targetDirectory: PsiDirectory, val targetPackage: FqName)
+
+ /** Represents the result of applying K2-only Move File semantics. */
+ sealed class ApplyOutcome {
+ /** K2 found blocking semantic conflicts and left PSI unchanged. */
+ data class Conflicts(val messages: List) : ApplyOutcome()
+
+ /**
+ * K2 changed package directives and/or Kotlin references.
+ *
+ * @param changedFiles final texts NetBeans must persist
+ * @param originalTexts pre-K2 snapshots for the same participants, used by transaction Undo
+ */
+ data class Success(
+ val changedFiles: Map,
+ val originalTexts: Map,
+ ) : ApplyOutcome()
+
+ /** The K2 semantic operation failed. */
+ data class Error(val error: Throwable) : ApplyOutcome()
+ }
+
+ /**
+ * Retains Kotlin files whose text may change during external-usage retargeting.
+ *
+ * K2 replaces the usage PSI while rebinding it, so [UsageInfo.element] can be invalid or absent
+ * after `retargetUsages`. Capture containing files before that mutation so NetBeans can persist
+ * updated imports and qualified references afterward.
+ */
+ private fun filesWithUsages(usages: Collection): List =
+ usages.mapNotNull { usage -> usage.element?.containingFile as? KtFile }.distinct()
+
+ /**
+ * Rewrites imports of top-level declarations moved from their former package to [targetPackage].
+ *
+ * Standalone K2's reference-rebinding service intentionally skips imports because replacing an
+ * import-path segment invalidates the whole import list without an IDEA document synchronizer.
+ * The old-to-new map for a File Move preserves declaration identity, so update the direct import
+ * path explicitly after K2 retargeting; code usages remain handled by the upstream K2 engine.
+ */
+ private fun rewriteMovedImports(usageFile: KtFile, movedImports: Map) {
+ if (movedImports.isEmpty()) return
+ val factory = KtPsiFactory(usageFile.project)
+ usageFile.importDirectives.forEach { directive ->
+ if (directive.aliasName != null || directive.isAllUnder) return@forEach
+ val importedFqName = directive.importedFqName ?: return@forEach
+ val importedName = directive.importedName ?: return@forEach
+ val targetPackage = movedImports[MoveImport(importedFqName.parent(), importedName.asString())]
+ ?: return@forEach
+ directive.replace(factory.createImportDirective(ImportPath(targetPackage.child(importedName), false)))
+ }
+ }
+
+
+ /**
+ * Determines whether [packageName]'s segments are the terminal directory segments of [path].
+ *
+ * A standalone K2 session does not expose IDEA's source-root model to a `LightVirtualFile`.
+ * Comparing only the terminal package suffix preserves the relevant K2MoveFilesHandler rule:
+ * package rewriting is safe only when the source-root-relative directory agrees with the
+ * declared package. The root package is safe because every directory layout represents it.
+ */
+ private fun packageMatchesDirectory(path: String, packageName: String): Boolean {
+ if (packageName.isEmpty()) return true
+ val parentSegments = path.substringBeforeLast('/', missingDelimiterValue = "")
+ .split('/')
+ .filter(String::isNotEmpty)
+ val packageSegments = packageName.split('.')
+ return parentSegments.takeLast(packageSegments.size) == packageSegments
+ }
+}
+
+/**
+ * Immutable data required by the NetBeans Move File UI and apply phase.
+ *
+ * @param sourceFilePath absolute physical path of the selected Kotlin file
+ * @param fileName source file name, including extension
+ * @param packageName declared Kotlin package, or an empty string for the root package
+ * @param packageMayBeUpdated whether the declared package agrees with the file's directory layout
+ */
+data class KaMoveFileResult(
+ val sourceFilePath: String,
+ val fileName: String,
+ val packageName: String,
+ val packageMayBeUpdated: Boolean,
+)
diff --git a/KotlinRefactoring/src/main/kotlin/org/jetbrains/kotlin/idea/k2/refactoring/move/KotlinMoveUsageSearchService.kt b/KotlinRefactoring/src/main/kotlin/org/jetbrains/kotlin/idea/k2/refactoring/move/KotlinMoveUsageSearchService.kt
index ec331e25..c8ad5ac8 100644
--- a/KotlinRefactoring/src/main/kotlin/org/jetbrains/kotlin/idea/k2/refactoring/move/KotlinMoveUsageSearchService.kt
+++ b/KotlinRefactoring/src/main/kotlin/org/jetbrains/kotlin/idea/k2/refactoring/move/KotlinMoveUsageSearchService.kt
@@ -18,8 +18,13 @@ package org.jetbrains.kotlin.idea.k2.refactoring.move
import com.intellij.openapi.application.ApplicationManager
import com.intellij.psi.PsiReference
+import org.jetbrains.kotlin.name.FqName
+import org.jetbrains.kotlin.psi.KtFile
import org.jetbrains.kotlin.psi.KtNamedDeclaration
+/** Identifies a top-level declaration's original direct-import path. */
+data class MoveImport(val packageName: FqName, val declarationName: String)
+
/**
* Application service: finds every reference to [declaration] across the whole NetBeans project.
*
@@ -34,6 +39,15 @@ import org.jetbrains.kotlin.psi.KtNamedDeclaration
interface KotlinMoveUsageSearchService {
fun findUsages(declaration: KtNamedDeclaration): List
+ /**
+ * Finds session files that directly import one of [movedImports]' old declaration paths.
+ *
+ * @param project IntelliJ project whose standalone K2 session supplies candidate files
+ * @param movedImports old package/name pairs mapped to their final package
+ * @return Kotlin files whose import directives require explicit non-semantic replacement
+ */
+ fun findImportingFiles(project: com.intellij.openapi.project.Project, movedImports: Map): List = emptyList()
+
companion object {
fun getInstance(): KotlinMoveUsageSearchService? =
ApplicationManager.getApplication()?.getService(KotlinMoveUsageSearchService::class.java)
diff --git a/KotlinRefactoring/src/main/kotlin/org/jetbrains/kotlin/idea/k2/refactoring/move/processor/findMoveFileUsages.kt b/KotlinRefactoring/src/main/kotlin/org/jetbrains/kotlin/idea/k2/refactoring/move/processor/findMoveFileUsages.kt
new file mode 100644
index 00000000..e514e982
--- /dev/null
+++ b/KotlinRefactoring/src/main/kotlin/org/jetbrains/kotlin/idea/k2/refactoring/move/processor/findMoveFileUsages.kt
@@ -0,0 +1,47 @@
+/*******************************************************************************
+ * Copyright 2026 nbplugins contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ *******************************************************************************/
+package org.jetbrains.kotlin.idea.k2.refactoring.move.processor
+
+import com.intellij.usageView.UsageInfo
+import org.jetbrains.kotlin.idea.k2.refactoring.move.processor.usages.findMoveDeclarationUsages
+import org.jetbrains.kotlin.name.FqName
+import org.jetbrains.kotlin.psi.KtFile
+
+/**
+ * Finds Kotlin usages affected by moving a complete Kotlin file in a standalone K2 session.
+ *
+ * This is the portable semantic half of upstream `KtFile.findUsages`. IDEA obtains external
+ * references through an indexed Java/light-class path; NetBeans instead uses the registered
+ * [org.jetbrains.kotlin.idea.k2.refactoring.move.KotlinMoveUsageSearchService]. Non-code searches
+ * remain disabled for F4.1, consistent with the unchecked IDEA defaults and existing move ports.
+ *
+ * @param searchInCommentsAndStrings whether comments and string usages were requested
+ * @param searchForText whether non-code text usages were requested
+ * @param targetPackage package that declarations will belong to after the physical move
+ * @return Kotlin usages that require package/reference retargeting
+ */
+fun KtFile.findMoveFileUsages(
+ searchInCommentsAndStrings: Boolean,
+ searchForText: Boolean,
+ targetPackage: FqName,
+): List {
+ // Parameters are deliberately retained to mirror IDEA's API. F4.1 does not provide the
+ // platform text-occurrence index, so only semantic Kotlin references are searched.
+ @Suppress("UNUSED_VARIABLE")
+ val ignoredNonCodeSearch = searchInCommentsAndStrings || searchForText || targetPackage.isRoot
+ return topLevelDeclarationsToUpdate.flatMap(::findMoveDeclarationUsages)
+}
diff --git a/KotlinRefactoring/src/main/kotlin/org/jetbrains/kotlin/idea/k2/refactoring/move/processor/prepareMovedFileForPackage.kt b/KotlinRefactoring/src/main/kotlin/org/jetbrains/kotlin/idea/k2/refactoring/move/processor/prepareMovedFileForPackage.kt
new file mode 100644
index 00000000..ab9a64a0
--- /dev/null
+++ b/KotlinRefactoring/src/main/kotlin/org/jetbrains/kotlin/idea/k2/refactoring/move/processor/prepareMovedFileForPackage.kt
@@ -0,0 +1,48 @@
+/*******************************************************************************
+ * Copyright 2026 nbplugins contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ *******************************************************************************/
+package org.jetbrains.kotlin.idea.k2.refactoring.move.processor
+
+import com.intellij.psi.PsiElement
+import org.jetbrains.kotlin.name.FqName
+import org.jetbrains.kotlin.psi.KtFile
+
+/**
+ * Applies the K2 Move File handler's mutation with a NetBeans-selected package.
+ *
+ * The upstream handler derives the destination package from IntelliJ's source-root-aware
+ * `PsiDirectory`. Standalone K2 PSI is backed by `LightVirtualFile`s and cannot expose that model,
+ * so F4.1 supplies the package chosen through NetBeans' [KotlinPackageTarget] equivalent directly.
+ * The actual rewrite, declaration mapping, and eligibility marker remain in [K2MoveFilesHandler].
+ *
+ * @param file writable session file whose package may be rewritten
+ * @param targetPackage selected destination package
+ * @param rewritePackage whether the source package matched its source-root-relative directory
+ * @param oldToNewMap mutable moved declaration map used for later K2 retargeting
+ */
+fun K2MoveFilesHandler.prepareMovedFileForPackage(
+ file: KtFile,
+ targetPackage: FqName,
+ rewritePackage: Boolean,
+ oldToNewMap: MutableMap,
+) {
+ // This is the mapping half of K2MoveFilesHandler.prepareMovedFile with the one IDEA-only
+ // dependency replaced: the handler normally derives the package from JavaDirectoryService.
+ // A mismatch must preserve its declaration exactly, matching K2MoveFilesHandler.needsUpdate.
+ if (rewritePackage && file.packageFqName != targetPackage) file.updatePackageDirective(targetPackage)
+ oldToNewMap[file] = file
+ file.allDeclarationsToUpdate.forEach { declaration -> oldToNewMap[declaration] = declaration }
+}
diff --git a/Nbm/pom.xml b/Nbm/pom.xml
index 7f7ac40d..06a4d570 100644
--- a/Nbm/pom.xml
+++ b/Nbm/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.231-SNAPSHOT
+ 0.11.245-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-nbm
diff --git a/Nbm/src/main/java/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFileUI.java b/Nbm/src/main/java/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFileUI.java
new file mode 100644
index 00000000..b6e7efaa
--- /dev/null
+++ b/Nbm/src/main/java/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFileUI.java
@@ -0,0 +1,309 @@
+/*******************************************************************************
+ * Copyright 2026 nbplugins contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ *******************************************************************************/
+package io.github.nbplugins.kotlin.nbm.refactoring;
+
+import io.github.nbplugins.kotlin.refactoring.KaMoveFileResult;
+import java.awt.BorderLayout;
+import java.awt.Component;
+import java.awt.GridBagConstraints;
+import java.awt.GridBagLayout;
+import java.awt.Insets;
+import java.util.List;
+import javax.swing.BorderFactory;
+import javax.swing.JCheckBox;
+import javax.swing.JComboBox;
+import javax.swing.JLabel;
+import javax.swing.JList;
+import javax.swing.JPanel;
+import javax.swing.JTextField;
+import javax.swing.ListCellRenderer;
+import javax.swing.event.ChangeListener;
+import javax.swing.event.DocumentEvent;
+import javax.swing.event.DocumentListener;
+import org.netbeans.modules.refactoring.api.AbstractRefactoring;
+import org.netbeans.modules.refactoring.api.Problem;
+import org.netbeans.modules.refactoring.spi.ui.CustomRefactoringPanel;
+import org.netbeans.modules.refactoring.spi.ui.RefactoringUI;
+import org.openide.util.HelpCtx;
+
+/**
+ * Configures Kotlin Move File's source-root/package destination and supported reference updates.
+ *
+ * @param initialResult discovered source-file data
+ * @param refactoring mutable carrier that receives selected parameters
+ * @param target available NetBeans source roots and package validator
+ */
+public class KotlinMoveFileUI implements RefactoringUI {
+
+ private final KaMoveFileResult initialResult;
+ private final KotlinMoveFileRefactoring refactoring;
+ private final KotlinPackageTarget target;
+ private final String initialPackage;
+ private MoveFilePanel panel;
+
+ /**
+ * Creates the Move File UI adapter.
+ *
+ * @param initialResult discovered source-file data
+ * @param refactoring mutable carrier that receives selected parameters
+ * @param target available NetBeans source roots and package validator
+ */
+ public KotlinMoveFileUI(KaMoveFileResult initialResult,
+ KotlinMoveFileRefactoring refactoring,
+ KotlinPackageTarget target) {
+ this(initialResult, refactoring, target, initialResult.getPackageName());
+ }
+
+ /**
+ * Creates a Move File or Move Directory UI adapter with an explicit initial package.
+ *
+ * @param initialResult discovered representative Kotlin-file data
+ * @param refactoring mutable carrier that receives selected parameters
+ * @param target available NetBeans source roots and package validator
+ * @param initialPackage package initially shown in the target field
+ */
+ public KotlinMoveFileUI(KaMoveFileResult initialResult,
+ KotlinMoveFileRefactoring refactoring,
+ KotlinPackageTarget target,
+ String initialPackage) {
+ this.initialResult = initialResult;
+ this.refactoring = refactoring;
+ this.target = target;
+ this.initialPackage = initialPackage;
+ }
+
+ /** @return the user-visible refactoring name. */
+ @Override
+ public String getName() {
+ return refactoring.getSelection().isDirectory() ? "Move Kotlin Directory" : "Move Kotlin File";
+ }
+
+ /** @return concise user-visible description of the selected file or directory. */
+ @Override
+ public String getDescription() {
+ if (refactoring.getSelection().isDirectory()) {
+ return "Move " + refactoring.getSelection().getSourceFiles().size() + " Kotlin files from directory '"
+ + refactoring.getSelection().getDisplayName() + "' to another package";
+ }
+ return "Move Kotlin file '" + initialResult.getFileName() + "' to another package";
+ }
+
+ /** @return false because Move File mutates project sources. */
+ @Override
+ public boolean isQuery() {
+ return false;
+ }
+
+ /**
+ * Creates the destination form on first use.
+ *
+ * @param parent listener notified when form values change
+ * @return the cached Move File form
+ */
+ @Override
+ public CustomRefactoringPanel getPanel(ChangeListener parent) {
+ if (panel == null) {
+ panel = new MoveFilePanel(
+ refactoring.getSelection().isDirectory()
+ ? refactoring.getSelection().getDisplayName() + " ("
+ + refactoring.getSelection().getSourceFiles().size() + " Kotlin files)"
+ : initialResult.getFileName(),
+ initialPackage,
+ initialResult.getPackageMayBeUpdated(),
+ refactoring.getSelection().isDirectory(),
+ target.getRoots(),
+ target.getDefaultRootPath(),
+ parent
+ );
+ }
+ return panel;
+ }
+
+ /**
+ * Copies selected UI values to the refactoring carrier.
+ *
+ * @return a fatal problem for an invalid target package, otherwise {@code null}
+ */
+ @Override
+ public Problem setParameters() {
+ if (panel == null) {
+ return null;
+ }
+ String packageName = panel.getPackageValue().trim();
+ if (!target.isValidPackage(packageName)) {
+ return new Problem(true, "Target package is not a valid Kotlin package name.");
+ }
+ refactoring.setTargetRootPath(panel.getRootPath());
+ refactoring.setTargetPackage(packageName);
+ refactoring.setUpdateReferences(panel.isUpdateReferences());
+ return null;
+ }
+
+ /**
+ * Validates current form values before NetBeans schedules the refactoring.
+ *
+ * @return a fatal problem when the destination is invalid, otherwise {@code null}
+ */
+ @Override
+ public Problem checkParameters() {
+ return setParameters();
+ }
+
+ /** @return true because Move File has a destination configuration form. */
+ @Override
+ public boolean hasParameters() {
+ return true;
+ }
+
+ /** @return the refactoring carrier used by NetBeans. */
+ @Override
+ public AbstractRefactoring getRefactoring() {
+ return refactoring;
+ }
+
+ /** @return no dedicated help page. */
+ @Override
+ public HelpCtx getHelpCtx() {
+ return null;
+ }
+
+ /** Swing form for Move File parameters. */
+ private static final class MoveFilePanel implements CustomRefactoringPanel {
+ private final JPanel component;
+ private final JComboBox rootField;
+ private final JTextField packageField;
+ private final JCheckBox updateReferences;
+
+ /**
+ * Builds the destination form.
+ *
+ * @param fileName displayed source file name
+ * @param sourcePackage initially selected package
+ * @param mayUpdatePackage whether automatic package rewriting is available
+ * @param directorySelection whether the selected directory name is retained below the target
+ * package
+ * @param roots selectable destination source roots
+ * @param defaultRoot source root containing the current file, if available
+ * @param changeListener listener notified when values change
+ */
+ MoveFilePanel(String fileName,
+ String sourcePackage,
+ boolean mayUpdatePackage,
+ boolean directorySelection,
+ List roots,
+ String defaultRoot,
+ ChangeListener changeListener) {
+ rootField = new JComboBox<>(roots.toArray(new KotlinPackageTargetRoot[0]));
+ rootField.setRenderer(new RootRenderer());
+ for (int index = 0; index < rootField.getItemCount(); index++) {
+ KotlinPackageTargetRoot root = rootField.getItemAt(index);
+ if (root.getPath().equals(defaultRoot)) {
+ rootField.setSelectedIndex(index);
+ break;
+ }
+ }
+ packageField = new JTextField(sourcePackage, 30);
+ updateReferences = new JCheckBox("Update Kotlin references", true);
+ updateReferences.setToolTipText("Updates supported Kotlin code references; Java, comments, and text are not searched.");
+
+ JPanel form = new JPanel(new GridBagLayout());
+ form.setBorder(BorderFactory.createEmptyBorder(8, 12, 8, 12));
+ GridBagConstraints label = new GridBagConstraints();
+ label.anchor = GridBagConstraints.WEST;
+ label.insets = new Insets(2, 0, 2, 6);
+ GridBagConstraints field = new GridBagConstraints();
+ field.fill = GridBagConstraints.HORIZONTAL;
+ field.weightx = 1.0;
+ field.gridwidth = GridBagConstraints.REMAINDER;
+ field.insets = new Insets(2, 0, 2, 0);
+
+ form.add(new JLabel(directorySelection ? "Kotlin directory:" : "Kotlin file:"), label);
+ form.add(new JLabel(fileName), field);
+ JLabel rootLabel = new JLabel("Target source root:");
+ rootLabel.setLabelFor(rootField);
+ form.add(rootLabel, label);
+ form.add(rootField, field);
+ JLabel packageLabel = new JLabel("Target package:");
+ packageLabel.setLabelFor(packageField);
+ form.add(packageLabel, label);
+ form.add(packageField, field);
+ form.add(new JLabel(""), label);
+ form.add(updateReferences, field);
+ if (directorySelection) {
+ form.add(new JLabel(""), label);
+ form.add(new JLabel("The selected directory name and nested folders are retained below the target package."), field);
+ }
+ if (!mayUpdatePackage) {
+ form.add(new JLabel(""), label);
+ form.add(new JLabel("Package differs from its current directory and will be preserved."), field);
+ }
+
+ component = new JPanel(new BorderLayout());
+ component.add(form, BorderLayout.NORTH);
+ DocumentListener listener = new DocumentListener() {
+ @Override public void insertUpdate(DocumentEvent event) { changeListener.stateChanged(null); }
+ @Override public void removeUpdate(DocumentEvent event) { changeListener.stateChanged(null); }
+ @Override public void changedUpdate(DocumentEvent event) { changeListener.stateChanged(null); }
+ };
+ packageField.getDocument().addDocumentListener(listener);
+ rootField.addActionListener(event -> changeListener.stateChanged(null));
+ updateReferences.addActionListener(event -> changeListener.stateChanged(null));
+ }
+
+ /** @return selected source-root path or an empty string. */
+ String getRootPath() {
+ KotlinPackageTargetRoot root = (KotlinPackageTargetRoot) rootField.getSelectedItem();
+ return root == null ? "" : root.getPath();
+ }
+
+ /** @return current target package text. */
+ String getPackageValue() {
+ return packageField.getText();
+ }
+
+ /** @return whether supported Kotlin references should be retargeted. */
+ boolean isUpdateReferences() {
+ return updateReferences.isSelected();
+ }
+
+ /** Requests initial focus for the target package field. */
+ @Override
+ public void initialize() {
+ packageField.requestFocusInWindow();
+ }
+
+ /** @return Swing component displayed by NetBeans. */
+ @Override
+ public Component getComponent() {
+ return component;
+ }
+ }
+
+ /** Renders source roots by their project-visible display names. */
+ private static final class RootRenderer extends JLabel implements ListCellRenderer {
+ @Override
+ public Component getListCellRendererComponent(JList extends KotlinPackageTargetRoot> list,
+ KotlinPackageTargetRoot value, int index,
+ boolean selected, boolean focus) {
+ setText(value == null ? "" : value.getDisplayName());
+ setOpaque(true);
+ setBackground(selected ? list.getSelectionBackground() : list.getBackground());
+ setForeground(selected ? list.getSelectionForeground() : list.getForeground());
+ return this;
+ }
+ }
+}
diff --git a/Nbm/src/main/java/org/jetbrains/kotlin/refactorings/rename/KotlinRefactoringsFactory.java b/Nbm/src/main/java/org/jetbrains/kotlin/refactorings/rename/KotlinRefactoringsFactory.java
index 39061565..dcdef66a 100644
--- a/Nbm/src/main/java/org/jetbrains/kotlin/refactorings/rename/KotlinRefactoringsFactory.java
+++ b/Nbm/src/main/java/org/jetbrains/kotlin/refactorings/rename/KotlinRefactoringsFactory.java
@@ -38,6 +38,8 @@
import io.github.nbplugins.kotlin.nbm.refactoring.KotlinCopyDeclarationRefactoring;
import io.github.nbplugins.kotlin.nbm.refactoring.KotlinMoveDeclarationPlugin;
import io.github.nbplugins.kotlin.nbm.refactoring.KotlinMoveDeclarationRefactoring;
+import io.github.nbplugins.kotlin.nbm.refactoring.KotlinMoveFilePlugin;
+import io.github.nbplugins.kotlin.nbm.refactoring.KotlinMoveFileRefactoring;
import io.github.nbplugins.kotlin.nbm.refactoring.KotlinChangeSignaturePlugin;
import io.github.nbplugins.kotlin.nbm.refactoring.KotlinChangeSignatureRefactoring;
import io.github.nbplugins.kotlin.nbm.refactoring.KotlinIntroduceImportAliasPlugin;
@@ -89,6 +91,7 @@
* {@link KotlinIntroduceFunctionalParameterRefactoring} — delegates to {@link KotlinIntroduceFunctionalParameterPlugin}
* {@link KotlinCopyDeclarationRefactoring} — delegates to {@link KotlinCopyDeclarationPlugin}
* {@link KotlinMoveDeclarationRefactoring} — delegates to {@link KotlinMoveDeclarationPlugin}
+ * {@link KotlinMoveFileRefactoring} — delegates to {@link KotlinMoveFilePlugin}
* {@link KotlinChangeSignatureRefactoring} — delegates to {@link KotlinChangeSignaturePlugin}
*
*/
@@ -97,6 +100,12 @@ public class KotlinRefactoringsFactory implements RefactoringPluginFactory {
@Override
public RefactoringPlugin createInstance(AbstractRefactoring refactoring) {
+ // Move Kotlin Directory is invoked from a folder node, so its refactoring source deliberately
+ // contains FileObjects rather than an editor StyledDocument. Recognize its typed carrier
+ // before the legacy editor-document applicability guard below.
+ if (refactoring instanceof KotlinMoveFileRefactoring) {
+ return new KotlinMoveFilePlugin((KotlinMoveFileRefactoring) refactoring);
+ }
FileObject fo = ProjectUtils.getFileObjectForDocument(
refactoring.getRefactoringSource().lookup(StyledDocument.class));
if (fo == null || !fo.hasExt("kt")) {
diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveDirectoryAction.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveDirectoryAction.kt
new file mode 100644
index 00000000..1a2160fc
--- /dev/null
+++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveDirectoryAction.kt
@@ -0,0 +1,110 @@
+/*******************************************************************************
+ * Copyright 2026 nbplugins contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ *******************************************************************************/
+package io.github.nbplugins.kotlin.nbm.refactoring
+
+import io.github.nbplugins.kotlin.nbm.resolve.KotlinAnalysisAPISession
+import io.github.nbplugins.kotlin.refactoring.KaMoveFileComputer
+import java.awt.EventQueue
+import org.jetbrains.kotlin.log.KotlinLogger
+import org.jetbrains.kotlin.utils.ProjectUtils
+import org.netbeans.modules.refactoring.spi.ui.UI
+import org.openide.awt.ActionID
+import org.openide.awt.ActionRegistration
+import org.openide.loaders.DataObject
+import org.openide.nodes.Node
+import org.openide.util.HelpCtx
+import org.openide.util.actions.NodeAction
+import org.openide.windows.TopComponent
+
+/**
+ * Folder-node action that moves all Kotlin descendants while retaining the selected folder below
+ * the target package.
+ *
+ * The action deliberately accepts one folder only. It leaves non-Kotlin descendants in place and
+ * delegates the Kotlin-only multi-file operation to [KotlinMoveFilePlugin].
+ */
+@ActionID(category = "Refactoring", id = "io.github.nbplugins.kotlin.nbm.refactoring.KotlinMoveDirectoryAction")
+@ActionRegistration(displayName = "Move Kotlin Directory...")
+open class KotlinMoveDirectoryAction : NodeAction() {
+
+ init {
+ putValue(NAME, "Move Kotlin Directory...")
+ putValue(SHORT_DESCRIPTION, "Move Kotlin Files in Directory to Another Package")
+ }
+
+ /**
+ * Opens the shared destination UI for the selected Kotlin-containing directory.
+ *
+ * Source discovery and K2 session preparation may take time, so NetBeans can run this action in
+ * a worker. [openRefactoringUi] moves the Swing-only UI step to the Event Dispatch Thread.
+ */
+ override fun performAction(nodes: Array) {
+ val folder = selectedFolder(nodes) ?: return
+ val selection = KotlinMoveSourceSelection.from(folder) ?: return
+ runCatching {
+ val project = ProjectUtils.getKotlinProjectForFileObject(selection.representativeFile)
+ ?: ProjectUtils.getValidProject()
+ ?: return@runCatching
+ val file = KotlinAnalysisAPISession.getSession(project).getKtFileForPath(selection.representativeFile.path)
+ ?: return@runCatching
+ val result = (KaMoveFileComputer(file).compute() as? KaMoveFileComputer.Outcome.Ready)?.result
+ ?: return@runCatching
+ val refactoring = KotlinMoveFileRefactoring(selection)
+ val target = KotlinPackageTarget(project, selection.representativeFile)
+ openRefactoringUi(
+ KotlinMoveFileUI(result, refactoring, target, target.packageForFolder(folder.parent ?: folder)),
+ )
+ }.onFailure { error ->
+ KotlinLogger.INSTANCE.logException("KotlinMoveDirectoryAction failed", error)
+ }
+ }
+
+ /**
+ * Opens a refactoring panel on the Event Dispatch Thread regardless of NodeAction's dispatcher.
+ *
+ * @param ui prepared destination UI; its construction is thread-neutral but its display is not
+ */
+ internal fun openRefactoringUi(ui: KotlinMoveFileUI) {
+ KotlinLogger.INSTANCE.logInfo("KotlinMoveDirectoryAction: scheduling refactoring UI on EDT")
+ EventQueue.invokeLater {
+ runCatching {
+ KotlinLogger.INSTANCE.logInfo("KotlinMoveDirectoryAction: opening refactoring UI on EDT")
+ UI.openRefactoringUI(ui, TopComponent.getRegistry().activated)
+ }.onFailure { error ->
+ KotlinLogger.INSTANCE.logException("KotlinMoveDirectoryAction UI opening failed", error)
+ }
+ }
+ }
+
+ /** Enables the action exclusively for one physical folder containing a Kotlin descendant. */
+ override fun enable(nodes: Array): Boolean = selectedFolder(nodes)
+ ?.let(KotlinMoveSourceSelection::from)
+ ?.isDirectory == true
+
+ /** @return no dedicated help page. */
+ override fun getHelpCtx(): HelpCtx = HelpCtx.DEFAULT_HELP
+
+ /** @return stable action name used by the manual layer registration. */
+ override fun getName(): String = "Move Kotlin Directory"
+
+ /** Resolves exactly one folder node's physical file object. */
+ private fun selectedFolder(nodes: Array): org.openide.filesystems.FileObject? {
+ if (nodes.size != 1) return null
+ val dataObject = nodes.single().lookup.lookup(DataObject::class.java) ?: return null
+ return dataObject.primaryFile.takeIf { it.isValid && it.isFolder }
+ }
+}
diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFileAction.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFileAction.kt
new file mode 100644
index 00000000..059617aa
--- /dev/null
+++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFileAction.kt
@@ -0,0 +1,75 @@
+/*******************************************************************************
+ * Copyright 2026 nbplugins contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ *******************************************************************************/
+package io.github.nbplugins.kotlin.nbm.refactoring
+
+import io.github.nbplugins.kotlin.nbm.resolve.KotlinAnalysisAPISession
+import io.github.nbplugins.kotlin.refactoring.KaMoveFileComputer
+import io.github.nbplugins.kotlin.refactoring.KaMoveFileResult
+import org.jetbrains.kotlin.log.KotlinLogger
+import org.jetbrains.kotlin.utils.ProjectUtils
+import org.netbeans.editor.BaseAction
+import org.netbeans.modules.refactoring.spi.ui.UI
+import org.openide.windows.TopComponent
+import java.awt.event.ActionEvent
+import javax.swing.text.JTextComponent
+import javax.swing.text.StyledDocument
+
+/**
+ * Editor action for moving the currently open Kotlin file to another source-root/package directory.
+ *
+ * The physical move, package update, and supported Kotlin reference retargeting are executed later
+ * by [KotlinMoveFilePlugin] through the NetBeans refactoring lifecycle.
+ */
+class KotlinMoveFileAction : BaseAction(ACTION_NAME, SAVE_POSITION or ABBREV_RESET) {
+
+ init {
+ putValue(NAME, "Move Kotlin File...")
+ putValue(SHORT_DESCRIPTION, "Move Kotlin File to Another Package")
+ putValue(POPUP_MENU_TEXT, "Move Kotlin File...")
+ }
+
+ override fun actionPerformed(evt: ActionEvent, target: JTextComponent) {
+ val document = target.document as? StyledDocument ?: return
+ runCatching {
+ val source = ProjectUtils.getFileObjectForDocument(document) ?: return@runCatching
+ val project = ProjectUtils.getKotlinProjectForFileObject(source)
+ ?: ProjectUtils.getValidProject()
+ ?: return@runCatching
+ val selection = KotlinMoveSourceSelection.from(source) ?: return@runCatching
+ val result = resolveOutcome(project, source.path) ?: return@runCatching
+ val refactoring = KotlinMoveFileRefactoring(selection)
+ UI.openRefactoringUI(
+ KotlinMoveFileUI(result, refactoring, KotlinPackageTarget(project, selection.representativeFile)),
+ TopComponent.getRegistry().activated,
+ )
+ }.onFailure { error ->
+ KotlinLogger.INSTANCE.logException("KotlinMoveFileAction failed", error)
+ }
+ }
+
+ /** Resolves a discovery result for the current physical Kotlin source file. */
+ private fun resolveOutcome(project: org.netbeans.api.project.Project, sourcePath: String): KaMoveFileResult? {
+ val session = KotlinAnalysisAPISession.getSession(project)
+ val file = session.getKtFileForPath(sourcePath) ?: return null
+ return (KaMoveFileComputer(file).compute() as? KaMoveFileComputer.Outcome.Ready)?.result
+ }
+
+ companion object {
+ /** Action name used by the manual layer.xml registration. */
+ const val ACTION_NAME = "kotlin-move-file"
+ }
+}
diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFilePlugin.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFilePlugin.kt
new file mode 100644
index 00000000..a53db099
--- /dev/null
+++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFilePlugin.kt
@@ -0,0 +1,250 @@
+/*******************************************************************************
+ * Copyright 2026 nbplugins contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ *******************************************************************************/
+package io.github.nbplugins.kotlin.nbm.refactoring
+
+import io.github.nbplugins.kotlin.nbm.navigation.KotlinFindUsagesResultElement
+import io.github.nbplugins.kotlin.nbm.resolve.KotlinAnalysisAPISession
+import io.github.nbplugins.kotlin.refactoring.KaMoveDeclarationComputer
+import io.github.nbplugins.kotlin.refactoring.KaMoveFileComputer
+import org.jetbrains.kotlin.log.KotlinLogger
+import org.jetbrains.kotlin.name.FqName
+import org.jetbrains.kotlin.utils.ProjectUtils
+import org.netbeans.modules.csl.api.OffsetRange
+import org.netbeans.modules.refactoring.api.Problem
+import org.netbeans.modules.refactoring.spi.ProgressProviderAdapter
+import org.netbeans.modules.refactoring.spi.RefactoringElementsBag
+import org.netbeans.modules.refactoring.spi.RefactoringPlugin
+import org.netbeans.modules.refactoring.spi.SimpleRefactoringElementImplementation
+import org.openide.filesystems.FileObject
+import org.openide.filesystems.FileUtil
+import org.openide.text.CloneableEditorSupport
+import org.openide.text.PositionBounds
+import org.openide.util.Lookup
+import org.openide.util.lookup.Lookups
+import javax.swing.text.Position.Bias
+
+/**
+ * Bridges NetBeans Move Kotlin File and Move Kotlin Directory UI to the standalone K2 semantic
+ * adapter.
+ *
+ * NetBeans owns destination creation, physical movement, document persistence, rollback, and undo.
+ * The ported K2 handler supplies eligible package rewriting, Kotlin conflict detection, and
+ * retargeting of supported external Kotlin references.
+ *
+ * @param refactoring carrier containing selected Kotlin sources and the destination selected in UI
+ */
+class KotlinMoveFilePlugin(
+ private val refactoring: KotlinMoveFileRefactoring,
+) : ProgressProviderAdapter(), RefactoringPlugin {
+
+ /** @return no preliminary problem; source validation happens while preparing the operation. */
+ override fun preCheck(): Problem? = null
+
+ /** @return no fast problem; the UI validates the package before this point. */
+ override fun fastCheckParameters(): Problem? = null
+
+ /** @return no extra parameter problem. */
+ override fun checkParameters(): Problem? = null
+
+ /** Cancels no external work because all Move File work runs in the refactoring lifecycle. */
+ override fun cancelRequest() = Unit
+
+ /**
+ * Adds a preview element for every selected Kotlin source and one atomic apply element.
+ *
+ * @param bag NetBeans bag populated with preview and mutation elements
+ * @return a fatal problem when a source cannot be analyzed, otherwise `null`
+ */
+ override fun prepare(bag: RefactoringElementsBag): Problem? {
+ KotlinLogger.INSTANCE.logInfo("KotlinMoveFilePlugin.prepare: plugin created")
+ val selection = refactoring.selection
+ val sourceProject = ProjectUtils.getKotlinProjectForFileObject(selection.representativeFile)
+ ?: ProjectUtils.getValidProject()
+ ?: return Problem(true, "Move Kotlin File could not find the source project.")
+ KotlinLogger.INSTANCE.logInfo(
+ "KotlinMoveFilePlugin.prepare: ${selection.sourceFiles.size} Kotlin source(s), " +
+ "project=${sourceProject.projectDirectory.path}",
+ )
+ val session = KotlinAnalysisAPISession.getSession(sourceProject)
+ val sources = selection.sourceFiles.map { source ->
+ session.getKtFileForPath(source.path)
+ ?: return Problem(true, "Move Kotlin File could not resolve ${source.nameExt}.")
+ }
+ val problem = sources.firstNotNullOfOrNull { file ->
+ when (val outcome = KaMoveFileComputer(file).compute()) {
+ is KaMoveFileComputer.Outcome.NotApplicable -> Problem(true, "The selected Kotlin file has no physical path.")
+ is KaMoveFileComputer.Outcome.Error -> Problem(true, outcome.error.message ?: "Move Kotlin File analysis failed.")
+ is KaMoveFileComputer.Outcome.Ready -> null
+ }
+ }
+ if (problem != null) return problem
+ sources.zip(selection.sourceFiles).forEach { (file, source) ->
+ bag.add(refactoring, KotlinFindUsagesResultElement(OffsetRange(0, file.textLength), source))
+ }
+ bag.add(refactoring, KotlinMoveFileApplyElement(selection, sourceProject, refactoring))
+ return null
+ }
+}
+
+/**
+ * Performs one atomic Kotlin file or directory move and retains the transaction for Undo Last
+ * Refactoring.
+ *
+ * @param selection Kotlin sources selected by the editor or directory-node action
+ * @param project NetBeans project owning the selected sources
+ * @param refactoring destination and search parameters
+ */
+class KotlinMoveFileApplyElement(
+ private val selection: KotlinMoveSourceSelection,
+ private val project: org.netbeans.api.project.Project,
+ private val refactoring: KotlinMoveFileRefactoring,
+) : SimpleRefactoringElementImplementation() {
+
+ /** Successful transaction retained so [undoChange] can restore original paths and text. */
+ private var transaction: KotlinRefactoringTransaction? = null
+
+ /** @return user-visible preview text. */
+ override fun getText(): String = if (selection.isDirectory) "Move Kotlin directory" else "Move Kotlin file"
+
+ /** @return user-visible preview text. */
+ override fun getDisplayText(): String = getText()
+
+ /** @return selected representative file lookup. */
+ override fun getLookup(): Lookup = Lookups.fixed(selection.representativeFile)
+
+ /** @return representative source file for NetBeans preview placement. */
+ override fun getParentFile(): FileObject = selection.representativeFile
+
+ /** @return source position bounds, when the editor support is available. */
+ override fun getPosition(): PositionBounds? = try {
+ val support = org.openide.loaders.DataObject.find(selection.representativeFile)
+ .lookup.lookup(CloneableEditorSupport::class.java) ?: return null
+ PositionBounds(
+ support.createPositionRef(0, Bias.Forward),
+ support.createPositionRef(0, Bias.Backward),
+ )
+ } catch (_: Exception) {
+ null
+ }
+
+ /**
+ * Runs K2 semantics first, then atomically moves every physical Kotlin file and persists every
+ * changed Kotlin document. Any failure rolls paths, documents, owned destination folders, and
+ * created files back to their original state.
+ */
+ override fun performChange() {
+ var pending: KotlinRefactoringTransaction? = null
+ runCatching {
+ val packageTarget = KotlinPackageTarget(project, selection.representativeFile)
+ val targetRoot = packageTarget.roots.firstOrNull { it.path == refactoring.targetRootPath }
+ ?: packageTarget.roots.firstOrNull { it.path == packageTarget.defaultRootPath }
+ ?: error("Move Kotlin File could not find a destination source root.")
+ val targetPackage = refactoring.targetPackage.trim()
+ check(packageTarget.isValidPackage(targetPackage)) { "Target package is invalid: $targetPackage" }
+
+ val current = KotlinRefactoringTransaction()
+ pending = current
+ val sourceByPath = selection.sourceFiles.associateBy(FileObject::getPath)
+ val targetFolders = selection.sourceFiles.associateWith { source ->
+ val packageSegments = targetPackage.split('.').filter(String::isNotEmpty) +
+ selection.targetRelativeFolderSegments(source)
+ createTargetFolder(current, targetRoot.folder, packageSegments)
+ }
+ validatePhysicalTargets(selection.sourceFiles, targetFolders)
+
+ val session = KotlinAnalysisAPISession.getSession(project)
+ val ktTargets = selection.sourceFiles.associate { source ->
+ val sourceKtFile = session.getKtFileForPath(source.path)
+ ?: error("Move Kotlin File could not resolve writable source PSI: ${source.nameExt}.")
+ val targetFolder = targetFolders.getValue(source)
+ val targetDirectory = KaMoveDeclarationComputer.resolveDirectory(sourceKtFile.project, targetFolder.path)
+ ?: error("Move Kotlin File could not resolve destination PSI directory: ${targetFolder.path}.")
+ val finalPackage = targetPackageFor(source, targetPackage)
+ sourceKtFile to KaMoveFileComputer.MoveTarget(targetDirectory, FqName(finalPackage))
+ }
+ when (val outcome = KaMoveFileComputer(ktTargets.keys.first()).apply(ktTargets, refactoring.updateReferences)) {
+ is KaMoveFileComputer.ApplyOutcome.Conflicts -> throw IllegalStateException(outcome.messages.joinToString("\n"))
+ is KaMoveFileComputer.ApplyOutcome.Error -> throw outcome.error
+ is KaMoveFileComputer.ApplyOutcome.Success -> {
+ selection.sourceFiles.forEach { source -> current.moveFile(source, targetFolders.getValue(source)) }
+ outcome.changedFiles.forEach { (path, text) ->
+ val file = sourceByPath[path] ?: FileUtil.toFileObject(FileUtil.normalizeFile(java.io.File(path)))
+ ?: error("Move Kotlin File could not resolve changed file $path.")
+ current.captureExisting(file, outcome.originalTexts[path])
+ current.stageText(file, text)
+ }
+ current.commit()
+ transaction = current
+ KotlinLogger.INSTANCE.logInfo("KotlinMoveFileApplyElement.performChange: transaction committed")
+ pending = null
+ }
+ }
+ }.onFailure { error ->
+ KotlinLogger.INSTANCE.logException("KotlinMoveFileApplyElement.performChange failed", error)
+ }
+ runCatching { pending?.rollback() }
+ .onFailure { error -> KotlinLogger.INSTANCE.logException("KotlinMoveFileApplyElement rollback failed", error) }
+ KotlinAnalysisAPISession.invalidate(project)
+ }
+
+ /** Restores original source paths and texts through the transaction retained after commit. */
+ override fun undoChange() {
+ runCatching {
+ val current = transaction
+ if (current == null) {
+ KotlinLogger.INSTANCE.logWarning("KotlinMoveFileApplyElement.undoChange: no retained transaction")
+ } else {
+ KotlinLogger.INSTANCE.logInfo("KotlinMoveFileApplyElement.undoChange: restoring retained transaction")
+ current.undo()
+ KotlinLogger.INSTANCE.logInfo("KotlinMoveFileApplyElement.undoChange: transaction restored")
+ transaction = null
+ }
+ KotlinAnalysisAPISession.invalidate(project)
+ }.onFailure { error ->
+ KotlinLogger.INSTANCE.logException("KotlinMoveFileApplyElement.undoChange failed", error)
+ }
+ }
+
+ /** Rejects same-directory, duplicate-path, and pre-existing-target moves before K2 mutates PSI. */
+ private fun validatePhysicalTargets(
+ sources: List,
+ targets: Map,
+ ) {
+ val targetPaths = mutableSetOf()
+ sources.forEach { source ->
+ val target = targets.getValue(source)
+ check(target != source.parent) { "${source.nameExt} is already in the selected destination folder." }
+ val path = "${target.path}/${source.nameExt}"
+ check(targetPaths.add(path)) { "Several selected Kotlin files would move to $path." }
+ val existing = target.getFileObject(source.name, source.ext)
+ check(existing == null || existing == source) { "Target already contains ${source.nameExt}." }
+ }
+ }
+
+ /** Computes one source's final package, retaining a selected directory's nested hierarchy. */
+ private fun targetPackageFor(source: FileObject, basePackage: String): String {
+ val segments = basePackage.split('.').filter(String::isNotEmpty) + selection.targetRelativeFolderSegments(source)
+ return segments.joinToString(".")
+ }
+
+ /** Creates only destination package folders that this transaction can safely delete on undo. */
+ private fun createTargetFolder(
+ transaction: KotlinRefactoringTransaction,
+ root: FileObject,
+ segments: List,
+ ): FileObject = segments.fold(root) { parent, segment -> transaction.createFolder(parent, segment) }
+}
diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFileRefactoring.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFileRefactoring.kt
new file mode 100644
index 00000000..92e27741
--- /dev/null
+++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFileRefactoring.kt
@@ -0,0 +1,42 @@
+/*******************************************************************************
+ * Copyright 2026 nbplugins contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ *******************************************************************************/
+package io.github.nbplugins.kotlin.nbm.refactoring
+
+import org.netbeans.modules.refactoring.api.AbstractRefactoring
+import org.openide.util.lookup.Lookups
+
+/**
+ * Carries selected Kotlin sources and destination parameters for Move File and Move Directory.
+ *
+ * The editor action creates a single-file [selection]. The folder-node action creates a recursive
+ * Kotlin-only selection; both variants run through one refactoring lifecycle and transaction.
+ *
+ * @param selection physical Kotlin sources selected for movement
+ */
+class KotlinMoveFileRefactoring(
+ val selection: KotlinMoveSourceSelection,
+) : AbstractRefactoring(Lookups.fixed(*selection.sourceFiles.toTypedArray())) {
+
+ /** Source-root path selected in the destination UI. */
+ var targetRootPath: String = ""
+
+ /** Kotlin package selected for the destination folder. */
+ var targetPackage: String = ""
+
+ /** Whether supported external Kotlin references should be retargeted. */
+ var updateReferences: Boolean = true
+}
diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveSourceSelection.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveSourceSelection.kt
new file mode 100644
index 00000000..29cb2bf0
--- /dev/null
+++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveSourceSelection.kt
@@ -0,0 +1,104 @@
+/*******************************************************************************
+ * Copyright 2026 nbplugins contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ *******************************************************************************/
+package io.github.nbplugins.kotlin.nbm.refactoring
+
+import org.openide.filesystems.FileObject
+
+/**
+ * Immutable Kotlin files selected for a Move Kotlin File or Move Kotlin Directory operation.
+ *
+ * A directory selection retains its leaf directory below the package chosen in the destination UI.
+ * For example, moving `feature/internal/Helper.kt` with selected directory `feature` to package
+ * `sample.target` places the file below `sample/target/feature/internal`.
+ *
+ * @param sourceFiles physical Kotlin source files in deterministic relative-path order
+ * @param selectedDirectory directory selected by the user, or `null` for a file selection
+ */
+class KotlinMoveSourceSelection private constructor(
+ val sourceFiles: List,
+ val selectedDirectory: FileObject?,
+) {
+ /** @return whether this selection was created from a directory node. */
+ val isDirectory: Boolean
+ get() = selectedDirectory != null
+
+ /** @return source used for project/source-root discovery and single-file UI details. */
+ val representativeFile: FileObject
+ get() = sourceFiles.first()
+
+ /** @return concise UI label for the selected source. */
+ val displayName: String
+ get() = selectedDirectory?.nameExt ?: representativeFile.nameExt
+
+ /**
+ * Computes the final relative folder segments beneath the user-selected target package.
+ *
+ * @param file a member of [sourceFiles]
+ * @return empty for a directly selected file, otherwise selected-directory name plus the file's
+ * relative parent path
+ */
+ fun targetRelativeFolderSegments(file: FileObject): List {
+ require(file in sourceFiles) { "File is not part of this Move Kotlin selection: ${file.path}" }
+ val directory = selectedDirectory ?: return emptyList()
+ val relativeParent = relativeSegments(directory, file.parent)
+ return listOf(directory.name) + relativeParent
+ }
+
+ /**
+ * Creates a selection from one Kotlin file or recursively from one directory.
+ *
+ * @param source selected physical Kotlin file or folder
+ * @return a non-empty selection, or `null` if [source] contains no valid Kotlin files
+ */
+ companion object {
+ /**
+ * Creates a Kotlin move selection from one physical file or recursively from one folder.
+ *
+ * @param source selected physical Kotlin file or source-tree folder
+ * @return a non-empty selection, or `null` when [source] has no valid Kotlin descendants
+ */
+ fun from(source: FileObject): KotlinMoveSourceSelection? {
+ if (!source.isValid) return null
+ if (!source.isFolder) {
+ return if (source.ext == "kt") KotlinMoveSourceSelection(listOf(source), null) else null
+ }
+ val files = collectKotlinFiles(source).sortedBy { it.path }
+ return files.takeIf(List::isNotEmpty)?.let { KotlinMoveSourceSelection(it, source) }
+ }
+
+ /** Recursively finds valid Kotlin source files while deliberately ignoring other content. */
+ private fun collectKotlinFiles(folder: FileObject): List = folder.children.flatMap { child ->
+ when {
+ child.isFolder -> collectKotlinFiles(child)
+ child.isValid && child.ext == "kt" -> listOf(child)
+ else -> emptyList()
+ }
+ }
+
+ /** Returns [descendant]'s relative folder path below [ancestor]. */
+ private fun relativeSegments(ancestor: FileObject, descendant: FileObject?): List {
+ val segments = mutableListOf()
+ var current = descendant
+ while (current != null && current != ancestor) {
+ segments += current.name
+ current = current.parent
+ }
+ require(current == ancestor) { "${descendant?.path} is not below ${ancestor.path}" }
+ return segments.asReversed()
+ }
+ }
+}
diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveUsageSearchServiceImpl.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveUsageSearchServiceImpl.kt
index 8c46d192..e73f2cf4 100644
--- a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveUsageSearchServiceImpl.kt
+++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveUsageSearchServiceImpl.kt
@@ -21,7 +21,10 @@ import com.intellij.psi.util.PsiTreeUtil
import io.github.nbplugins.kotlin.nbm.resolve.KotlinAnalysisAPISession
import org.jetbrains.kotlin.analysis.api.analyze
import org.jetbrains.kotlin.idea.k2.refactoring.move.KotlinMoveUsageSearchService
+import org.jetbrains.kotlin.idea.k2.refactoring.move.MoveImport
import org.jetbrains.kotlin.idea.references.KtReference
+import org.jetbrains.kotlin.name.FqName
+import org.jetbrains.kotlin.psi.KtFile
import org.jetbrains.kotlin.log.KotlinLogger
import org.jetbrains.kotlin.psi.KtImportDirective
import org.jetbrains.kotlin.psi.KtNamedDeclaration
@@ -78,4 +81,39 @@ class KotlinMoveUsageSearchServiceImpl : KotlinMoveUsageSearchService {
}
return references
}
+
+ /**
+ * Finds Kotlin files that directly import a moved top-level declaration.
+ *
+ * Import directives are intentionally absent from [findUsages] because K2 semantic rebinding
+ * cannot safely replace an import-path segment in standalone PSI. The caller replaces whole
+ * directives after semantic retargeting instead.
+ *
+ * @param project IntelliJ project owning the standalone K2 session
+ * @param movedImports original package/name keys mapped to final packages
+ * @return session files with a direct non-aliased import that needs rewriting
+ */
+ override fun findImportingFiles(
+ project: com.intellij.openapi.project.Project,
+ movedImports: Map,
+ ): List {
+ if (movedImports.isEmpty()) return emptyList()
+ val session = KotlinAnalysisAPISession.forProject(project) ?: return emptyList()
+ val candidates = session.fileMap.values.mapNotNull { lightFile ->
+ val file = session.getKtFileForPath(lightFile.path) ?: return@mapNotNull null
+ file.takeIf { candidate ->
+ candidate.importDirectives.any { directive ->
+ directive.aliasName == null && !directive.isAllUnder && directive.importedFqName?.let { imported ->
+ MoveImport(imported.parent(), imported.shortName().asString()) in movedImports
+ } == true
+ }
+ }
+ }
+ if (candidates.isNotEmpty()) {
+ KotlinLogger.INSTANCE.logInfo(
+ "KotlinMoveUsageSearchServiceImpl: found ${candidates.size} import-only Move File usage(s)",
+ )
+ }
+ return candidates
+ }
}
diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinPackageTarget.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinPackageTarget.kt
index 41692470..361b0973 100644
--- a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinPackageTarget.kt
+++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinPackageTarget.kt
@@ -89,6 +89,17 @@ class KotlinPackageTarget(private val project: Project, private val sourceFile:
fun isValidPackage(packageName: String): Boolean = packageName.isBlank() ||
packageName.split('.').all { segment -> IDENTIFIER.matches(segment) }
+ /**
+ * Returns [folder]'s dotted path relative to its containing source root.
+ *
+ * @param folder source-root descendant for which to calculate an initial target package
+ * @return the source-root-relative package, or an empty string when no root contains [folder]
+ */
+ fun packageForFolder(folder: FileObject): String = roots.firstNotNullOfOrNull { root ->
+ val relative = relativePath(root.folder, folder) ?: return@firstNotNullOfOrNull null
+ relative.split('/').filter(String::isNotEmpty).joinToString(".")
+ }.orEmpty()
+
/** @return whether [file] is located at or beneath this target's source root. */
private fun contains(root: FileObject): Boolean = sourceFile.toURI().let { sourceUri ->
root.toURI().let { rootUri -> sourceUri.toString().startsWith(rootUri.toString()) }
@@ -112,6 +123,17 @@ class KotlinPackageTarget(private val project: Project, private val sourceFile:
.filter(FileObject::isFolder)
.map(::KotlinSourceGroup)
+ /** Returns the source-root-relative path to [folder], or `null` when it is outside [root]. */
+ private fun relativePath(root: FileObject, folder: FileObject): String? {
+ val rootPath = root.path.trimEnd('/')
+ val folderPath = folder.path
+ return when {
+ folderPath == rootPath -> ""
+ folderPath.startsWith("$rootPath/") -> folderPath.removePrefix("$rootPath/")
+ else -> null
+ }
+ }
+
/** Returns the selected root object, retaining selection by path rather than UI object identity. */
private fun rootFolder(path: String?): FileObject? = path?.let { selected ->
roots.firstOrNull { it.path == selected }?.folder
diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRefactoringTransaction.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRefactoringTransaction.kt
index e48fbd51..29c93ae1 100644
--- a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRefactoringTransaction.kt
+++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRefactoringTransaction.kt
@@ -16,6 +16,7 @@
*******************************************************************************/
package io.github.nbplugins.kotlin.nbm.refactoring
+import org.jetbrains.kotlin.log.KotlinLogger
import org.openide.cookies.EditorCookie
import org.openide.filesystems.FileObject
import org.openide.loaders.DataObject
@@ -31,10 +32,12 @@ import javax.swing.text.StyledDocument
*
* @param openDocument resolves a writable editor document for a file
* @param writeDocument replaces a document's text; injectable to test rollback deterministically
+ * @param saveDocument persists an editor document and releases its NetBeans file lock before a move
*/
internal class KotlinRefactoringTransaction internal constructor(
private val openDocument: (FileObject) -> StyledDocument? = ::openEditorDocument,
private val writeDocument: (FileObject, StyledDocument, String) -> Unit = ::replaceDocumentText,
+ private val saveDocument: (FileObject) -> Unit = ::saveEditorDocument,
) {
/** Identifies a file whose transaction operation could not be completed. */
@@ -52,24 +55,79 @@ internal class KotlinRefactoringTransaction internal constructor(
var stagedWriter: ((StyledDocument, String, String) -> Unit)? = null,
)
+ private data class MoveEntry(
+ val originalFile: FileObject,
+ val originalParent: FileObject,
+ val originalName: String,
+ val originalExtension: String,
+ val targetParent: FileObject,
+ var movedFile: FileObject? = null,
+ )
+
private val entries = linkedMapOf()
+ private val moves = mutableListOf()
+ private val ownedFolders = mutableListOf()
+ private var committed = false
+ private var movesApplied = false
+ private var restoring = false
+
+ /**
+ * Records a physical file move that will be applied atomically with staged document changes.
+ *
+ * @param file existing Kotlin source file to move
+ * @param targetParent destination folder, which must not already contain the same file name
+ */
+ fun moveFile(file: FileObject, targetParent: FileObject) {
+ check(file.isValid) { "Cannot move invalid file ${file.path}" }
+ check(!file.isFolder) { "Move transaction supports files; enumerate Kotlin directory descendants first." }
+ val originalParent = file.parent ?: error("Cannot move root file ${file.path}")
+ check(targetParent.isValid && targetParent.isFolder) { "Invalid target folder ${targetParent.path}" }
+ check(targetParent.getFileObject(file.name, file.ext) == null) {
+ "Target file already exists: ${targetParent.path}/${file.nameExt}"
+ }
+ captureExisting(file)
+ moves += MoveEntry(file, originalParent, file.name, file.ext, targetParent)
+ }
+
+ /**
+ * Creates a destination folder owned by this transaction when it does not already exist.
+ *
+ * @param parent existing folder in which to create or find the child
+ * @param name simple child folder name
+ * @return the existing or newly created folder; only a newly created folder is deleted on undo
+ */
+ fun createFolder(parent: FileObject, name: String): FileObject {
+ parent.getFileObject(name)?.let { existing ->
+ check(existing.isFolder) { "Target path is not a folder: ${existing.path}" }
+ return existing
+ }
+ val folder = parent.createFolder(name)
+ ownedFolders += folder
+ return folder
+ }
/**
* Captures [file]'s original text and opens its NetBeans editor document exactly once.
*
* @param file an existing source file that this transaction may modify
+ * @param originalText optional snapshot taken before an external semantic engine mutated PSI
* @return the opened document associated with [file]
*/
- fun captureExisting(file: FileObject): StyledDocument {
+ fun captureExisting(file: FileObject, originalText: String? = null): StyledDocument {
entries[file]?.let { return it.document }
check(file.isValid) { "Cannot capture invalid file ${file.path}" }
val document = requireDocument(file)
+ val snapshot = originalText ?: document.getText(0, document.length)
entries[file] = Entry(
file,
existedBefore = true,
- originalText = document.getText(0, document.length),
+ originalText = snapshot,
document = document,
)
+ KotlinLogger.INSTANCE.logInfo(
+ "KotlinRefactoringTransaction.captureExisting: path=${file.path}, " +
+ "snapshot=${describeText(snapshot)}, document=${describeText(document.getText(0, document.length))}"
+ )
return document
}
@@ -141,6 +199,7 @@ internal class KotlinRefactoringTransaction internal constructor(
* have been restored and transaction-created files have been deleted
*/
fun commit() {
+ check(!committed) { "Refactoring transaction was already committed." }
val stagedEntries = entries.values.filter { it.stagedText != null }
try {
stagedEntries.forEach { entry ->
@@ -149,43 +208,174 @@ internal class KotlinRefactoringTransaction internal constructor(
"Target document changed while preparing transaction: ${entry.file.path}"
}
}
+ // A NetBeans editor document remains attached to its current FileObject. Write all
+ // staged content while every source path is still valid; moving first invalidates the
+ // source FileObject and makes CloneableEditorSupport reject a later document edit.
stagedEntries.forEach { entry ->
try {
+ KotlinLogger.INSTANCE.logInfo(
+ "KotlinRefactoringTransaction.commit: writing path=${entry.file.path}, " +
+ "snapshot=${describeText(entry.originalText ?: "")}, " +
+ "before=${describeDocument(entry.document)}, target=${describeText(entry.stagedText!!)}"
+ )
entry.stagedWriter?.invoke(entry.document, entry.originalText ?: "", entry.stagedText!!)
?: writeDocument(entry.file, entry.document, entry.stagedText!!)
+ KotlinLogger.INSTANCE.logInfo(
+ "KotlinRefactoringTransaction.commit: wrote path=${entry.file.path}, " +
+ "after=${describeDocument(entry.document)}"
+ )
} catch (error: Throwable) {
throw Failure(entry.file, error)
}
}
+ // NetBeans retains a FileLock for every modified editor document until it is saved.
+ // Persist every staged document before FileObject.move() attempts to acquire its lock.
+ stagedEntries.forEach { entry -> saveDocument(entry.file) }
+ applyMoves()
+ committed = true
} catch (error: Throwable) {
rollback()
if (error is Failure) throw error
- val failed = stagedEntries.lastOrNull()?.file ?: entries.values.lastOrNull()?.file
+ val failed = stagedEntries.lastOrNull()?.file ?: moves.lastOrNull()?.originalFile ?: entries.values.lastOrNull()?.file
if (failed != null) throw Failure(failed, error)
throw error
}
}
- /** Restores original documents and removes only files this transaction created. */
+ /** Restores original documents, reverses owned physical moves, and removes owned files/folders. */
fun rollback() = restoreOriginalState()
- /** Reverts a successfully committed refactoring using its original snapshots. */
+ /** Reverts a successfully committed refactoring using its original snapshots and paths. */
fun undo() = restoreOriginalState()
- /** Restores existing documents before removing owned files, retaining the first failure. */
+ /** Applies all registered physical moves after every document has been validated. */
+ private fun applyMoves() {
+ moves.forEach { move ->
+ val file = move.movedFile ?: move.originalFile
+ check(file.isValid) { "Move source is invalid: ${move.originalFile.path}" }
+ check(move.targetParent.getFileObject(move.originalName, move.originalExtension) == null) {
+ "Move target already exists: ${move.targetParent.path}/${move.originalFile.nameExt}"
+ }
+ try {
+ file.lock().use { lock ->
+ move.movedFile = file.move(lock, move.targetParent, move.originalName, move.originalExtension)
+ }
+ // FileObject identity changes across a NetBeans move, but the editor document is
+ // intentionally retained. Re-key the snapshot so staged writes and later undo use
+ // the physical object now located at the destination.
+ val entry = entries.remove(file)
+ if (entry != null) entries[move.movedFile!!] = entry.copy(file = move.movedFile!!)
+ movesApplied = true
+ } catch (error: Throwable) {
+ throw Failure(file, error)
+ }
+ }
+ }
+
+ /** Restores documents before paths, retaining the first cleanup failure. */
private fun restoreOriginalState() {
+ if (restoring) return
+ restoring = true
var failure: Throwable? = null
entries.values.filter { it.existedBefore }.forEach { entry ->
- runCatching { writeDocument(entry.file, entry.document, entry.originalText!!) }
- .onFailure { if (failure == null) failure = it }
+ runCatching {
+ KotlinLogger.INSTANCE.logInfo(
+ "KotlinRefactoringTransaction.restore: writing path=${entry.file.path}, " +
+ "snapshot=${describeText(entry.originalText ?: "")}, before=${describeDocument(entry.document)}"
+ )
+ writeDocument(entry.file, entry.document, entry.originalText!!)
+ KotlinLogger.INSTANCE.logInfo(
+ "KotlinRefactoringTransaction.restore: wrote path=${entry.file.path}, " +
+ "after=${describeDocument(entry.document)}"
+ )
+ // Persist every restored document before any path reversal. This both makes rollback
+ // durable if a later move fails and releases the DataEditorSupport file lock.
+ saveDocument(entry.file)
+ KotlinLogger.INSTANCE.logInfo(
+ "KotlinRefactoringTransaction.restore: saved path=${entry.file.path}"
+ )
+ }.onFailure { error ->
+ KotlinLogger.INSTANCE.logException(
+ "KotlinRefactoringTransaction.restore failed for ${entry.file.path}", error
+ )
+ if (failure == null) failure = error
+ }
+ }
+ reverseMoves().onFailure { error ->
+ KotlinLogger.INSTANCE.logException("KotlinRefactoringTransaction.reverseMoves failed", error)
+ if (failure == null) failure = error
+ }
+ // FileObject.move() may replace the on-disk content from its own filesystem state after the
+ // pre-move editor save. Reapply each original snapshot through the FileObject at its final
+ // restored path so Undo persists the package directive, not merely the retained document.
+ entries.values.filter { it.existedBefore }.forEach { entry ->
+ runCatching {
+ val finalDocument = requireDocument(entry.file)
+ KotlinLogger.INSTANCE.logInfo(
+ "KotlinRefactoringTransaction.restoreAfterMoves: writing path=${entry.file.path}, " +
+ "snapshot=${describeText(entry.originalText ?: "")}, before=${describeDocument(finalDocument)}"
+ )
+ writeDocument(entry.file, finalDocument, entry.originalText!!)
+ saveDocument(entry.file)
+ entries[entry.file] = entry.copy(document = finalDocument)
+ KotlinLogger.INSTANCE.logInfo(
+ "KotlinRefactoringTransaction.restoreAfterMoves: saved path=${entry.file.path}, " +
+ "after=${describeDocument(finalDocument)}"
+ )
+ }.onFailure { error ->
+ KotlinLogger.INSTANCE.logException(
+ "KotlinRefactoringTransaction.restoreAfterMoves failed for ${entry.file.path}", error
+ )
+ if (failure == null) failure = error
+ }
}
entries.values.filterNot { it.existedBefore }.forEach { entry ->
runCatching { if (entry.file.isValid) entry.file.delete() }
.onFailure { if (failure == null) failure = it }
}
- failure?.let { throw Failure(entries.values.first().file, it) }
+ ownedFolders.asReversed().forEach { folder ->
+ runCatching { if (folder.isValid && folder.children.isEmpty()) folder.delete() }
+ .onFailure { if (failure == null) failure = it }
+ }
+ committed = false
+ restoring = false
+ failure?.let { throw Failure(entries.values.firstOrNull()?.file ?: moves.first().originalFile, it) }
}
+ /** Moves each successfully moved file back to its original parent and name. */
+ private fun reverseMoves(): Result = runCatching {
+ if (!movesApplied) return@runCatching
+ moves.asReversed().forEach { move ->
+ val moved = move.movedFile ?: return@forEach
+ if (!moved.isValid) return@forEach
+ check(move.originalParent.getFileObject(move.originalName, move.originalExtension) == null) {
+ "Cannot restore moved file because original path is occupied: ${move.originalParent.path}/${move.originalName}.${move.originalExtension}"
+ }
+ KotlinLogger.INSTANCE.logInfo(
+ "KotlinRefactoringTransaction.reverseMoves: moving ${moved.path} to ${move.originalParent.path}, " +
+ "document=${entries[moved]?.let { describeDocument(it.document) } ?: "missing"}"
+ )
+ moved.lock().use { lock ->
+ move.movedFile = moved.move(lock, move.originalParent, move.originalName, move.originalExtension)
+ }
+ val entry = entries.remove(moved)
+ if (entry != null) entries[move.movedFile!!] = entry.copy(file = move.movedFile!!)
+ KotlinLogger.INSTANCE.logInfo(
+ "KotlinRefactoringTransaction.reverseMoves: moved to ${move.movedFile!!.path}, " +
+ "document=${entries[move.movedFile!!]?.let { describeDocument(it.document) } ?: "missing"}"
+ )
+ }
+ movesApplied = false
+ }
+
+ /** Produces bounded, one-line diagnostic text without logging full source contents. */
+ private fun describeText(text: String): String =
+ "length=${text.length}, head=${text.take(160).replace("\n", "\\n")}"
+
+ /** Produces a bounded description of the live document, including the package-header evidence. */
+ private fun describeDocument(document: StyledDocument): String =
+ describeText(document.getText(0, document.length))
+
/** Obtains a document or fails with a clear transaction error. */
private fun requireDocument(file: FileObject): StyledDocument =
openDocument(file) ?: error("Could not open editor document for ${file.path}")
@@ -202,5 +392,10 @@ internal class KotlinRefactoringTransaction internal constructor(
document.insertString(0, text, null)
}
}
+
+ /** Persists a modified editor document so its DataEditorSupport releases the source file lock. */
+ fun saveEditorDocument(file: FileObject) {
+ DataObject.find(file).lookup.lookup(EditorCookie::class.java)?.saveDocument()
+ }
}
}
diff --git a/Nbm/src/main/resources/org/jetbrains/kotlin/layer.xml b/Nbm/src/main/resources/org/jetbrains/kotlin/layer.xml
index 5759e8ec..1a0718ab 100644
--- a/Nbm/src/main/resources/org/jetbrains/kotlin/layer.xml
+++ b/Nbm/src/main/resources/org/jetbrains/kotlin/layer.xml
@@ -111,6 +111,15 @@
+
+
+
+
+
+
+
+
@@ -133,6 +142,14 @@
+
+
+
+
+
+
+
+
@@ -624,6 +641,10 @@
+
+
+
+
diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaMoveFileComputerTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaMoveFileComputerTest.kt
new file mode 100644
index 00000000..5a7915d3
--- /dev/null
+++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaMoveFileComputerTest.kt
@@ -0,0 +1,303 @@
+/*******************************************************************************
+ * Copyright 2026 nbplugins contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ *******************************************************************************/
+package io.github.nbplugins.kotlin.nbm.refactoring
+
+import io.github.nbplugins.kotlin.nbm.resolve.KotlinAnalysisAPISession
+import io.github.nbplugins.kotlin.refactoring.KaMoveDeclarationComputer
+import io.github.nbplugins.kotlin.refactoring.KaMoveFileComputer
+import utils.KotlinTestCase
+import java.nio.file.Files
+import java.nio.file.Path
+
+/**
+ * Unit tests for [KaMoveFileComputer].
+ *
+ * Fixtures are in `projForTest/src/moveFile/`. Each subdirectory contains a Kotlin file whose
+ * source-root-relative package layout is used to verify Move File discovery.
+ */
+class KaMoveFileComputerTest : KotlinTestCase("KaMoveFileComputerTest", "moveFile") {
+
+ /**
+ * Verifies that a Kotlin source file whose package matches its source-root-relative directory
+ * is accepted and declares that its package may be updated after a physical move.
+ */
+ fun testCompute_matchingPackageDirectory_returnsReadyWithPackageUpdate() {
+ val session = KotlinAnalysisAPISession.getSession(project)
+ if (!session.hasDependencies) {
+ println("KaMoveFileComputerTest: skipping — no K2 dependencies available")
+ return
+ }
+ val file = dir.getFileObject("matching.kt") ?: error("Missing matching.kt fixture")
+ val ktFile = session.getKtFileForPath(file.path) ?: error("Could not obtain matching.kt PSI")
+
+ val outcome = KaMoveFileComputer(ktFile).compute()
+
+ assertTrue("Expected Ready for a Kotlin source file, got $outcome", outcome is KaMoveFileComputer.Outcome.Ready)
+ val result = (outcome as KaMoveFileComputer.Outcome.Ready).result
+ assertEquals(file.path, result.sourceFilePath)
+ assertEquals("moveFile", result.packageName)
+ assertTrue("Matching source-root/package layout must permit a package update", result.packageMayBeUpdated)
+ }
+
+ /**
+ * Verifies that a Kotlin source file with a mismatched package and directory remains movable,
+ * but Move File explicitly preserves its package rather than applying a guessed rewrite.
+ */
+ fun testCompute_mismatchedPackageDirectory_preservesPackage() {
+ val session = KotlinAnalysisAPISession.getSession(project)
+ if (!session.hasDependencies) {
+ println("KaMoveFileComputerTest: skipping — no K2 dependencies available")
+ return
+ }
+ val file = dir.getFileObject("mismatch.kt") ?: error("Missing mismatch.kt fixture")
+ val ktFile = session.getKtFileForPath(file.path) ?: error("Could not obtain mismatch.kt PSI")
+
+ val outcome = KaMoveFileComputer(ktFile).compute()
+
+ assertTrue("Expected Ready for a Kotlin source file, got $outcome", outcome is KaMoveFileComputer.Outcome.Ready)
+ val result = (outcome as KaMoveFileComputer.Outcome.Ready).result
+ assertEquals("different.package", result.packageName)
+ assertFalse("Mismatched source-root/package layout must preserve the declared package", result.packageMayBeUpdated)
+ }
+
+ /**
+ * Verifies the lifecycle-free port invokes IDEA's K2MoveFilesHandler semantics: a matching
+ * package is rewritten for the destination and no full IDEA file-move processor is required.
+ */
+ fun testApply_realSession_updatesEligiblePackage() {
+ val stdlib = findStdlibJar() ?: run {
+ println("kotlin-stdlib not on test classpath — skipping Move File integration test")
+ return
+ }
+ val temp = Files.createTempDirectory("nbkotlin-move-file")
+ try {
+ val sourceDirectory = temp.resolve("move/source")
+ val targetDirectory = temp.resolve("move/target")
+ Files.createDirectories(sourceDirectory)
+ Files.createDirectories(targetDirectory)
+ val sourcePath = sourceDirectory.resolve("Moved.kt")
+ Files.writeString(sourcePath, "package move.source\\n\\nfun moved() = 1\\n")
+
+ val session = KotlinAnalysisAPISession.createWithJars(
+ moduleName = "move-file-integration",
+ binaryJars = listOf(stdlib),
+ sourceRoots = listOf(temp),
+ )
+ val source = session.getKtFileForPath(sourcePath.toString()) ?: error("Could not obtain source PSI")
+ val targetPsi = KaMoveDeclarationComputer.resolveDirectory(source.project, targetDirectory.toString())
+ ?: error("Could not resolve target directory PSI")
+
+ val outcome = KaMoveFileComputer(source).apply(
+ files = listOf(source),
+ targetDirectory = targetPsi,
+ targetPackage = org.jetbrains.kotlin.name.FqName("move.target"),
+ updateReferences = true,
+ )
+
+ assertTrue("Expected K2 Move File success, got $outcome", outcome is KaMoveFileComputer.ApplyOutcome.Success)
+ val success = outcome as KaMoveFileComputer.ApplyOutcome.Success
+ assertTrue(
+ "Expected package directive rewritten by K2MoveFilesHandler, got ${success.changedFiles[sourcePath.toString()]}",
+ success.changedFiles[sourcePath.toString()]?.contains("package move.target") == true,
+ )
+ } finally {
+ temp.toFile().deleteRecursively()
+ }
+ }
+
+ /**
+ * Verifies an external Kotlin import is returned for persistence after Move File retargeting.
+ */
+ fun testApply_realSession_returnsRetargetedUsageImport() {
+ val stdlib = findStdlibJar() ?: run {
+ println("kotlin-stdlib not on test classpath — skipping Move File integration test")
+ return
+ }
+ val temp = Files.createTempDirectory("nbkotlin-move-file-usage")
+ try {
+ val sourceDirectory = temp.resolve("move/source")
+ val targetDirectory = temp.resolve("move/target")
+ val usageDirectory = temp.resolve("move/usage")
+ Files.createDirectories(sourceDirectory)
+ Files.createDirectories(targetDirectory)
+ Files.createDirectories(usageDirectory)
+ val sourcePath = sourceDirectory.resolve("Moved.kt")
+ val usagePath = usageDirectory.resolve("Usage.kt")
+ Files.writeString(sourcePath, "package move.source\n\nfun moved() = 1\n")
+ Files.writeString(
+ usagePath,
+ "package move.usage\n\nimport move.source.moved\n\nfun useMoved() = moved()\n",
+ )
+
+ val session = KotlinAnalysisAPISession.createWithJars(
+ moduleName = "move-file-usage-integration",
+ binaryJars = listOf(stdlib),
+ sourceRoots = listOf(temp),
+ )
+ val source = session.getKtFileForPath(sourcePath.toString()) ?: error("Could not obtain source PSI")
+ val targetPsi = KaMoveDeclarationComputer.resolveDirectory(source.project, targetDirectory.toString())
+ ?: error("Could not resolve target directory PSI")
+
+ val outcome = KaMoveFileComputer(source).apply(
+ files = listOf(source),
+ targetDirectory = targetPsi,
+ targetPackage = org.jetbrains.kotlin.name.FqName("move.target"),
+ updateReferences = true,
+ )
+
+ assertTrue("Expected K2 Move File success, got $outcome", outcome is KaMoveFileComputer.ApplyOutcome.Success)
+ val success = outcome as KaMoveFileComputer.ApplyOutcome.Success
+ assertTrue(
+ "Expected changed files to include the retargeted usage, got ${success.changedFiles.keys}",
+ usagePath.toString() in success.changedFiles,
+ )
+ assertEquals(
+ "The source snapshot must precede K2's package mutation",
+ "package move.source\n\nfun moved() = 1\n",
+ success.originalTexts[sourcePath.toString()],
+ )
+ assertEquals(
+ "The usage snapshot must precede K2's import retargeting",
+ "package move.usage\n\nimport move.source.moved\n\nfun useMoved() = moved()\n",
+ success.originalTexts[usagePath.toString()],
+ )
+ assertTrue(
+ "Expected usage import rewritten to the target package, got ${success.changedFiles[usagePath.toString()]}",
+ success.changedFiles[usagePath.toString()]?.contains("import move.target.moved") == true,
+ )
+ } finally {
+ temp.toFile().deleteRecursively()
+ }
+ }
+
+ /**
+ * Verifies a package/directory mismatch is physically movable but retains its declared package
+ * in the K2 result, matching the upstream `K2MoveFilesHandler.needsUpdate` contract.
+ */
+ fun testApply_realSession_preservesMismatchedPackage() {
+ val stdlib = findStdlibJar() ?: run {
+ println("kotlin-stdlib not on test classpath — skipping Move File integration test")
+ return
+ }
+ val temp = Files.createTempDirectory("nbkotlin-move-file-mismatch")
+ try {
+ val sourceDirectory = temp.resolve("layout/source")
+ val targetDirectory = temp.resolve("layout/target")
+ Files.createDirectories(sourceDirectory)
+ Files.createDirectories(targetDirectory)
+ val sourcePath = sourceDirectory.resolve("Mismatch.kt")
+ Files.writeString(sourcePath, "package declared.somewhereElse\\n\\nfun mismatch() = 1\\n")
+
+ val session = KotlinAnalysisAPISession.createWithJars(
+ moduleName = "move-file-mismatch-integration",
+ binaryJars = listOf(stdlib),
+ sourceRoots = listOf(temp),
+ )
+ val source = session.getKtFileForPath(sourcePath.toString()) ?: error("Could not obtain source PSI")
+ val targetPsi = KaMoveDeclarationComputer.resolveDirectory(source.project, targetDirectory.toString())
+ ?: error("Could not resolve target directory PSI")
+
+ val outcome = KaMoveFileComputer(source).apply(
+ files = listOf(source),
+ targetDirectory = targetPsi,
+ targetPackage = org.jetbrains.kotlin.name.FqName("layout.target"),
+ updateReferences = true,
+ )
+
+ assertTrue("Expected K2 Move File success, got $outcome", outcome is KaMoveFileComputer.ApplyOutcome.Success)
+ val success = outcome as KaMoveFileComputer.ApplyOutcome.Success
+ assertTrue(
+ "Mismatched package must be preserved, got ${success.changedFiles[sourcePath.toString()]}",
+ success.changedFiles[sourcePath.toString()]?.contains("package declared.somewhereElse") == true,
+ )
+ } finally {
+ temp.toFile().deleteRecursively()
+ }
+ }
+
+ /**
+ * Verifies one K2 pass supports distinct target packages for directory descendants and retargets
+ * imports of declarations from both source files.
+ */
+ fun testApply_realSession_updatesNestedDirectoryTargetsTogether() {
+ val stdlib = findStdlibJar() ?: run {
+ println("kotlin-stdlib not on test classpath — skipping Move Directory integration test")
+ return
+ }
+ val temp = Files.createTempDirectory("nbkotlin-move-directory")
+ try {
+ val feature = temp.resolve("source/feature")
+ val internal = feature.resolve("internal")
+ val usageDirectory = temp.resolve("usage")
+ val targetFeature = temp.resolve("target/sample/destination/feature")
+ val targetInternal = targetFeature.resolve("internal")
+ Files.createDirectories(internal)
+ Files.createDirectories(usageDirectory)
+ Files.createDirectories(targetInternal)
+ val publicPath = feature.resolve("Public.kt")
+ val helperPath = internal.resolve("Helper.kt")
+ val usagePath = usageDirectory.resolve("Usage.kt")
+ Files.writeString(publicPath, "package source.feature\n\nfun publicApi() = helper()\n")
+ Files.writeString(helperPath, "package source.feature.internal\n\nfun helper() = 1\n")
+ Files.writeString(
+ usagePath,
+ "package usage\n\nimport source.feature.publicApi\nimport source.feature.internal.helper\n\nfun use() = publicApi() + helper()\n",
+ )
+ val session = KotlinAnalysisAPISession.createWithJars(
+ moduleName = "move-directory-integration",
+ binaryJars = listOf(stdlib),
+ sourceRoots = listOf(temp),
+ )
+ val publicFile = session.getKtFileForPath(publicPath.toString()) ?: error("Could not obtain Public.kt PSI")
+ val helperFile = session.getKtFileForPath(helperPath.toString()) ?: error("Could not obtain Helper.kt PSI")
+ val targetFeaturePsi = KaMoveDeclarationComputer.resolveDirectory(publicFile.project, targetFeature.toString())
+ ?: error("Could not resolve feature target PSI")
+ val targetInternalPsi = KaMoveDeclarationComputer.resolveDirectory(publicFile.project, targetInternal.toString())
+ ?: error("Could not resolve internal target PSI")
+
+ val outcome = KaMoveFileComputer(publicFile).apply(
+ mapOf(
+ publicFile to KaMoveFileComputer.MoveTarget(
+ targetFeaturePsi,
+ org.jetbrains.kotlin.name.FqName("sample.destination.feature"),
+ ),
+ helperFile to KaMoveFileComputer.MoveTarget(
+ targetInternalPsi,
+ org.jetbrains.kotlin.name.FqName("sample.destination.feature.internal"),
+ ),
+ ),
+ updateReferences = true,
+ )
+
+ assertTrue("Expected K2 Move Directory success, got $outcome", outcome is KaMoveFileComputer.ApplyOutcome.Success)
+ val success = outcome as KaMoveFileComputer.ApplyOutcome.Success
+ assertTrue(success.changedFiles[publicPath.toString()]?.contains("package sample.destination.feature") == true)
+ assertTrue(success.changedFiles[helperPath.toString()]?.contains("package sample.destination.feature.internal") == true)
+ assertTrue(success.changedFiles[usagePath.toString()]?.contains("import sample.destination.feature.publicApi") == true)
+ assertTrue(success.changedFiles[usagePath.toString()]?.contains("import sample.destination.feature.internal.helper") == true)
+ } finally {
+ temp.toFile().deleteRecursively()
+ }
+ }
+
+ /** Locates Kotlin stdlib supplied by the Maven test runtime. */
+ private fun findStdlibJar(): Path? = System.getProperty("java.class.path")
+ .split(System.getProperty("path.separator"))
+ .map { Path.of(it) }
+ .firstOrNull { it.fileName?.toString()?.startsWith("kotlin-stdlib") == true && it.toFile().exists() }
+}
+
diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveDirectoryActionTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveDirectoryActionTest.kt
new file mode 100644
index 00000000..49335357
--- /dev/null
+++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveDirectoryActionTest.kt
@@ -0,0 +1,36 @@
+/*******************************************************************************
+ * Copyright 2026 nbplugins contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ *******************************************************************************/
+package io.github.nbplugins.kotlin.nbm.refactoring
+
+import org.netbeans.junit.NbTestCase
+
+/** Unit tests for [KotlinMoveDirectoryAction]. */
+class KotlinMoveDirectoryActionTest : NbTestCase("KotlinMoveDirectoryActionTest") {
+
+ /** Verifies source discovery remains asynchronous while the action explicitly marshals UI later. */
+ fun testAsynchronous_allowsBackgroundSourceDiscovery() {
+ assertTrue(
+ "K2 source discovery may run in NetBeans' RequestProcessor before UI is marshalled to EDT",
+ TestAction().isAsynchronous(),
+ )
+ }
+
+ /** Exposes NodeAction's protected dispatcher contract for this focused test. */
+ private class TestAction : KotlinMoveDirectoryAction() {
+ fun isAsynchronous(): Boolean = asynchronous()
+ }
+}
diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFileRefactoringTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFileRefactoringTest.kt
new file mode 100644
index 00000000..dc444e9d
--- /dev/null
+++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFileRefactoringTest.kt
@@ -0,0 +1,38 @@
+/*******************************************************************************
+ * Copyright 2026 nbplugins contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ *******************************************************************************/
+package io.github.nbplugins.kotlin.nbm.refactoring
+
+import org.netbeans.junit.NbTestCase
+import org.openide.filesystems.FileUtil
+import org.jetbrains.kotlin.refactorings.rename.KotlinRefactoringsFactory
+
+/** Unit tests for [KotlinMoveFileRefactoring]'s plugin-factory applicability. */
+class KotlinMoveFileRefactoringTest : NbTestCase("KotlinMoveFileRefactoringTest") {
+
+ /** Verifies folder-context sources create the Kotlin Move File plugin without an editor document. */
+ fun testFactory_createsPluginForDirectorySelectionWithoutStyledDocument() {
+ val root = FileUtil.createMemoryFileSystem().root
+ val folder = root.createFolder("feature")
+ folder.createData("Public.kt")
+ val selection = KotlinMoveSourceSelection.from(folder) ?: error("Expected Kotlin selection")
+ val refactoring = KotlinMoveFileRefactoring(selection)
+
+ val plugin = KotlinRefactoringsFactory().createInstance(refactoring)
+
+ assertTrue("Move Directory must not be rejected by the editor-document applicability guard", plugin is KotlinMoveFilePlugin)
+ }
+}
diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveSourceSelectionTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveSourceSelectionTest.kt
new file mode 100644
index 00000000..d5a3b735
--- /dev/null
+++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveSourceSelectionTest.kt
@@ -0,0 +1,79 @@
+/*******************************************************************************
+ * Copyright 2026 nbplugins contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ *******************************************************************************/
+package io.github.nbplugins.kotlin.nbm.refactoring
+
+import org.netbeans.junit.NbTestCase
+import org.openide.filesystems.FileObject
+import org.openide.filesystems.FileUtil
+
+/** Unit tests for [KotlinMoveSourceSelection]. */
+class KotlinMoveSourceSelectionTest : NbTestCase("KotlinMoveSourceSelectionTest") {
+
+ /** Verifies a selected directory recursively includes only Kotlin source files in path order. */
+ fun testFrom_directoryCollectsOnlyKotlinFilesInDeterministicOrder() {
+ val root = FileUtil.createMemoryFileSystem().root
+ val feature = root.createFolder("feature")
+ feature.createData("Readme.txt")
+ val api = feature.createFolder("api")
+ val apiFile = api.createData("Api.kt")
+ val internal = feature.createFolder("internal")
+ val helper = internal.createData("Helper.kt")
+ feature.createFolder("empty")
+
+ val selection = KotlinMoveSourceSelection.from(feature) ?: error("Expected Kotlin directory selection")
+
+ assertTrue(selection.isDirectory)
+ assertEquals("feature", selection.displayName)
+ assertEquals(listOf(apiFile.path, helper.path), selection.sourceFiles.map(FileObject::getPath))
+ }
+
+ /** Verifies a directory target retains its own name and each nested relative parent path. */
+ fun testTargetRelativeFolderSegments_directoryRetainsDirectoryAndNestedParent() {
+ val root = FileUtil.createMemoryFileSystem().root
+ val feature = root.createFolder("feature")
+ val internal = feature.createFolder("internal")
+ val helper = internal.createData("Helper.kt")
+ val rootFile = feature.createData("Public.kt")
+ val selection = KotlinMoveSourceSelection.from(feature) ?: error("Expected selection")
+
+ assertEquals(listOf("feature", "internal"), selection.targetRelativeFolderSegments(helper))
+ assertEquals(listOf("feature"), selection.targetRelativeFolderSegments(rootFile))
+ }
+
+ /** Verifies a direct Kotlin-file selection has no retained directory hierarchy. */
+ fun testFrom_fileCreatesSingleFileSelectionWithoutRelativeFolder() {
+ val root = FileUtil.createMemoryFileSystem().root
+ val file = root.createData("Single.kt")
+
+ val selection = KotlinMoveSourceSelection.from(file) ?: error("Expected Kotlin file selection")
+
+ assertFalse(selection.isDirectory)
+ assertEquals(listOf(file), selection.sourceFiles)
+ assertEquals(emptyList(), selection.targetRelativeFolderSegments(file))
+ }
+
+ /** Verifies folders without Kotlin descendants and non-Kotlin files cannot start the refactoring. */
+ fun testFrom_nonKotlinSourceReturnsNull() {
+ val root = FileUtil.createMemoryFileSystem().root
+ val folder = root.createFolder("resources")
+ folder.createData("config.json")
+ val text = root.createData("Notes.txt")
+
+ assertNull(KotlinMoveSourceSelection.from(folder))
+ assertNull(KotlinMoveSourceSelection.from(text))
+ }
+}
diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRefactoringTransactionTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRefactoringTransactionTest.kt
index 7f2abfd3..463f943b 100644
--- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRefactoringTransactionTest.kt
+++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRefactoringTransactionTest.kt
@@ -54,6 +54,13 @@ class KotlinRefactoringTransactionTest : NbTestCase("KotlinRefactoringTransactio
return file
}
+ /** Creates an existing file and document beneath [parent]. */
+ private fun Fixture.existingIn(parent: FileObject, name: String, text: String): FileObject {
+ val file = parent.createData(name)
+ documents[file] = DefaultStyledDocument().also { it.insertString(0, text, null) }
+ return file
+ }
+
/** Reads the in-memory document associated with [file]. */
private fun Fixture.text(file: FileObject): String {
val document = documents[file]
@@ -61,6 +68,15 @@ class KotlinRefactoringTransactionTest : NbTestCase("KotlinRefactoringTransactio
return document!!.getText(0, document.length)
}
+ /** Finds the retained editor document after a FileObject changed identity through a move. */
+ private fun Fixture.textByPath(file: FileObject): String {
+ val document = documents.entries.firstOrNull { (knownFile, _) ->
+ knownFile.path == file.path || !knownFile.isValid
+ }?.value
+ assertNotNull("Expected retained document for ${file.path}", document)
+ return document!!.getText(0, document.length)
+ }
+
/** Verifies commit changes every staged existing document and undo restores every snapshot. */
fun testCommitAndUndo_restoresEveryExistingDocument() {
val fixture = fixture()
@@ -82,6 +98,205 @@ class KotlinRefactoringTransactionTest : NbTestCase("KotlinRefactoringTransactio
assertEquals("fun target() = 2\n", fixture.text(target))
}
+ /** Verifies an externally captured pre-mutation snapshot is restored by undo. */
+ fun testCommitAndUndo_restoresProvidedPreMutationSnapshot() {
+ val fixture = fixture()
+ val source = fixture.existing("Source.kt", "package target\n\nfun source() = 1\n")
+ val original = "package source\n\nfun source() = 1\n"
+
+ fixture.transaction.captureExisting(source, original)
+ fixture.transaction.stageText(source, "package target\n\nfun source() = 1\n")
+ fixture.transaction.commit()
+ fixture.transaction.undo()
+
+ assertEquals("undo must restore the snapshot from before K2 PSI mutation", original, fixture.text(source))
+ }
+
+ /** Verifies several directory descendants move and undo together in reverse physical-move order. */
+ fun testCommitAndUndo_restoresEveryMovedDirectoryDescendant() {
+ val fixture = fixture()
+ val sourceFolder = fixture.root.createFolder("feature")
+ val nestedSource = sourceFolder.createFolder("internal")
+ val targetFolder = fixture.root.createFolder("target")
+ val nestedTarget = targetFolder.createFolder("internal")
+ val publicFile = fixture.existingIn(sourceFolder, "Public.kt", "package feature\n\nfun publicApi() = 1\n")
+ val helperFile = fixture.existingIn(nestedSource, "Helper.kt", "package feature.internal\n\nfun helper() = 1\n")
+ val retainedText = sourceFolder.createData("Readme.txt")
+
+ fixture.transaction.moveFile(publicFile, targetFolder)
+ fixture.transaction.moveFile(helperFile, nestedTarget)
+ fixture.transaction.stageText(publicFile, "package target\n\nfun publicApi() = 1\n")
+ fixture.transaction.stageText(helperFile, "package target.internal\n\nfun helper() = 1\n")
+ fixture.transaction.commit()
+
+ assertNotNull(targetFolder.getFileObject("Public.kt"))
+ assertNotNull(nestedTarget.getFileObject("Helper.kt"))
+ assertTrue("non-Kotlin source content must remain untouched", retainedText.isValid)
+ fixture.transaction.undo()
+
+ assertNotNull(sourceFolder.getFileObject("Public.kt"))
+ assertNotNull(nestedSource.getFileObject("Helper.kt"))
+ assertNull(targetFolder.getFileObject("Public.kt"))
+ assertNull(nestedTarget.getFileObject("Helper.kt"))
+ assertTrue("non-Kotlin source content must remain after undo", retainedText.isValid)
+ }
+
+ /** Verifies a physical move is committed and Undo Last Refactoring restores its path and text. */
+ fun testCommitAndUndo_restoresMovedFilePathAndText() {
+ val fixture = fixture()
+ val sourceFolder = fixture.root.createFolder("source")
+ val targetFolder = fixture.root.createFolder("target")
+ val source = fixture.existingIn(sourceFolder, "Moved.kt", "package source\n\nfun moved() = 1\n")
+
+ fixture.transaction.moveFile(source, targetFolder)
+ fixture.transaction.stageText(source, "package target\n\nfun moved() = 1\n")
+ fixture.transaction.commit()
+
+ val moved = targetFolder.getFileObject("Moved.kt")
+ assertNotNull("commit must physically move the file", moved)
+ assertNull("source path must no longer exist", sourceFolder.getFileObject("Moved.kt"))
+ assertEquals("package target\n\nfun moved() = 1\n", fixture.textByPath(moved!!))
+
+ fixture.transaction.undo()
+
+ val restored = sourceFolder.getFileObject("Moved.kt")
+ assertNotNull("undo must restore the original source path", restored)
+ assertNull("undo must remove the moved target path", targetFolder.getFileObject("Moved.kt"))
+ assertEquals("package source\n\nfun moved() = 1\n", fixture.textByPath(restored!!))
+ }
+
+ /** Verifies undo writes the original snapshot through the FileObject returned by the reverse move. */
+ fun testUndo_rewritesSnapshotAtRestoredFilePath() {
+ val root = FileUtil.createMemoryFileSystem().root
+ val sourceFolder = root.createFolder("source")
+ val targetFolder = root.createFolder("target")
+ val source = sourceFolder.createData("Moved.kt")
+ val original = "package source\n\nfun moved() = 1\n"
+ val target = "package target\n\nfun moved() = 1\n"
+ val documents = mutableMapOf()
+ val persistedTexts = mutableMapOf(source.path to original)
+ val transaction = KotlinRefactoringTransaction(
+ openDocument = { file -> documents.getOrPut(file) { DefaultStyledDocument().also { it.insertString(0, persistedTexts[file.path] ?: "", null) } } },
+ writeDocument = { file, document, text ->
+ if (document.length > 0) document.remove(0, document.length)
+ document.insertString(0, text, null)
+ persistedTexts[file.path] = text
+ },
+ saveDocument = { },
+ )
+
+ transaction.moveFile(source, targetFolder)
+ transaction.captureExisting(source, original)
+ transaction.stageText(source, target)
+ transaction.commit()
+ val moved = targetFolder.getFileObject("Moved.kt") ?: error("Expected moved file")
+ persistedTexts[moved.path] = target
+ transaction.undo()
+
+ val restored = sourceFolder.getFileObject("Moved.kt") ?: error("Expected restored file")
+ assertEquals("undo must persist the snapshot at the restored physical path", original, persistedTexts[restored.path])
+ }
+
+ /** Verifies a later write failure prevents the physical move and restores source text. */
+ fun testCommitFailure_doesNotMoveFileBeforeEveryDocumentIsWritten() {
+ val fixture = fixture(failWriting = { it.nameExt == "Failing.kt" })
+ val sourceFolder = fixture.root.createFolder("source")
+ val targetFolder = fixture.root.createFolder("target")
+ val source = fixture.existingIn(sourceFolder, "Moved.kt", "fun moved() = 1\n")
+ val failing = fixture.existing("Failing.kt", "fun failing() = 1\n")
+
+ fixture.transaction.moveFile(source, targetFolder)
+ fixture.transaction.stageText(source, "fun moved() = 2\n")
+ fixture.transaction.captureExisting(failing)
+ fixture.transaction.stageText(failing, "fun failing() = 2\n")
+
+ try {
+ fixture.transaction.commit()
+ fail("commit must fail when a staged write fails")
+ } catch (_: KotlinRefactoringTransaction.Failure) {
+ // Expected: no physical move occurs until every staged document is writable.
+ }
+
+ val restored = sourceFolder.getFileObject("Moved.kt")
+ assertNotNull("failed document writes must leave the source path intact", restored)
+ assertNull("failed document writes must not create the target path", targetFolder.getFileObject("Moved.kt"))
+ assertEquals("fun moved() = 1\n", fixture.textByPath(restored!!))
+ assertEquals("fun failing() = 1\n", fixture.text(failing))
+ }
+
+ /** Verifies every staged document is written and saved before the source file is moved. */
+ fun testCommit_writesAndSavesEveryDocumentBeforePhysicalMove() {
+ val root = FileUtil.createMemoryFileSystem().root
+ val sourceFolder = root.createFolder("source")
+ val targetFolder = root.createFolder("target")
+ val source = sourceFolder.createData("Moved.kt")
+ val usage = root.createData("Usage.kt")
+ val sourceDocument = DefaultStyledDocument().also { it.insertString(0, "fun moved() = 1\n", null) }
+ val usageDocument = DefaultStyledDocument().also { it.insertString(0, "fun usage() = moved()\n", null) }
+ val documents = mapOf(source to sourceDocument, usage to usageDocument)
+ val savedFiles = mutableListOf()
+ val transaction = KotlinRefactoringTransaction(
+ openDocument = { file -> documents[file] },
+ writeDocument = { _, editableDocument, text ->
+ if (editableDocument.length > 0) editableDocument.remove(0, editableDocument.length)
+ editableDocument.insertString(0, text, null)
+ },
+ saveDocument = { file ->
+ assertNotNull("a moved file must remain at its original path until saves complete", sourceFolder.getFileObject("Moved.kt"))
+ savedFiles += file
+ },
+ )
+
+ transaction.moveFile(source, targetFolder)
+ transaction.stageText(source, "fun moved() = 2\n")
+ transaction.captureExisting(usage)
+ transaction.stageText(usage, "fun usage() = moved()\n")
+ transaction.commit()
+
+ assertEquals("all staged documents must be saved before the move", listOf(source, usage), savedFiles)
+ assertNotNull("commit must still move the file after saving", targetFolder.getFileObject("Moved.kt"))
+ }
+
+ /** Verifies undo saves the moved source before restoring its original path. */
+ fun testUndo_savesMovedSourceBeforePhysicalRestore() {
+ val root = FileUtil.createMemoryFileSystem().root
+ val sourceFolder = root.createFolder("source")
+ val targetFolder = root.createFolder("target")
+ val source = sourceFolder.createData("Moved.kt")
+ val document = DefaultStyledDocument().also { it.insertString(0, "fun moved() = 1\n", null) }
+ var savedMovedSourceBeforeRestore = false
+ val transaction = KotlinRefactoringTransaction(
+ openDocument = { document },
+ writeDocument = { _, editableDocument, text ->
+ if (editableDocument.length > 0) editableDocument.remove(0, editableDocument.length)
+ editableDocument.insertString(0, text, null)
+ },
+ saveDocument = { file ->
+ if (file.parent == targetFolder) {
+ savedMovedSourceBeforeRestore = sourceFolder.getFileObject("Moved.kt") == null
+ }
+ },
+ )
+
+ transaction.moveFile(source, targetFolder)
+ transaction.stageText(source, "fun moved() = 2\n")
+ transaction.commit()
+ transaction.undo()
+
+ assertTrue("undo must save the moved source before restoring its original path", savedMovedSourceBeforeRestore)
+ assertNotNull("undo must restore the original path", sourceFolder.getFileObject("Moved.kt"))
+ }
+
+ /** Verifies empty destination folders created by the transaction are deleted on rollback. */
+ fun testRollback_deletesTransactionOwnedEmptyFolder() {
+ val fixture = fixture()
+ val destination = fixture.transaction.createFolder(fixture.root, "generated")
+
+ fixture.transaction.rollback()
+
+ assertFalse("rollback must delete an unused transaction-owned folder", destination.isValid())
+ }
+
/** Verifies an owned target file is retained at commit and deleted by undo. */
fun testUndo_deletesTransactionCreatedFile() {
val fixture = fixture()
diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/RefactoringCoverageBaselineTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/RefactoringCoverageBaselineTest.kt
index b10a831f..78aed07f 100644
--- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/RefactoringCoverageBaselineTest.kt
+++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/RefactoringCoverageBaselineTest.kt
@@ -54,10 +54,10 @@ class RefactoringCoverageBaselineTest : NbTestCase("RefactoringCoverageBaselineT
}
}
- val totals = "**Baseline counts:** 0 complete, 18 partial, 6 absent, 24 total."
+ val totals = "**Baseline counts:** 0 complete, 19 partial, 5 absent, 24 total."
assertTrue("Visible baseline totals must match the records", Files.readString(matrix).contains(totals))
- assertEquals(18, records.count { it.status == "partial" })
- assertEquals(6, records.count { it.status == "absent" })
+ assertEquals(19, records.count { it.status == "partial" })
+ assertEquals(5, records.count { it.status == "absent" })
}
/** Finds the checkout root from Surefire's module working directory. */
diff --git a/Nbm/src/test/resources/projForTest/src/moveFile/matching.kt b/Nbm/src/test/resources/projForTest/src/moveFile/matching.kt
new file mode 100644
index 00000000..e31cf917
--- /dev/null
+++ b/Nbm/src/test/resources/projForTest/src/moveFile/matching.kt
@@ -0,0 +1,3 @@
+package moveFile
+
+fun matching() = Unit
diff --git a/Nbm/src/test/resources/projForTest/src/moveFile/mismatch.kt b/Nbm/src/test/resources/projForTest/src/moveFile/mismatch.kt
new file mode 100644
index 00000000..f07f8c6c
--- /dev/null
+++ b/Nbm/src/test/resources/projForTest/src/moveFile/mismatch.kt
@@ -0,0 +1,3 @@
+package different.package
+
+fun mismatch() = Unit
diff --git a/docs/development-plan.md b/docs/development-plan.md
index 65501125..fea05930 100644
--- a/docs/development-plan.md
+++ b/docs/development-plan.md
@@ -658,7 +658,8 @@ Priority order: E1 → E2 → E3 → E4 → E5 → E6 → E7 → E8 → E9 → E
implicit-package-prefix, module-dependency, internal-member-usage, actual/expect, plus
helpers). Excluded (out of scope): `K2MoveHandler.kt` (IDE action dispatch), `ui/**` (Swing —
NB has its own text-field UI), `K2MoveFilesOrDirectoriesRefactoringProcessor.kt` (file/dir
- moves), `K2ChangePackageDescriptor.kt`/processor (separate feature).
+ moves; F4.1 now ports its Kotlin handler through a lifecycle-free adapter),
+ `K2ChangePackageDescriptor.kt`/processor (separate F4.2 feature).
- Single-module architecture: this plugin has no multi-module/multiplatform project model
anywhere (confirmed via dedicated research — every NetBeans project gets one isolated K2
analysis session). `SingletonModule`/`ModuleUtilCore`/`ModuleType` stubs
@@ -742,6 +743,27 @@ Priority order: E1 → E2 → E3 → E4 → E5 → E6 → E7 → E8 → E9 → E
is removed on any conflict/error, an existing target is restored exactly, and **Refactor → Undo
Last Refactoring** restores both documents (or removes the target file if the refactoring created it).
+ - [x] **F4.1** — Move Kotlin File and Directory (Refactor menu / folder context menu)
+ - Ports the Kotlin-specific `K2MoveFilesHandler` portion of
+ `K2MoveFilesOrDirectoriesRefactoringProcessor.kt` through `KaMoveFileComputer`. The generic
+ IDEA processor is intentionally not invoked: it depends on writable IntelliJ VFS, indexed
+ generic-file search, command/listener lifecycle, and modal UI unavailable in standalone K2.
+ - **Refactor → Move Kotlin File...** selects a destination source root and package. NetBeans owns
+ physical movement through `KotlinRefactoringTransaction`; the K2 adapter updates a package only
+ where source-root-relative package/directory alignment proves it safe, checks portable Kotlin
+ conflicts, and retargets supported Kotlin references.
+ - Transactions now record reversible physical file moves and transaction-owned destination folders;
+ failed writes roll back the path and document snapshots, while **Undo Last Refactoring** restores
+ the original path and text.
+ - Folder-node **Move Kotlin Directory...** recursively moves only Kotlin descendants through one
+ atomic transaction, retaining the selected directory and every nested Kotlin folder below the
+ target package; a single K2 pass uses per-file target packages so inter-file references remain
+ coherent. Non-Kotlin descendants remain at their source paths.
+ - Portable coverage includes matching-package update, package/directory mismatch preservation,
+ K2 semantic adapter execution, physical-move rollback, owned-folder cleanup, and undo. The
+ remaining F4.1 scope is generic non-Kotlin content, Java/comment/text/index-only references,
+ and full IDEA project-model conflict behavior.
+
- [x] **E9.8** — Change Signature (Ctrl+F6) — PRs #114, #115
- IDEA sources: `changeSignature/KotlinChangeSignatureUsageSearcher.kt`,
`changeSignature/KotlinChangeInfo.kt`, `changeSignature/KotlinChangeInfoBase.kt`,
diff --git a/docs/manual-test-move-kotlin-file.md b/docs/manual-test-move-kotlin-file.md
new file mode 100644
index 00000000..c2ec1d8a
--- /dev/null
+++ b/docs/manual-test-move-kotlin-file.md
@@ -0,0 +1,149 @@
+# Ручное тестирование: Move Kotlin File
+
+Этот план проверяет refactoring **Move Kotlin File** (F4.1) и исправление отображения source root.
+
+## Подготовка
+
+1. Соберите плагин и установите созданный `.nbm` в тестовый NetBeans.
+2. Перезапустите NetBeans.
+3. Откройте или создайте Kotlin-проект с исходным корнем `src/main/kotlin` либо `src/main/java`.
+4. Для проверок обновления ссылок используйте не менее двух Kotlin-файлов, один из которых импортирует или использует объявление из перемещаемого файла.
+
+## 1. Доступность команды
+
+1. Откройте Kotlin-файл в редакторе.
+2. Поместите курсор в файл без выделения текста.
+3. Выберите **Refactor → Move Kotlin File**.
+
+**Ожидаемый результат:** открывается диалог Move Kotlin File; команда доступна для Kotlin-файла в редакторе.
+
+## 2. Отображение Source Root
+
+1. В открытом диалоге раскройте список **Source Root**.
+2. Если в проекте несколько source root, проверьте каждый элемент.
+3. Закройте список и проверьте выбранное значение.
+
+**Ожидаемый результат:** список и выбранное значение показывают краткие проектные имена, например `Source Packages`. Не должны отображаться технические строки вида `KotlinPackageTargetRoot(path=..., displayName=..., folder=...)`.
+
+## 3. Перемещение в корректный пакет с обновлением ссылок
+
+1. Создайте файл `sample/source/Greeting.kt`:
+ ```kotlin
+ package sample.source
+
+ class Greeting
+ ```
+2. Создайте использующий его файл, например `sample/client/UseGreeting.kt`:
+ ```kotlin
+ package sample.client
+
+ import sample.source.Greeting
+
+ val greeting = Greeting()
+ ```
+3. Откройте `Greeting.kt`, выберите **Refactor → Move Kotlin File**.
+4. Выберите нужный Source Root, укажите пакет `sample.target`.
+5. Оставьте включённым **Update Kotlin references** и выполните рефакторинг.
+
+**Ожидаемый результат:**
+
+- файл перемещён в каталог `sample/target` выбранного source root;
+- в `Greeting.kt` пакет изменён на `package sample.target`;
+- импорт в `UseGreeting.kt` изменён на `import sample.target.Greeting`;
+- проект остаётся без ошибок компиляции, использование `Greeting()` разрешается.
+
+## 4. Undo Last Refactoring
+
+1. После успешного перемещения выберите **Refactor → Undo Last Refactoring**.
+2. Проверьте оба файла и их расположение.
+
+**Ожидаемый результат:**
+
+- `Greeting.kt` возвращён в исходный каталог;
+- исходная директива `package sample.source` восстановлена;
+- импорт в использующем файле снова указывает на `sample.source.Greeting`;
+- состояние редакторов и проекта согласовано.
+
+## 5. Перемещение в другой source root
+
+1. Добавьте в проект второй source root, например `src/test/kotlin`, если его ещё нет.
+2. Откройте `Greeting.kt` и запустите Move Kotlin File.
+3. В поле **Source Root** выберите второй корень.
+4. Укажите пакет `sample.target` и выполните рефакторинг.
+
+**Ожидаемый результат:** файл появляется в `sample/target` именно выбранного source root; значение Source Root остаётся читаемым; package declaration соответствует заданному пакету.
+
+## 6. Несоответствие package и каталога
+
+1. Создайте Kotlin-файл в каталоге, который не соответствует его `package`, например:
+ - путь: `sample/wrong/Unexpected.kt`;
+ - содержимое: `package sample.declared`.
+2. Переместите файл в пакет `sample.target`.
+
+**Ожидаемый результат:** операция задаёт итоговое расположение по выбранному target package и обновляет package declaration на `sample.target`; она не зависит от исходного несовпадения package и каталога.
+
+## 7. Конфликт имени файла
+
+1. В целевом каталоге заранее создайте файл с тем же именем, например `sample/target/Greeting.kt`.
+2. Попробуйте переместить туда исходный `Greeting.kt`.
+
+**Ожидаемый результат:** рефакторинг сообщает о конфликте и не повреждает ни исходный, ни уже существующий целевой файл.
+
+## 8. Отключение обновления Kotlin-ссылок
+
+1. Подготовьте файл с Kotlin-импортом перемещаемого класса, как в разделе 3.
+2. В диалоге Move Kotlin File снимите флажок **Update Kotlin references**.
+3. Переместите файл в другой пакет.
+
+**Ожидаемый результат:** файл и его package declaration перемещены/обновлены, но импорт или ссылки в других Kotlin-файлах автоматически не меняются. Неразрешённая ссылка допустима как ожидаемое следствие отключённой опции.
+
+## 9. Откат при ошибке записи
+
+1. Подготовьте операцию, для которой целевой каталог недоступен для записи, либо воспроизведите другую контролируемую ошибку файловой системы без риска потери данных.
+2. Запустите перемещение.
+
+**Ожидаемый результат:** при ошибке операция прекращается корректно; исходный файл, его package declaration и ранее существующие целевые файлы не остаются в частично изменённом состоянии.
+
+## 10. Перемещение каталога Kotlin
+
+1. Создайте каталог `sample/source/feature` с файлами `Public.kt` и `internal/Helper.kt`:
+ ```kotlin
+ // Public.kt
+ package sample.source.feature
+
+ fun publicApi() = helper()
+
+ // internal/Helper.kt
+ package sample.source.feature.internal
+
+ fun helper() = 1
+ ```
+2. Добавьте рядом не-Kotlin файл `sample/source/feature/README.txt`.
+3. В дереве Projects или Files щёлкните правой кнопкой по каталогу `feature` и выберите
+ **Move Kotlin Directory...**.
+4. Выберите source root, укажите пакет `sample.target`, оставьте обновление Kotlin-ссылок включённым
+ и выполните рефакторинг.
+
+**Ожидаемый результат:**
+
+- `Public.kt` перемещён в `sample/target/feature` и имеет `package sample.target.feature`;
+- `Helper.kt` перемещён в `sample/target/feature/internal` и имеет
+ `package sample.target.feature.internal`;
+- ссылки и импорты Kotlin на оба объявления обновлены;
+- `README.txt` остаётся по исходному пути `sample/source/feature/README.txt`;
+- **Undo Last Refactoring** возвращает оба Kotlin-файла, их package directives и ссылки, не удаляя
+ исходный каталог с `README.txt`.
+
+## Неподдерживаемые сценарии
+
+Следующие сценарии не входят в F4.1 и не являются критериями приёмки:
+
+- запуск Move Kotlin File из контекстного меню узла файла (для одного файла используйте команду в редакторе);
+- перемещение не-Kotlin файлов;
+- обновление Java usages;
+- обновление упоминаний в комментариях, строках и произвольном тексте;
+- сценарии, требующие IDEA index-backed filesystem или IDEA project model.
+
+## Результат проверки
+
+Зафиксируйте для каждого раздела: **пройдено**, **не пройдено** либо **не применимо**, а для непрохождения приложите минимальный пример проекта и шаги воспроизведения.
diff --git a/docs/refactoring-coverage.md b/docs/refactoring-coverage.md
index c7d46e60..5de7b62b 100644
--- a/docs/refactoring-coverage.md
+++ b/docs/refactoring-coverage.md
@@ -28,7 +28,7 @@ netbeans=; test=; milestone=
-
+
@@ -48,7 +48,7 @@ The corresponding record comments above are deliberately machine-readable. `Refa
| **Absent** | IDEA exposes the Kotlin refactoring family but the NetBeans plugin has no corresponding command. |
| **Not applicable** | IDEA-only integration which is not a Kotlin refactoring target for this plugin. No such rows are currently tracked. |
-**Baseline counts:** 0 complete, 18 partial, 6 absent, 24 total.
+**Baseline counts:** 0 complete, 19 partial, 5 absent, 24 total.
## Current NetBeans refactorings
@@ -68,6 +68,7 @@ The corresponding record comments above are deliberately machine-readable. `Refa
| `introduce-parameter` | Introduce Parameter | `Ctrl+Alt+P` | Partial | Change Signature pipeline updates callers through a transaction that rolls every touched file back on failure; context parameters and adding an absent primary constructor remain. | F6 |
| `introduce-functional-parameter` | Introduce Functional Parameter | `Ctrl+Alt+Shift+P` | Partial | K2 extraction/Change Signature path covers single expressions with transactional multi-file persistence; multi-statement and idiomatic lambda cases remain. | F6 |
| `move-declaration` | Move top-level declaration | Refactor → Move Declaration | Partial | Real K2 move/retargeting engine is used; source/target mutations now roll back atomically and support Undo Last Refactoring, while only top-level declarations are exposed. | F1 |
+| `move-file` | Move Kotlin file/directory | Refactor → Move Kotlin File / folder → Move Kotlin Directory | Partial | Moves one Kotlin file or recursively moves Kotlin descendants of a selected folder to a selectable source root/package, retaining the selected folder and nested hierarchy; matching package directives and supported Kotlin code references are updated, and Undo Last Refactoring restores every original path/text. Generic non-Kotlin content, Java/comment/text/index-only references, and full IDEA filesystem behavior remain. | F4 |
| `copy-declaration` | Copy declaration | Refactor → Copy / F5 | Partial | Top-level copy supports selectable source-root/package targets, internal retargeting, atomic rollback, and Undo Last Refactoring; nested declarations and broader IDEA target workflows remain. | F1 |
| `extract-interface-superclass` | Extract Interface / Superclass | Refactor menu | Partial | K2 Extract Super engine atomically creates or restores its target together with the source, including Undo Last Refactoring; advanced constructors, generics and full conflicts remain. | F6 |
| `pull-members-up` | Pull Members Up | `Ctrl+Alt+U` | Partial | Build-wide K2 hierarchy discovery covers Kotlin override chains; visibility, accidental-override, Java and full IDEA-index conflict checks remain. | F2 |
@@ -79,7 +80,6 @@ The corresponding record comments above are deliberately machine-readable. `Refa
|---|---|---|---|
| `inline-type-alias` | Inline Type Alias | The K2 processor is not compiled or adapted to NetBeans. | F5 |
| `inline-anonymous-function` | Inline Anonymous Function/Lambda | The K2 processor is not compiled or adapted to NetBeans. | F5 |
-| `move-file` | Move Kotlin file/directory | Requires filesystem transaction, source-root and project search services. | F4 |
| `change-package` | Change Package | Requires package/filesystem move support and usage retargeting. | F4 |
| `move-nested-member-method` | Move nested class/member/method | Requires receiver/visibility/hierarchy conflict support. | F4 |
| `rename-file-package-directory` | Rename file/package/directory | Requires NetBeans filesystem refactoring integration and Kotlin-aware update rules. | F7 |
diff --git a/pom.xml b/pom.xml
index 043b1751..0007f98e 100644
--- a/pom.xml
+++ b/pom.xml
@@ -4,7 +4,7 @@
4.0.0
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.231-SNAPSHOT
+ 0.11.245-SNAPSHOT
pom
Netbeans Plugin Kotlin