diff --git a/CHANGELOG.md b/CHANGELOG.md
index ebc3823f..5f865b5a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,4 @@
+- Added Change Kotlin Package refactoring: use **Refactor → Change Kotlin Package...** for the active Kotlin file or a folder's recursive Kotlin descendants to update package directives in place; nested files retain their relative package hierarchy, supported Kotlin code references and direct imports update, and **Undo Last Refactoring** restores the original contents.
- 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.
diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml
index def966ab..4fafda65 100644
--- a/CoreImpl/pom.xml
+++ b/CoreImpl/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.245-SNAPSHOT
+ 0.11.249-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-core-impl
diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml
index e6ce9bbf..c5739481 100644
--- a/KotlinCompilerCliBase/pom.xml
+++ b/KotlinCompilerCliBase/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.245-SNAPSHOT
+ 0.11.249-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-compiler-cli-base
diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml
index 99a782c5..8c9b4c9e 100644
--- a/KotlinFixesImpl/pom.xml
+++ b/KotlinFixesImpl/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.245-SNAPSHOT
+ 0.11.249-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-fixes-impl
diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml
index 718cc9bd..a77898eb 100644
--- a/KotlinFormatter/pom.xml
+++ b/KotlinFormatter/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.245-SNAPSHOT
+ 0.11.249-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-formatter
diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml
index f8d6137f..9b5f2c69 100644
--- a/KotlinHighlighting/pom.xml
+++ b/KotlinHighlighting/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.245-SNAPSHOT
+ 0.11.249-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-highlighting
diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml
index d611fb63..9ef79aaa 100644
--- a/KotlinIcons/pom.xml
+++ b/KotlinIcons/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.245-SNAPSHOT
+ 0.11.249-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-icons
diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml
index 99876de5..1d555881 100644
--- a/KotlinIntentionUtils/pom.xml
+++ b/KotlinIntentionUtils/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.245-SNAPSHOT
+ 0.11.249-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-intention-utils
diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml
index b313aaa6..8326f753 100644
--- a/KotlinRefactoring/pom.xml
+++ b/KotlinRefactoring/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.245-SNAPSHOT
+ 0.11.249-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-refactoring
@@ -751,9 +751,10 @@
KaMoveFileComputer invokes the K2 handler semantics through a lifecycle-free adapter.
Excluded: K2MoveHandler.kt (IDE action-system dispatch), ui/** (Swing dialog/model —
NB provides its own UI), K2MoveDirectoryWithClassesHelper.kt (generic filesystem move),
- K2ChangePackageDescriptor.kt/K2ChangePackageRefactoringProcessor.kt (F4.2), and
- K2MoveDeclarationsRefactoringListener.kt (message-bus event topic, stripped from the
- declaration processor because no standalone listeners are subscribed). -->
+ and K2MoveDeclarationsRefactoringListener.kt (message-bus event topic, stripped from
+ the declaration processor because no standalone listeners are subscribed). F4.2 ports
+ K2ChangePackageDescriptor/K2ChangePackageRefactoringProcessor and strips only their
+ IDEA lifecycle wrapper after copying. -->
copy-kotlin-refactoring-move-k2-declarations
initialize
@@ -770,6 +771,8 @@
org/jetbrains/kotlin/idea/k2/refactoring/move/descriptor/K2MoveSourceDescriptor.kt
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/descriptor/K2ChangePackageDescriptor.kt
+ org/jetbrains/kotlin/idea/k2/refactoring/move/processor/K2ChangePackageRefactoringProcessor.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
@@ -1967,6 +1970,47 @@ if (k2MoveFilesProcessor.exists()) {
log.info("Patched K2MoveFilesOrDirectoriesRefactoringProcessor.kt: retained lifecycle-free K2MoveFilesHandler only")
}
+// 16b. K2ChangePackageRefactoringProcessor.kt (F4.2): preserve the real IDEA K2 package-change
+// algorithm while removing only BaseRefactoringProcessor's IDEA command, modal progress,
+// conflict-dialog, usage-view, and event-listener lifecycle. KaChangePackageComputer supplies
+// a per-file target package plan (needed for recursive NetBeans folder selection) and invokes
+// this lifecycle-free port for its actual usage discovery, conflict check, package rewrite,
+// declaration map, and retargeting sequence.
+def k2ChangePackageProcessor = new File(srcDir, 'org/jetbrains/kotlin/idea/k2/refactoring/move/processor/K2ChangePackageRefactoringProcessor.kt')
+if (k2ChangePackageProcessor.exists()) {
+ def text = k2ChangePackageProcessor.text
+ text = text.replace('import com.intellij.openapi.actionSystem.ex.ActionUtil\n', '')
+ text = text.replace('import com.intellij.openapi.util.Ref\n', '')
+ text = text.replace('import com.intellij.refactoring.BaseRefactoringProcessor\n', '')
+ text = text.replace('import com.intellij.refactoring.RefactoringBundle\n', '')
+ text = text.replace('import com.intellij.refactoring.listeners.RefactoringEventData\n', '')
+ text = text.replace('import com.intellij.usageView.UsageViewDescriptor\n', '')
+ text = text.replace('import com.intellij.util.containers.MultiMap\n', '')
+ text = text.replace('import com.intellij.refactoring.util.MoveRenameUsageInfo\n', '')
+ text = text.replace('import org.jetbrains.kotlin.analysis.api.permissions.KaAllowAnalysisOnEdt\n', '')
+ text = text.replace('import org.jetbrains.kotlin.analysis.api.permissions.allowAnalysisOnEdt\n', '')
+ text = text.replace('import org.jetbrains.kotlin.idea.base.resources.KotlinBundle\n', '')
+ text = text.replace('class K2ChangePackageRefactoringProcessor(private val descriptor: K2ChangePackageDescriptor) : BaseRefactoringProcessor(descriptor.project) {',
+ 'class K2ChangePackageRefactoringProcessor(private val descriptor: K2ChangePackageDescriptor) {')
+ def commandStart = text.indexOf(' override fun getCommandName()')
+ def findUsagesStart = text.indexOf(' protected override fun findUsages()')
+ if (commandStart >= 0 && findUsagesStart > commandStart) {
+ text = text.substring(0, commandStart) + text.substring(findUsagesStart)
+ }
+ text = text.replace(' protected override fun findUsages()', ' fun findUsages()')
+ def preprocessStart = text.indexOf(' protected override fun preprocessUsages(')
+ def performStart = text.indexOf(' @OptIn(KaAllowAnalysisOnEdt::class)\n override fun performRefactoring(')
+ if (preprocessStart >= 0 && performStart > preprocessStart) {
+ text = text.substring(0, preprocessStart) + text.substring(performStart)
+ }
+ text = text.replace(' @OptIn(KaAllowAnalysisOnEdt::class)\n override fun performRefactoring(usages: Array) = allowAnalysisOnEdt {',
+ ' fun findConflicts(usages: List): com.intellij.util.containers.MultiMap =\n findAllMoveConflicts(descriptor.files, descriptor.target, usages)\n\n fun prepareRefactoring(oldToNewMap: MutableMap) {\n val files = descriptor.files\n files.forEach { it.updatePackageDirective(descriptor.target) }\n files.forEach { file ->\n oldToNewMap[file] = file\n file.declarations.filterIsInstance().forEach { declaration ->\n oldToNewMap[declaration] = declaration\n }\n }\n }\n\n fun retargetUsages(usages: Collection, oldToNewMap: Map) {\n retargetUsagesAfterMove(usages.toList(), oldToNewMap)\n }\n\n fun performRefactoring(usages: Collection) {\n val oldToNewMap = linkedMapOf()\n prepareRefactoring(oldToNewMap)\n retargetUsages(usages, oldToNewMap)\n }')
+ text = text.replace('\n\n override fun getBeforeData(): RefactoringEventData = RefactoringEventData().apply {\n addElements(descriptor.files)\n }', '')
+ text = text.replace(' val files = descriptor.files\n files.forEach { it.updatePackageDirective(descriptor.target) }\n val oldToNewMap: MutableMap = files.associateWith { it }.toMutableMap()\n files.forEach { file -> file.allDeclarationsToUpdate.forEach { decl -> oldToNewMap[decl] = decl } }\n retargetUsagesAfterMove(usages.toList(), oldToNewMap)\n }', '')
+ k2ChangePackageProcessor.text = text
+ log.info("Patched K2ChangePackageRefactoringProcessor.kt: retained lifecycle-free upstream K2 semantic sequence")
+}
+
// 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
diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt
new file mode 100644
index 00000000..337ad4db
--- /dev/null
+++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt
@@ -0,0 +1,209 @@
+/*******************************************************************************
+ * 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.refactoring.util.MoveRenameUsageInfo
+import com.intellij.usageView.UsageInfo
+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.descriptor.K2ChangePackageDescriptor
+import org.jetbrains.kotlin.idea.k2.refactoring.move.processor.K2ChangePackageRefactoringProcessor
+import org.jetbrains.kotlin.name.FqName
+import org.jetbrains.kotlin.psi.KtFile
+import org.jetbrains.kotlin.psi.KtNamedDeclaration
+import org.jetbrains.kotlin.psi.KtPsiFactory
+import org.jetbrains.kotlin.resolve.ImportPath
+
+/**
+ * Applies IDEA K2 Change Package semantics without its IDE-specific processor lifecycle.
+ *
+ * NetBeans supplies writable session PSI and persists the returned text atomically. The computer
+ * retains the upstream algorithm's usage discovery, conflict pass, package rewrite, declaration
+ * identity map, and reference retargeting while deliberately leaving filesystem mutations to no one:
+ * changing a package never changes a physical path.
+ *
+ * @param ktFile representative writable K2-session source file
+ */
+class KaChangePackageComputer(
+ private val ktFile: KtFile,
+) {
+
+ /** Describes the result of applying package changes to one or more Kotlin source files. */
+ sealed class ApplyOutcome {
+ /** K2 found blocking semantic conflicts and did not mutate any PSI. */
+ data class Conflicts(val messages: List) : ApplyOutcome()
+
+ /** K2 produced final texts and pre-mutation snapshots for every changed participant. */
+ data class Success(
+ val changedFiles: Map,
+ val originalTexts: Map,
+ ) : ApplyOutcome()
+
+ /** An unexpected K2 or PSI failure prevented the operation. */
+ data class Error(val error: Throwable) : ApplyOutcome()
+ }
+
+ /**
+ * Changes every source in [targets] to its requested package and retargets supported usages.
+ *
+ * @param targets writable source PSI mapped to their final declared packages
+ * @param updateReferences whether supported external Kotlin references and direct imports update
+ * @return conflicts without mutation, changed texts with original snapshots, or an error
+ */
+ fun apply(targets: Map, updateReferences: Boolean): ApplyOutcome {
+ return try {
+ require(targets.isNotEmpty()) { "Change Package requires at least one Kotlin file." }
+ val files = targets.keys.toList()
+ require(files.all { it.virtualFile?.path != null }) { "Change Package requires physical Kotlin files." }
+
+ val processors = targets.entries.groupBy({ it.value }, { it.key }).map { (targetPackage, groupedFiles) ->
+ K2ChangePackageRefactoringProcessor(
+ K2ChangePackageDescriptor(
+ project = ktFile.project,
+ files = groupedFiles.toSet(),
+ target = targetPackage,
+ searchForText = false,
+ searchInComments = false,
+ ),
+ )
+ }
+ // Use the copied upstream Change Package processor rather than the F4.1 Move File helper:
+ // its findUsages() marks whole-file internal references. That is what forces a same-package,
+ // no-import usage in another source file to gain an import after its declaration changes package.
+ val usages = if (updateReferences) processors.flatMap { it.findUsages().asIterable() }.distinct() else emptyList()
+ val conflicts = processors.flatMap { processor ->
+ processor.findConflicts(usages.filterIsInstance()).values()
+ }
+ if (conflicts.isNotEmpty()) return ApplyOutcome.Conflicts(conflicts.distinct())
+
+ val movedImports = files.flatMap { file ->
+ file.declarations.mapNotNull { declaration ->
+ (declaration as? KtNamedDeclaration)?.name?.let { name ->
+ MoveImport(file.packageFqName, name) to targets.getValue(file)
+ }
+ }
+ }.toMap()
+ val samePackageImportUpdates = if (updateReferences) {
+ findSamePackageImportUpdates(files, targets)
+ } else {
+ emptyMap()
+ }
+ val importOnlyUsageFiles = if (updateReferences) {
+ KotlinMoveUsageSearchService.getInstance()
+ ?.findImportingFiles(ktFile.project, movedImports)
+ .orEmpty()
+ } else {
+ emptyList()
+ }
+ val usageFiles = (filesWithUsages(usages) + importOnlyUsageFiles + samePackageImportUpdates.keys).distinct()
+ val originalTexts = (files + usageFiles).distinct().mapNotNull { file ->
+ file.virtualFile?.path?.let { path -> path to file.text }
+ }.toMap()
+ val oldToNew = linkedMapOf()
+ processors.forEach { processor -> processor.prepareRefactoring(oldToNew) }
+ if (updateReferences) processors.first().retargetUsages(usages, oldToNew)
+
+ val changedFiles = linkedMapOf()
+ files.forEach { file -> file.virtualFile?.path?.let { changedFiles[it] = file.text } }
+ usageFiles.filterNot { it in files }.forEach { file ->
+ rewriteMovedImports(file, movedImports)
+ val changedText = addSamePackageImports(file, samePackageImportUpdates[file].orEmpty())
+ file.virtualFile?.path?.let { changedFiles[it] = changedText }
+ }
+ ApplyOutcome.Success(changedFiles, originalTexts)
+ } catch (error: Throwable) {
+ ApplyOutcome.Error(error)
+ }
+ }
+
+ /**
+ * Finds old-package simple references that will lose their implicit package visibility.
+ *
+ * The standalone K2 retargeter excludes these whole-file internal usages, so identify them
+ * before changing directives and later add only the imports required to preserve binding.
+ */
+ private fun findSamePackageImportUpdates(
+ movedFiles: Collection,
+ targets: Map,
+ ): Map> {
+ val movedDeclarations = movedFiles.flatMap { file ->
+ file.declarations.filterIsInstance().mapNotNull { declaration ->
+ declaration.name?.let { name -> declaration to targets.getValue(file).child(org.jetbrains.kotlin.name.Name.identifier(name)) }
+ }
+ }.toMap()
+ if (movedDeclarations.isEmpty()) return emptyMap()
+ val session = KotlinMoveUsageSearchService.getInstance() ?: return emptyMap()
+ val updates = linkedMapOf>()
+ movedDeclarations.forEach { (declaration, targetFqName) ->
+ session.findUsages(declaration).forEach { reference ->
+ val file = reference.element?.containingFile as? KtFile ?: return@forEach
+ if (file in movedFiles || file.packageFqName != declaration.containingKtFile.packageFqName) return@forEach
+ updates.getOrPut(file, ::linkedSetOf) += targetFqName
+ }
+ }
+ return updates
+ }
+
+ /**
+ * Adds imports for former same-package declarations that no longer resolve implicitly.
+ *
+ * The standalone K2 retargeter creates the first import list without its package separator.
+ * Rebuild this narrow header text rather than inserting PSI whitespace, which the standalone
+ * parser drops while adding the import-list node.
+ *
+ * @return final source text with a canonical package/import boundary
+ */
+ private fun addSamePackageImports(usageFile: KtFile, imports: Set): String {
+ if (imports.isEmpty()) return usageFile.text
+ val packageDirective = usageFile.packageDirective ?: return usageFile.text
+ val importList = usageFile.importList
+ val existingImports = importList?.imports.orEmpty().map { it.text }
+ val existingFqNames = usageFile.importDirectives.mapNotNull { it.importedFqName }.toSet()
+ val missingImports = imports.filterNot { it in existingFqNames }.map { imported ->
+ "import ${imported.asString()}"
+ }
+ val directives = existingImports + missingImports
+ if (directives.isEmpty()) return usageFile.text
+ val headerEnd = importList?.textRange?.endOffset ?: packageDirective.textRange.endOffset
+ return usageFile.text.substring(0, packageDirective.textRange.endOffset) +
+ "\n\n${directives.joinToString(separator = "\n")}" +
+ usageFile.text.substring(headerEnd)
+ }
+
+ /** Captures usage files before K2 reference rebinding can invalidate individual usage elements. */
+ private fun filesWithUsages(usages: Collection): List =
+ usages.mapNotNull { usage -> usage.element?.containingFile as? KtFile }.distinct()
+
+ /**
+ * Replaces direct imports after K2 code retargeting.
+ *
+ * Standalone K2 cannot safely rebind a segment within an import directive because that can
+ * invalidate the complete import list before an IDEA document synchronizer updates it.
+ */
+ 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)))
+ }
+ }
+}
diff --git a/Nbm/pom.xml b/Nbm/pom.xml
index 06a4d570..73999424 100644
--- a/Nbm/pom.xml
+++ b/Nbm/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.245-SNAPSHOT
+ 0.11.249-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-nbm
diff --git a/Nbm/src/main/java/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageUI.java b/Nbm/src/main/java/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageUI.java
new file mode 100644
index 00000000..b06c3eca
--- /dev/null
+++ b/Nbm/src/main/java/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageUI.java
@@ -0,0 +1,232 @@
+/*******************************************************************************
+ * 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 java.awt.BorderLayout;
+import java.awt.Component;
+import java.awt.GridBagConstraints;
+import java.awt.GridBagLayout;
+import java.awt.Insets;
+import javax.swing.BorderFactory;
+import javax.swing.JCheckBox;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import javax.swing.JTextField;
+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 the package and supported-reference update option for Change Kotlin Package.
+ *
+ * @param refactoring mutable carrier receiving user-selected settings
+ * @param target package validator and source-root context
+ * @param initialPackage initial package text shown to the user
+ */
+public final class KotlinChangePackageUI implements RefactoringUI {
+
+ private final KotlinChangePackageRefactoring refactoring;
+ private final KotlinPackageTarget target;
+ private final String initialPackage;
+ private ChangePackagePanel panel;
+
+ /**
+ * Creates the Change Kotlin Package UI.
+ *
+ * @param refactoring mutable carrier receiving user-selected settings
+ * @param target package validator and source-root context
+ * @param initialPackage initial package text shown to the user
+ */
+ public KotlinChangePackageUI(KotlinChangePackageRefactoring refactoring,
+ KotlinPackageTarget target,
+ String initialPackage) {
+ this.refactoring = refactoring;
+ this.target = target;
+ this.initialPackage = initialPackage;
+ }
+
+ /** @return the user-visible refactoring name. */
+ @Override
+ public String getName() {
+ return "Change Kotlin Package";
+ }
+
+ /** @return concise description of the selected file or directory. */
+ @Override
+ public String getDescription() {
+ if (refactoring.getSelection().isDirectory()) {
+ return "Change package of " + refactoring.getSelection().getSourceFiles().size()
+ + " Kotlin files in directory '" + refactoring.getSelection().getDisplayName() + "'";
+ }
+ return "Change package of Kotlin file '" + refactoring.getSelection().getDisplayName() + "'";
+ }
+
+ /** @return false because the operation mutates Kotlin source documents. */
+ @Override
+ public boolean isQuery() {
+ return false;
+ }
+
+ /**
+ * Creates the package-change form on first use.
+ *
+ * @param parent listener notified when values change
+ * @return cached package-change form
+ */
+ @Override
+ public CustomRefactoringPanel getPanel(ChangeListener parent) {
+ if (panel == null) {
+ panel = new ChangePackagePanel(
+ refactoring.getSelection().getDisplayName(),
+ refactoring.getSelection().getSourceFiles().size(),
+ refactoring.getSelection().isDirectory(),
+ initialPackage,
+ parent
+ );
+ }
+ return panel;
+ }
+
+ /**
+ * Copies selected settings to the carrier.
+ *
+ * @return a fatal problem for an invalid 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.setTargetPackage(packageName);
+ refactoring.setUpdateReferences(panel.isUpdateReferences());
+ return null;
+ }
+
+ /**
+ * Validates current form values before NetBeans schedules the operation.
+ *
+ * @return fatal validation problem, or {@code null}
+ */
+ @Override
+ public Problem checkParameters() {
+ return setParameters();
+ }
+
+ /** @return true because Change Package has a configuration form. */
+ @Override
+ public boolean hasParameters() {
+ return true;
+ }
+
+ /** @return carrier supplied to NetBeans refactoring infrastructure. */
+ @Override
+ public AbstractRefactoring getRefactoring() {
+ return refactoring;
+ }
+
+ /** @return no dedicated help page. */
+ @Override
+ public HelpCtx getHelpCtx() {
+ return null;
+ }
+
+ /** Swing form for Change Kotlin Package parameters. */
+ private static final class ChangePackagePanel implements CustomRefactoringPanel {
+ private final JPanel component;
+ private final JTextField packageField;
+ private final JCheckBox updateReferences;
+
+ /**
+ * Builds the package-change form.
+ *
+ * @param name displayed source file or directory name
+ * @param sourceCount selected Kotlin file count
+ * @param directorySelection whether the selection originated from a directory
+ * @param initialPackage initially displayed package
+ * @param changeListener listener notified of UI changes
+ */
+ ChangePackagePanel(String name, int sourceCount, boolean directorySelection, String initialPackage,
+ ChangeListener changeListener) {
+ packageField = new JTextField(initialPackage, 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(directorySelection ? name + " (" + sourceCount + " Kotlin files)" : name), field);
+ JLabel packageLabel = new JLabel("New package:");
+ packageLabel.setLabelFor(packageField);
+ form.add(packageLabel, label);
+ form.add(packageField, field);
+ form.add(new JLabel(""), label);
+ form.add(updateReferences, field);
+ form.add(new JLabel(""), label);
+ String note = directorySelection
+ ? "Nested Kotlin files retain their relative subfolder hierarchy; physical locations remain unchanged."
+ : "The file's physical location remains unchanged.";
+ form.add(new JLabel(note), 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);
+ updateReferences.addActionListener(event -> changeListener.stateChanged(null));
+ }
+
+ /** @return current target-package text. */
+ String getPackageValue() {
+ return packageField.getText();
+ }
+
+ /** @return whether supported external Kotlin references should be retargeted. */
+ boolean isUpdateReferences() {
+ return updateReferences.isSelected();
+ }
+
+ /** Requests initial focus for the package field. */
+ @Override
+ public void initialize() {
+ packageField.requestFocusInWindow();
+ }
+
+ /** @return component displayed by NetBeans. */
+ @Override
+ public Component getComponent() {
+ return component;
+ }
+ }
+}
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 dcdef66a..aa1580fb 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
@@ -40,6 +40,8 @@
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.KotlinChangePackagePlugin;
+import io.github.nbplugins.kotlin.nbm.refactoring.KotlinChangePackageRefactoring;
import io.github.nbplugins.kotlin.nbm.refactoring.KotlinChangeSignaturePlugin;
import io.github.nbplugins.kotlin.nbm.refactoring.KotlinChangeSignatureRefactoring;
import io.github.nbplugins.kotlin.nbm.refactoring.KotlinIntroduceImportAliasPlugin;
@@ -92,6 +94,7 @@
* {@link KotlinCopyDeclarationRefactoring} — delegates to {@link KotlinCopyDeclarationPlugin}
* {@link KotlinMoveDeclarationRefactoring} — delegates to {@link KotlinMoveDeclarationPlugin}
* {@link KotlinMoveFileRefactoring} — delegates to {@link KotlinMoveFilePlugin}
+ * {@link KotlinChangePackageRefactoring} — delegates to {@link KotlinChangePackagePlugin}
* {@link KotlinChangeSignatureRefactoring} — delegates to {@link KotlinChangeSignaturePlugin}
*
*/
@@ -106,6 +109,9 @@ public RefactoringPlugin createInstance(AbstractRefactoring refactoring) {
if (refactoring instanceof KotlinMoveFileRefactoring) {
return new KotlinMoveFilePlugin((KotlinMoveFileRefactoring) refactoring);
}
+ if (refactoring instanceof KotlinChangePackageRefactoring) {
+ return new KotlinChangePackagePlugin((KotlinChangePackageRefactoring) 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/KotlinChangePackageAction.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageAction.kt
new file mode 100644
index 00000000..fca038dc
--- /dev/null
+++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageAction.kt
@@ -0,0 +1,64 @@
+/*******************************************************************************
+ * 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.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 that changes the active Kotlin file's package without moving its physical path.
+ */
+class KotlinChangePackageAction : BaseAction(ACTION_NAME, SAVE_POSITION or ABBREV_RESET) {
+ init {
+ putValue(NAME, "Change Kotlin Package...")
+ putValue(SHORT_DESCRIPTION, "Change Kotlin File Package Without Moving It")
+ putValue(POPUP_MENU_TEXT, "Change Kotlin Package...")
+ }
+
+ /**
+ * Opens the package-change UI for the active Kotlin source file.
+ *
+ * @param evt action event raised by NetBeans
+ * @param target active editor component
+ */
+ 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 = KotlinChangePackageSourceSelection.from(source) ?: return@runCatching
+ val packageTarget = KotlinPackageTarget(project, source)
+ UI.openRefactoringUI(
+ KotlinChangePackageUI(KotlinChangePackageRefactoring(selection), packageTarget, packageTarget.defaultPackage),
+ TopComponent.getRegistry().activated,
+ )
+ }.onFailure { error -> KotlinLogger.INSTANCE.logException("KotlinChangePackageAction failed", error) }
+ }
+
+ companion object {
+ /** Action name used by the manual layer.xml registration. */
+ const val ACTION_NAME = "kotlin-change-package"
+ }
+}
diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageDirectoryAction.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageDirectoryAction.kt
new file mode 100644
index 00000000..fd67a885
--- /dev/null
+++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageDirectoryAction.kt
@@ -0,0 +1,97 @@
+/*******************************************************************************
+ * 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 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 changes package directives for recursive Kotlin descendants in place.
+ *
+ * The selected folder itself is not included in the target package because physical directories do
+ * not move. Descendant folders become suffixes of the user-selected package.
+ */
+@ActionID(category = "Refactoring", id = "io.github.nbplugins.kotlin.nbm.refactoring.KotlinChangePackageDirectoryAction")
+@ActionRegistration(displayName = "Change Kotlin Package...")
+open class KotlinChangePackageDirectoryAction : NodeAction() {
+ init {
+ putValue(NAME, "Change Kotlin Package...")
+ putValue(SHORT_DESCRIPTION, "Change Package of Kotlin Files in Directory")
+ }
+
+ /**
+ * Prepares and opens the shared UI for a selected Kotlin-containing folder.
+ *
+ * @param nodes folder node supplied by NetBeans
+ */
+ override fun performAction(nodes: Array) {
+ val folder = selectedFolder(nodes) ?: return
+ val selection = KotlinChangePackageSourceSelection.from(folder) ?: return
+ runCatching {
+ val project = ProjectUtils.getKotlinProjectForFileObject(selection.representativeFile)
+ ?: ProjectUtils.getValidProject()
+ ?: return@runCatching
+ val packageTarget = KotlinPackageTarget(project, selection.representativeFile)
+ openRefactoringUi(
+ KotlinChangePackageUI(
+ KotlinChangePackageRefactoring(selection),
+ packageTarget,
+ packageTarget.packageForFolder(folder),
+ ),
+ )
+ }.onFailure { error -> KotlinLogger.INSTANCE.logException("KotlinChangePackageDirectoryAction failed", error) }
+ }
+
+ /**
+ * Opens the Swing refactoring UI on the Event Dispatch Thread.
+ *
+ * @param ui prepared UI whose display must run on the Event Dispatch Thread
+ */
+ internal fun openRefactoringUi(ui: KotlinChangePackageUI) {
+ EventQueue.invokeLater {
+ runCatching { UI.openRefactoringUI(ui, TopComponent.getRegistry().activated) }
+ .onFailure { error -> KotlinLogger.INSTANCE.logException("KotlinChangePackageDirectoryAction UI opening failed", error) }
+ }
+ }
+
+ /** @return true only for one valid folder containing at least one Kotlin descendant. */
+ override fun enable(nodes: Array): Boolean = selectedFolder(nodes)
+ ?.let(KotlinChangePackageSourceSelection::from)
+ ?.isDirectory == true
+
+ /** @return NetBeans default help context. */
+ override fun getHelpCtx(): HelpCtx = HelpCtx.DEFAULT_HELP
+
+ /** @return stable action name used by the manual layer registration. */
+ override fun getName(): String = "Change Kotlin Package"
+
+ /** Resolves exactly one physical folder from NetBeans' selected node. */
+ 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/KotlinChangePackagePlugin.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackagePlugin.kt
new file mode 100644
index 00000000..ed7b9cc1
--- /dev/null
+++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackagePlugin.kt
@@ -0,0 +1,161 @@
+/*******************************************************************************
+ * 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.KaChangePackageComputer
+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 Change Kotlin Package UI to the standalone K2 semantic adapter.
+ *
+ * @param refactoring selected sources and destination parameters from the NetBeans UI
+ */
+class KotlinChangePackagePlugin(
+ private val refactoring: KotlinChangePackageRefactoring,
+) : ProgressProviderAdapter(), RefactoringPlugin {
+ /** @return no preflight problem; sources are checked during prepare. */
+ override fun preCheck(): Problem? = null
+ /** @return no fast problem; UI validates the package text. */
+ override fun fastCheckParameters(): Problem? = null
+ /** @return no extra parameter problem. */
+ override fun checkParameters(): Problem? = null
+ /** Cancels no external asynchronous work. */
+ override fun cancelRequest() = Unit
+
+ /** Adds selected source previews and one atomic package-change element. */
+ override fun prepare(bag: RefactoringElementsBag): Problem? {
+ val selection = refactoring.selection
+ val project = ProjectUtils.getKotlinProjectForFileObject(selection.representativeFile)
+ ?: ProjectUtils.getValidProject()
+ ?: return Problem(true, "Change Kotlin Package could not find the source project.")
+ val session = KotlinAnalysisAPISession.getSession(project)
+ selection.sourceFiles.forEach { source ->
+ val file = session.getKtFileForPath(source.path)
+ ?: return Problem(true, "Change Kotlin Package could not resolve ${source.nameExt}.")
+ bag.add(refactoring, KotlinFindUsagesResultElement(OffsetRange(0, file.textLength), source))
+ }
+ bag.add(refactoring, KotlinChangePackageApplyElement(selection, project, refactoring))
+ return null
+ }
+}
+
+/**
+ * Applies one atomic package change and retains its transaction for Undo Last Refactoring.
+ *
+ * @param selection Kotlin sources whose paths must remain unchanged
+ * @param project owning NetBeans project
+ * @param refactoring requested package settings
+ */
+class KotlinChangePackageApplyElement(
+ private val selection: KotlinChangePackageSourceSelection,
+ private val project: org.netbeans.api.project.Project,
+ private val refactoring: KotlinChangePackageRefactoring,
+) : SimpleRefactoringElementImplementation() {
+ private var transaction: KotlinRefactoringTransaction? = null
+
+ /** @return preview label for this all-or-nothing operation. */
+ override fun getText(): String = "Change Kotlin package"
+ /** @return preview label for this all-or-nothing operation. */
+ override fun getDisplayText(): String = text
+ /** @return representative source lookup. */
+ override fun getLookup(): Lookup = Lookups.fixed(selection.representativeFile)
+ /** @return representative source for preview placement. */
+ override fun getParentFile(): FileObject = selection.representativeFile
+ /** @return source bounds when the editor support is available. */
+ override fun getPosition(): PositionBounds? = runCatching {
+ 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))
+ }.getOrNull()
+
+ /** Runs K2 semantics and persists every changed document through one transaction. */
+ override fun performChange() {
+ var pending: KotlinRefactoringTransaction? = null
+ runCatching {
+ val basePackage = refactoring.targetPackage.trim()
+ check(KotlinPackageTarget(project, selection.representativeFile).isValidPackage(basePackage)) {
+ "Target package is invalid: $basePackage"
+ }
+ val session = KotlinAnalysisAPISession.getSession(project)
+ val sourceByPath = selection.sourceFiles.associateBy(FileObject::getPath)
+ val targets = selection.sourceFiles.associate { source ->
+ val file = session.getKtFileForPath(source.path)
+ ?: error("Change Kotlin Package could not resolve writable source PSI: ${source.nameExt}.")
+ val segments = basePackage.split('.').filter(String::isNotEmpty) + selection.targetRelativePackageSegments(source)
+ file to FqName(segments.joinToString("."))
+ }
+ val current = KotlinRefactoringTransaction()
+ pending = current
+ when (val outcome = KaChangePackageComputer(targets.keys.first()).apply(targets, refactoring.updateReferences)) {
+ is KaChangePackageComputer.ApplyOutcome.Conflicts ->
+ throw IllegalStateException(outcome.messages.joinToString("\n"))
+ is KaChangePackageComputer.ApplyOutcome.Error -> throw outcome.error
+ is KaChangePackageComputer.ApplyOutcome.Success -> {
+ outcome.changedFiles.forEach { (path, text) ->
+ val file = sourceByPath[path] ?: FileUtil.toFileObject(FileUtil.normalizeFile(java.io.File(path)))
+ ?: error("Change Kotlin Package could not resolve changed file $path.")
+ val originalText = outcome.originalTexts[path]
+ ?: error("Change Kotlin Package has no original text snapshot for $path.")
+ current.captureExisting(file, originalText)
+ current.stageText(file, text)
+ }
+ current.commit()
+ transaction = current
+ pending = null
+ }
+ }
+ }.onFailure { error -> KotlinLogger.INSTANCE.logException("KotlinChangePackageApplyElement.performChange failed", error) }
+ runCatching { pending?.rollback() }
+ .onFailure { error -> KotlinLogger.INSTANCE.logException("KotlinChangePackageApplyElement rollback failed", error) }
+ KotlinAnalysisAPISession.invalidate(project)
+ }
+
+ /** Restores all original package directives and usage texts. */
+ override fun undoChange() {
+ runCatching {
+ val current = transaction
+ if (current == null) {
+ KotlinLogger.INSTANCE.logWarning("KotlinChangePackageApplyElement.undoChange: no retained transaction")
+ } else {
+ KotlinLogger.INSTANCE.logInfo("KotlinChangePackageApplyElement.undoChange: restoring retained transaction")
+ current.undo()
+ transaction = null
+ KotlinLogger.INSTANCE.logInfo("KotlinChangePackageApplyElement.undoChange: transaction restored")
+ }
+ KotlinAnalysisAPISession.invalidate(project)
+ }.onFailure { error ->
+ KotlinLogger.INSTANCE.logException("KotlinChangePackageApplyElement.undoChange failed", error)
+ }
+ }
+}
diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageRefactoring.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageRefactoring.kt
new file mode 100644
index 00000000..bbf05ae6
--- /dev/null
+++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageRefactoring.kt
@@ -0,0 +1,35 @@
+/*******************************************************************************
+ * 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 requested package-change parameters through NetBeans.
+ *
+ * @param selection physical Kotlin sources whose package directives will change without relocation
+ */
+class KotlinChangePackageRefactoring(
+ val selection: KotlinChangePackageSourceSelection,
+) : AbstractRefactoring(Lookups.fixed(*selection.sourceFiles.toTypedArray())) {
+ /** User-selected base package, empty for Kotlin's root package. */
+ var targetPackage: String = ""
+
+ /** Whether supported external Kotlin references and direct imports should be retargeted. */
+ var updateReferences: Boolean = true
+}
diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageSourceSelection.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageSourceSelection.kt
new file mode 100644
index 00000000..3cc43f85
--- /dev/null
+++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageSourceSelection.kt
@@ -0,0 +1,95 @@
+/*******************************************************************************
+ * 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 sources selected for a Change Kotlin Package operation.
+ *
+ * A directory selection changes only package directives. Its own directory name is therefore not
+ * appended to the user-entered package; only descendant directory segments are retained.
+ *
+ * @param sourceFiles physical Kotlin sources in deterministic relative-path order
+ * @param selectedDirectory selected folder, or `null` for one editor-selected file
+ */
+class KotlinChangePackageSourceSelection private constructor(
+ val sourceFiles: List,
+ val selectedDirectory: FileObject?,
+) {
+ /** @return whether this selection originated from a folder node. */
+ val isDirectory: Boolean
+ get() = selectedDirectory != null
+
+ /** @return source used for project discovery and concise UI details. */
+ val representativeFile: FileObject
+ get() = sourceFiles.first()
+
+ /** @return concise label for the selected file or folder. */
+ val displayName: String
+ get() = selectedDirectory?.nameExt ?: representativeFile.nameExt
+
+ /**
+ * Computes relative descendant folders to append below the user-selected target package.
+ *
+ * @param file a member of [sourceFiles]
+ * @return empty for one file or a file directly in the selected folder
+ */
+ fun targetRelativePackageSegments(file: FileObject): List {
+ require(file in sourceFiles) { "File is not part of this Change Package selection: ${file.path}" }
+ val directory = selectedDirectory ?: return emptyList()
+ return relativeSegments(directory, file.parent)
+ }
+
+ companion object {
+ /**
+ * Creates a selection from one Kotlin file or recursively from one folder.
+ *
+ * @param source physical file or folder selected by the user
+ * @return a non-empty Kotlin selection, or `null` for unsupported content
+ */
+ fun from(source: FileObject): KotlinChangePackageSourceSelection? {
+ if (!source.isValid) return null
+ if (!source.isFolder) {
+ return if (source.ext == "kt") KotlinChangePackageSourceSelection(listOf(source), null) else null
+ }
+ val files = collectKotlinFiles(source).sortedBy(FileObject::getPath)
+ return files.takeIf(List::isNotEmpty)?.let { KotlinChangePackageSourceSelection(it, source) }
+ }
+
+ /** Recursively finds valid Kotlin files while deliberately leaving all other content alone. */
+ 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 segments 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/resources/org/jetbrains/kotlin/layer.xml b/Nbm/src/main/resources/org/jetbrains/kotlin/layer.xml
index 1a0718ab..091d756c 100644
--- a/Nbm/src/main/resources/org/jetbrains/kotlin/layer.xml
+++ b/Nbm/src/main/resources/org/jetbrains/kotlin/layer.xml
@@ -120,6 +120,13 @@
+
+
+
+
+
+
+
@@ -150,6 +157,10 @@
+
+
+
+
@@ -314,6 +325,17 @@
+
+
+
+
+
+
+
+
+
+
+
@@ -645,6 +667,10 @@
+
+
+
+
diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt
new file mode 100644
index 00000000..4dbe5b0a
--- /dev/null
+++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt
@@ -0,0 +1,209 @@
+/*******************************************************************************
+ * 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.KaChangePackageComputer
+import org.jetbrains.kotlin.name.FqName
+import utils.KotlinTestCase
+import java.nio.file.Files
+import java.nio.file.Path
+
+/** Integration tests for the standalone K2 [KaChangePackageComputer]. */
+class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") {
+
+ /** Verifies Change Package rewrites a file despite a package/directory mismatch without moving it. */
+ fun testApply_realSession_changesMismatchedPackageWithoutMovingFile() {
+ val stdlib = findStdlibJar() ?: run {
+ println("kotlin-stdlib not on test classpath — skipping Change Package integration test")
+ return
+ }
+ val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-mismatch")
+ try {
+ val sourceDirectory = temporaryRoot.resolve("layout/source")
+ Files.createDirectories(sourceDirectory)
+ val sourcePath = sourceDirectory.resolve("Mismatch.kt")
+ Files.writeString(sourcePath, "package declared.somewhereElse\n\nfun mismatch() = 1\n")
+ val session = KotlinAnalysisAPISession.createWithJars(
+ moduleName = "change-package-mismatch-integration",
+ binaryJars = listOf(stdlib),
+ sourceRoots = listOf(temporaryRoot),
+ )
+ val source = session.getKtFileForPath(sourcePath.toString()) ?: error("Could not obtain source PSI")
+
+ val outcome = KaChangePackageComputer(source).apply(
+ mapOf(source to FqName("target.changed")),
+ updateReferences = true,
+ )
+
+ assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success)
+ val success = outcome as KaChangePackageComputer.ApplyOutcome.Success
+ assertTrue(success.changedFiles[sourcePath.toString()]?.contains("package target.changed") == true)
+ assertTrue("Change Package must not move physical files", Files.isRegularFile(sourcePath))
+ } finally {
+ temporaryRoot.toFile().deleteRecursively()
+ }
+ }
+
+ /** Verifies one package change atomically retargets external Kotlin imports and code usages. */
+ fun testApply_realSession_retargetsExternalImport() {
+ val stdlib = findStdlibJar() ?: run {
+ println("kotlin-stdlib not on test classpath — skipping Change Package integration test")
+ return
+ }
+ val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-usage")
+ try {
+ val sourceDirectory = temporaryRoot.resolve("source")
+ val usageDirectory = temporaryRoot.resolve("usage")
+ Files.createDirectories(sourceDirectory)
+ Files.createDirectories(usageDirectory)
+ val sourcePath = sourceDirectory.resolve("Moved.kt")
+ val usagePath = usageDirectory.resolve("Usage.kt")
+ Files.writeString(sourcePath, "package old.source\n\nfun moved() = 1\n")
+ Files.writeString(
+ usagePath,
+ "package usage\n\nimport old.source.moved\n\nfun use() = moved()\n",
+ )
+ val session = KotlinAnalysisAPISession.createWithJars(
+ moduleName = "change-package-usage-integration",
+ binaryJars = listOf(stdlib),
+ sourceRoots = listOf(temporaryRoot),
+ )
+ val source = session.getKtFileForPath(sourcePath.toString()) ?: error("Could not obtain source PSI")
+
+ val outcome = KaChangePackageComputer(source).apply(
+ mapOf(source to FqName("new.changed")),
+ updateReferences = true,
+ )
+
+ assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success)
+ val success = outcome as KaChangePackageComputer.ApplyOutcome.Success
+ assertEquals("package old.source\n\nfun moved() = 1\n", success.originalTexts[sourcePath.toString()])
+ assertEquals(
+ "package usage\n\nimport old.source.moved\n\nfun use() = moved()\n",
+ success.originalTexts[usagePath.toString()],
+ )
+ assertTrue(success.changedFiles[sourcePath.toString()]?.contains("package new.changed") == true)
+ assertTrue(success.changedFiles[usagePath.toString()]?.contains("import new.changed.moved") == true)
+ } finally {
+ temporaryRoot.toFile().deleteRecursively()
+ }
+ }
+
+ /**
+ * Verifies a caller in the old package receives an explicit reference after its declaration moves.
+ *
+ * Before Change Package's upstream whole-file usage discovery was used, this no-import call was
+ * invisible to the adapter: it remained a stale simple name in the old package.
+ */
+ fun testApply_realSession_retargetsSamePackageReferenceWithoutImport() {
+ val stdlib = findStdlibJar() ?: run {
+ println("kotlin-stdlib not on test classpath — skipping Change Package integration test")
+ return
+ }
+ val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-same-package")
+ try {
+ val oldPackageDirectory = temporaryRoot.resolve("old/source")
+ Files.createDirectories(oldPackageDirectory)
+ val sourcePath = oldPackageDirectory.resolve("Moved.kt")
+ val callerPath = oldPackageDirectory.resolve("Caller.kt")
+ Files.writeString(sourcePath, "package old.source\n\nfun moved() = 1\n")
+ Files.writeString(callerPath, "package old.source\n\nfun use() = moved()\n")
+ val session = KotlinAnalysisAPISession.createWithJars(
+ moduleName = "change-package-same-package-integration",
+ binaryJars = listOf(stdlib),
+ sourceRoots = listOf(temporaryRoot),
+ )
+ val source = session.getKtFileForPath(sourcePath.toString()) ?: error("Could not obtain source PSI")
+
+ val outcome = KaChangePackageComputer(source).apply(
+ mapOf(source to FqName("new.changed")),
+ updateReferences = true,
+ )
+
+ assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success)
+ val success = outcome as KaChangePackageComputer.ApplyOutcome.Success
+ assertTrue(success.changedFiles[sourcePath.toString()]?.contains("package new.changed") == true)
+ assertEquals(
+ "A same-package caller without existing imports needs a separated import section",
+ "package old.source\n\nimport new.changed.moved\n\nfun use() = moved()\n",
+ success.changedFiles[callerPath.toString()],
+ )
+ assertEquals(
+ "Every generated import change needs an exact pre-refactoring snapshot for Undo Last Refactoring",
+ "package old.source\n\nfun use() = moved()\n",
+ success.originalTexts[callerPath.toString()],
+ )
+ } finally {
+ temporaryRoot.toFile().deleteRecursively()
+ }
+ }
+
+ /** Verifies recursive-style per-file packages retarget imports for every selected source together. */
+ fun testApply_realSession_supportsDistinctDescendantPackages() {
+ val stdlib = findStdlibJar() ?: run {
+ println("kotlin-stdlib not on test classpath — skipping Change Package integration test")
+ return
+ }
+ val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-directory")
+ try {
+ val folder = temporaryRoot.resolve("physical/feature")
+ val internal = folder.resolve("internal")
+ val usageDirectory = temporaryRoot.resolve("usage")
+ Files.createDirectories(internal)
+ Files.createDirectories(usageDirectory)
+ val publicPath = folder.resolve("Public.kt")
+ val helperPath = internal.resolve("Helper.kt")
+ val usagePath = usageDirectory.resolve("Usage.kt")
+ Files.writeString(publicPath, "package old.feature\n\nfun publicApi() = helper()\n")
+ Files.writeString(helperPath, "package old.feature.internal\n\nfun helper() = 1\n")
+ Files.writeString(
+ usagePath,
+ "package usage\n\nimport old.feature.publicApi\nimport old.feature.internal.helper\n\nfun use() = publicApi() + helper()\n",
+ )
+ val session = KotlinAnalysisAPISession.createWithJars(
+ moduleName = "change-package-directory-integration",
+ binaryJars = listOf(stdlib),
+ sourceRoots = listOf(temporaryRoot),
+ )
+ 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 outcome = KaChangePackageComputer(publicFile).apply(
+ mapOf(publicFile to FqName("new.target"), helperFile to FqName("new.target.internal")),
+ updateReferences = true,
+ )
+
+ assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success)
+ val success = outcome as KaChangePackageComputer.ApplyOutcome.Success
+ assertTrue(success.changedFiles[publicPath.toString()]?.contains("package new.target") == true)
+ assertTrue(success.changedFiles[helperPath.toString()]?.contains("package new.target.internal") == true)
+ assertTrue(success.changedFiles[usagePath.toString()]?.contains("import new.target.publicApi") == true)
+ assertTrue(success.changedFiles[usagePath.toString()]?.contains("import new.target.internal.helper") == true)
+ assertTrue("Change Package must retain the first physical path", Files.isRegularFile(publicPath))
+ assertTrue("Change Package must retain the nested physical path", Files.isRegularFile(helperPath))
+ } finally {
+ temporaryRoot.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)
+ .firstOrNull { it.fileName?.toString()?.startsWith("kotlin-stdlib") == true && it.toFile().exists() }
+}
diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageDirectoryActionTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageDirectoryActionTest.kt
new file mode 100644
index 00000000..c389242f
--- /dev/null
+++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageDirectoryActionTest.kt
@@ -0,0 +1,65 @@
+/*******************************************************************************
+ * 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 java.nio.file.Files
+import java.nio.file.Path
+
+/** Unit tests for [KotlinChangePackageDirectoryAction]. */
+class KotlinChangePackageDirectoryActionTest : NbTestCase("KotlinChangePackageDirectoryActionTest") {
+
+ /** Verifies source discovery can run outside the Event Dispatch Thread before UI marshalling. */
+ fun testAsynchronous_allowsBackgroundSourceDiscovery() {
+ assertTrue(
+ "K2 source discovery may run in NetBeans' RequestProcessor before UI is marshalled to EDT",
+ TestAction().isAsynchronous(),
+ )
+ }
+
+ /** Verifies the directory action has exactly one folder-context layer registration. */
+ fun testLayer_registersFolderActionWithoutGlobalMenuDuplicate() {
+ val layer = repositoryRoot().resolve("Nbm/src/main/resources/org/jetbrains/kotlin/layer.xml")
+ val contents = Files.readString(layer)
+ val actionName = "KotlinChangePackageDirectoryAction.shadow"
+
+ assertEquals("Folder context action must have exactly one dedicated layer shadow", 1, contents.countOccurrences(actionName))
+ assertTrue("Folder context action must be registered in Loaders/folder/any/Actions", contents.contains(""))
+ }
+
+ /** Finds the checkout root from the NBM module's Surefire working directory. */
+ private fun repositoryRoot(): Path {
+ var candidate: Path? = Path.of("").toAbsolutePath()
+ while (candidate != null) {
+ if (Files.isRegularFile(candidate.resolve("pom.xml")) && Files.isDirectory(candidate.resolve("Nbm"))) {
+ return candidate
+ }
+ candidate = candidate.parent
+ }
+ fail("Could not locate repository root from ${Path.of("").toAbsolutePath()}")
+ error("unreachable")
+ }
+
+ /** Counts non-overlapping literal [needle] occurrences in this source text. */
+ private fun String.countOccurrences(needle: String): Int =
+ split(needle).size - 1
+
+ /** Exposes NodeAction's protected dispatcher contract for this focused test. */
+ private class TestAction : KotlinChangePackageDirectoryAction() {
+ fun isAsynchronous(): Boolean = asynchronous()
+ }
+}
diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageRefactoringTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageRefactoringTest.kt
new file mode 100644
index 00000000..48497892
--- /dev/null
+++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageRefactoringTest.kt
@@ -0,0 +1,41 @@
+/*******************************************************************************
+ * 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.jetbrains.kotlin.refactorings.rename.KotlinRefactoringsFactory
+import org.netbeans.junit.NbTestCase
+import org.openide.filesystems.FileUtil
+
+/** Unit tests for [KotlinChangePackageRefactoring]'s plugin-factory applicability. */
+class KotlinChangePackageRefactoringTest : NbTestCase("KotlinChangePackageRefactoringTest") {
+
+ /** Verifies a folder carrier creates the plugin without requiring an editor document. */
+ fun testFactory_createsPluginForDirectorySelectionWithoutStyledDocument() {
+ val root = FileUtil.createMemoryFileSystem().root
+ val folder = root.createFolder("feature")
+ folder.createData("Public.kt")
+ val selection = KotlinChangePackageSourceSelection.from(folder) ?: error("Expected Kotlin selection")
+ val refactoring = KotlinChangePackageRefactoring(selection)
+
+ val plugin = KotlinRefactoringsFactory().createInstance(refactoring)
+
+ assertTrue(
+ "Change Package folder selection must not be rejected by the editor-document applicability guard",
+ plugin is KotlinChangePackagePlugin,
+ )
+ }
+}
diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageSourceSelectionTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageSourceSelectionTest.kt
new file mode 100644
index 00000000..657badb6
--- /dev/null
+++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageSourceSelectionTest.kt
@@ -0,0 +1,78 @@
+/*******************************************************************************
+ * 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 [KotlinChangePackageSourceSelection]. */
+class KotlinChangePackageSourceSelectionTest : NbTestCase("KotlinChangePackageSourceSelectionTest") {
+
+ /** Verifies a folder selection includes only Kotlin descendants in deterministic 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")
+
+ val selection = KotlinChangePackageSourceSelection.from(feature) ?: error("Expected Kotlin selection")
+
+ assertTrue(selection.isDirectory)
+ assertEquals("feature", selection.displayName)
+ assertEquals(listOf(apiFile.path, helper.path), selection.sourceFiles.map(FileObject::getPath))
+ }
+
+ /** Verifies nested descendants retain only their relative folder hierarchy below the target package. */
+ fun testTargetRelativePackageSegments_directoryExcludesSelectedFolderName() {
+ 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 = KotlinChangePackageSourceSelection.from(feature) ?: error("Expected Kotlin selection")
+
+ assertEquals(listOf("internal"), selection.targetRelativePackageSegments(helper))
+ assertEquals(emptyList(), selection.targetRelativePackageSegments(rootFile))
+ }
+
+ /** Verifies an editor file selection has no extra target package hierarchy. */
+ fun testFrom_fileCreatesSingleFileSelectionWithoutRelativeHierarchy() {
+ val root = FileUtil.createMemoryFileSystem().root
+ val file = root.createData("Single.kt")
+
+ val selection = KotlinChangePackageSourceSelection.from(file) ?: error("Expected Kotlin selection")
+
+ assertFalse(selection.isDirectory)
+ assertEquals(listOf(file), selection.sourceFiles)
+ assertEquals(emptyList(), selection.targetRelativePackageSegments(file))
+ }
+
+ /** Verifies non-Kotlin files and folders without Kotlin descendants are rejected. */
+ fun testFrom_nonKotlinSourceReturnsNull() {
+ val root = FileUtil.createMemoryFileSystem().root
+ val resources = root.createFolder("resources")
+ resources.createData("config.json")
+ val text = root.createData("Notes.txt")
+
+ assertNull(KotlinChangePackageSourceSelection.from(resources))
+ assertNull(KotlinChangePackageSourceSelection.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 463f943b..6294b6b1 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
@@ -112,6 +112,25 @@ class KotlinRefactoringTransactionTest : NbTestCase("KotlinRefactoringTransactio
assertEquals("undo must restore the snapshot from before K2 PSI mutation", original, fixture.text(source))
}
+ /** Verifies undo removes an import that Change Package added to a same-package caller. */
+ fun testCommitAndUndo_removesGeneratedSamePackageImport() {
+ val fixture = fixture()
+ val moved = fixture.existing("Moved.kt", "package new.target\n\nfun moved() = 1\n")
+ val caller = fixture.existing("Caller.kt", "package old.source\n\nimport new.target.moved\n\nfun use() = moved()\n")
+ val originalMoved = "package old.source\n\nfun moved() = 1\n"
+ val originalCaller = "package old.source\n\nfun use() = moved()\n"
+
+ fixture.transaction.captureExisting(moved, originalMoved)
+ fixture.transaction.captureExisting(caller, originalCaller)
+ fixture.transaction.stageText(moved, "package new.target\n\nfun moved() = 1\n")
+ fixture.transaction.stageText(caller, "package old.source\n\nimport new.target.moved\n\nfun use() = moved()\n")
+ fixture.transaction.commit()
+ fixture.transaction.undo()
+
+ assertEquals("package source must be restored", originalMoved, fixture.text(moved))
+ assertEquals("undo must remove the generated import exactly", originalCaller, fixture.text(caller))
+ }
+
/** Verifies several directory descendants move and undo together in reverse physical-move order. */
fun testCommitAndUndo_restoresEveryMovedDirectoryDescendant() {
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 78aed07f..e356e738 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, 19 partial, 5 absent, 24 total."
+ val totals = "**Baseline counts:** 0 complete, 20 partial, 4 absent, 24 total."
assertTrue("Visible baseline totals must match the records", Files.readString(matrix).contains(totals))
- assertEquals(19, records.count { it.status == "partial" })
- assertEquals(5, records.count { it.status == "absent" })
+ assertEquals(20, records.count { it.status == "partial" })
+ assertEquals(4, records.count { it.status == "absent" })
}
/** Finds the checkout root from Surefire's module working directory. */
diff --git a/docs/development-plan.md b/docs/development-plan.md
index fea05930..b6c0e88d 100644
--- a/docs/development-plan.md
+++ b/docs/development-plan.md
@@ -764,6 +764,21 @@ Priority order: E1 → E2 → E3 → E4 → E5 → E6 → E7 → E8 → E9 → E
remaining F4.1 scope is generic non-Kotlin content, Java/comment/text/index-only references,
and full IDEA project-model conflict behavior.
+ - [x] **F4.2** — Change Kotlin Package (Refactor menu / folder context menu)
+ - Ports real upstream `K2ChangePackageDescriptor` and
+ `K2ChangePackageRefactoringProcessor` sources from `kotlin.refactorings.move.k2`. The copied
+ processor is patched only to remove IDEA's `BaseRefactoringProcessor` command/progress/dialog/
+ listener lifecycle; its K2 usage discovery, conflict collection, package-directive rewrite,
+ declaration mapping, and retargeting sequence remain the semantic engine.
+ - **Refactor → Change Kotlin Package...** changes the active Kotlin file's package without
+ moving it. A folder-node invocation recursively changes only Kotlin descendants; direct
+ children use the entered package while nested descendants append their relative folder paths.
+ The selected folder's name is not added because its physical path remains unchanged.
+ - Supported Kotlin code references and direct non-aliased imports update atomically through
+ `KotlinRefactoringTransaction`; **Undo Last Refactoring** restores every source and usage
+ document. Java/comment/text/index-only references, full IDEA project-model conflicts, and
+ arbitrary multi-selection remain outside the standalone scope.
+
- [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-change-kotlin-package.md b/docs/manual-test-change-kotlin-package.md
new file mode 100644
index 00000000..7a20106b
--- /dev/null
+++ b/docs/manual-test-change-kotlin-package.md
@@ -0,0 +1,68 @@
+# Manual Test — Change Kotlin Package
+
+Use the NBM built from the `feature/f4-2-change-kotlin-package` branch. Ensure NetBeans starts
+with the required JVM `--add-opens` flags from `CLAUDE.md`.
+
+## 1. Change the active Kotlin file package
+
+1. Create or open a Kotlin project with:
+ ```kotlin
+ // src/main/kotlin/old/source/Moved.kt
+ package old.source
+
+ fun moved() = 1
+ ```
+2. Create a caller:
+ ```kotlin
+ // src/main/kotlin/usage/Usage.kt
+ package usage
+
+ import old.source.moved
+
+ fun use() = moved()
+ ```
+3. Open `Moved.kt`, select **Refactor → Change Kotlin Package...**, enter `new.changed`, leave
+ **Update Kotlin references** selected, and apply.
+4. Verify:
+ - `Moved.kt` remains physically at `old/source/Moved.kt`.
+ - Its directive is `package new.changed`.
+ - `Usage.kt` imports `new.changed.moved` and its call remains valid.
+5. Choose **Refactor → Undo Last Refactoring**. Verify both files' exact original text and the
+ original physical path are restored.
+
+## 2. Change a recursive folder package in place
+
+1. Create a directory `src/main/kotlin/physical/feature` containing:
+ ```kotlin
+ // physical/feature/Public.kt
+ package old.feature
+
+ fun publicApi() = helper()
+ ```
+ ```kotlin
+ // physical/feature/internal/Helper.kt
+ package old.feature.internal
+
+ fun helper() = 1
+ ```
+2. Add a separate caller importing both declarations.
+3. Right-click the **feature** folder and select **Refactor → Change Kotlin Package...**.
+4. Enter `new.target`, retain **Update Kotlin references**, and apply.
+5. Verify:
+ - `Public.kt` is still in `physical/feature` and declares `package new.target`.
+ - `Helper.kt` is still in `physical/feature/internal` and declares
+ `package new.target.internal`.
+ - The caller imports `new.target.publicApi` and `new.target.internal.helper`.
+ - No directory was created, deleted, renamed, or moved.
+6. Use **Undo Last Refactoring** and verify every package directive and import returns exactly to
+ its original state.
+
+## 3. Boundaries and validation
+
+1. Put a non-Kotlin file such as `README.txt` below the selected folder. Repeat the folder test and
+ verify it remains unchanged.
+2. Try an invalid package such as `not-valid` or `123.start`; verify the dialog blocks refactoring.
+3. Add a Java usage, a comment mention, and a string mention of the old FQ name. Verify the dialog
+ describes the limitation and those non-Kotlin/non-code occurrences are not claimed as updated.
+4. Trigger a known Kotlin name/visibility conflict where feasible. Verify the refactoring does not
+ leave partially changed source or import documents.
diff --git a/docs/refactoring-coverage.md b/docs/refactoring-coverage.md
index 5de7b62b..91fbf33f 100644
--- a/docs/refactoring-coverage.md
+++ b/docs/refactoring-coverage.md
@@ -29,7 +29,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, 19 partial, 5 absent, 24 total.
+**Baseline counts:** 0 complete, 20 partial, 4 absent, 24 total.
## Current NetBeans refactorings
@@ -69,6 +69,7 @@ The corresponding record comments above are deliberately machine-readable. `Refa
| `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 |
+| `change-package` | Change Package | Refactor → Change Kotlin Package... / folder → Change Kotlin Package... | Partial | Changes the active Kotlin file or recursively selected folder's Kotlin descendants without moving physical paths; nested descendants retain relative package segments below the entered package, supported Kotlin code/direct imports update atomically, and Undo Last Refactoring restores every original text. Java/comment/text/index-only references, full IDEA project-model conflicts, and arbitrary multi-selection remain unavailable. | 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 |
@@ -80,7 +81,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 |
-| `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 0007f98e..6cdb1b89 100644
--- a/pom.xml
+++ b/pom.xml
@@ -4,7 +4,7 @@
4.0.0
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.245-SNAPSHOT
+ 0.11.249-SNAPSHOT
pom
Netbeans Plugin Kotlin