diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4b356197..9bd339ad 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,4 @@
+- Added Move Nested Member refactoring: choose **Refactor → Move Nested Member...** on a nested Kotlin class/object or a companion function/property, select a compatible target from a structured package/class tree, and apply the atomic source/target change; nested classes/objects can move into a target class/object without a body, which gains one automatically, while companion functions/properties require an existing companion object. **Undo Last Refactoring** restores both files. Instance members, import aliases, and unvalidated external qualified-use rewrites remain unavailable.
- Added Inline Anonymous Function/Lambda refactoring: place the caret in an immediately invoked Kotlin lambda or anonymous function, press **Ctrl+Alt+N** or choose **Refactor → Inline...**, preview the one-call rewrite, and apply the IntelliJ IDEA K2-generated substitution; **Undo Last Refactoring** restores the original document.
- Added separate Java and Kotlin package trees in Project Explorer: conventional `src/main/java` and `src/test/java` remain under **Source Packages** and **Test Packages**, while `src/main/kotlin` and `src/test/kotlin` appear under **Kotlin Source Packages** and **Kotlin Test Packages** in Maven, Gradle, and Ant/J2SE projects.
- Added Inline Type Alias refactoring: place the caret on a Kotlin `typealias` declaration or one of its type uses, press **Ctrl+Alt+N** or choose **Refactor → Inline...**, then expand every supported Kotlin occurrence or only the selected use and optionally retain the alias declaration; generic arguments, imports, and parenthesisation use the IntelliJ IDEA K2 engine, while **Undo Last Refactoring** restores all changed Kotlin files.
diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml
index 76ef7293..fa88e892 100644
--- a/CoreImpl/pom.xml
+++ b/CoreImpl/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.256-SNAPSHOT
+ 0.11.260-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-core-impl
diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml
index 3020573a..242bf08d 100644
--- a/KotlinCompilerCliBase/pom.xml
+++ b/KotlinCompilerCliBase/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.256-SNAPSHOT
+ 0.11.260-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-compiler-cli-base
diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml
index a12bf71b..2fed4f62 100644
--- a/KotlinFixesImpl/pom.xml
+++ b/KotlinFixesImpl/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.256-SNAPSHOT
+ 0.11.260-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-fixes-impl
diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml
index 15535125..13a1b95d 100644
--- a/KotlinFormatter/pom.xml
+++ b/KotlinFormatter/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.256-SNAPSHOT
+ 0.11.260-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-formatter
diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml
index 8c6b9d9c..13b07995 100644
--- a/KotlinHighlighting/pom.xml
+++ b/KotlinHighlighting/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.256-SNAPSHOT
+ 0.11.260-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-highlighting
diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml
index 62ced0b0..696be996 100644
--- a/KotlinIcons/pom.xml
+++ b/KotlinIcons/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.256-SNAPSHOT
+ 0.11.260-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-icons
diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml
index 372d3085..ace7696e 100644
--- a/KotlinIntentionUtils/pom.xml
+++ b/KotlinIntentionUtils/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.256-SNAPSHOT
+ 0.11.260-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-intention-utils
diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml
index 340fdc1b..2a1469fa 100644
--- a/KotlinRefactoring/pom.xml
+++ b/KotlinRefactoring/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.256-SNAPSHOT
+ 0.11.260-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-refactoring
diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaMoveNestedMemberComputer.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaMoveNestedMemberComputer.kt
new file mode 100644
index 00000000..bd78d257
--- /dev/null
+++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaMoveNestedMemberComputer.kt
@@ -0,0 +1,345 @@
+/*******************************************************************************
+ * 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.openapi.util.TextRange
+import com.intellij.psi.PsiElement
+import com.intellij.psi.PsiWhiteSpace
+import org.jetbrains.kotlin.idea.k2.refactoring.move.KotlinMoveUsageSearchService
+import org.jetbrains.kotlin.idea.k2.refactoring.move.processor.usages.K2MoveRenameUsageInfo
+import org.jetbrains.kotlin.idea.references.KtReference
+import org.jetbrains.kotlin.idea.references.KtSimpleNameReference
+import org.jetbrains.kotlin.psi.KtClassOrObject
+import org.jetbrains.kotlin.psi.KtFile
+import org.jetbrains.kotlin.psi.KtNamedDeclaration
+import org.jetbrains.kotlin.psi.KtNamedFunction
+import org.jetbrains.kotlin.psi.KtObjectDeclaration
+import org.jetbrains.kotlin.psi.KtProperty
+import org.jetbrains.kotlin.psi.KtPsiFactory
+import org.jetbrains.kotlin.psi.psiUtil.collectDescendantsOfType
+import org.jetbrains.kotlin.psi.psiUtil.getNonStrictParentOfType
+import org.jetbrains.kotlin.psi.psiUtil.getStrictParentOfType
+import org.jetbrains.kotlin.psi.psiUtil.parentsWithSelf
+
+/**
+ * Lifecycle-free K2 adapter for the portable subset of Move Nested Kotlin Member.
+ *
+ * IDEA's generic Java Move Members processor needs indexed Java PSI, light classes, Usage View, and
+ * command lifecycle services unavailable in the standalone container. This adapter instead retains
+ * IDEA's K2 reference-retargeting engine while only accepting relocations whose receiver change is
+ * mechanically safe: nested classes and companion-object functions/properties.
+ *
+ * @param file K2 session-owned file containing the invocation caret.
+ * @param caretOffset editor offset at which the operation was invoked.
+ */
+class KaMoveNestedMemberComputer(
+ private val file: KtFile,
+ private val caretOffset: Int,
+) {
+ /** Result of resolving a supported declaration. */
+ sealed interface Outcome {
+ /** The caret does not identify a nested class or companion member with safe semantics. */
+ data object NotApplicable : Outcome
+
+ /** The UI may offer a target class/object for this declaration. */
+ data class Ready(
+ /** Range to preview in the declaration file. */
+ val declarationRange: TextRange,
+ /** Simple declaration name. */
+ val declarationName: String,
+ /** Whether the declaration must stay in a companion object. */
+ val requiresCompanionTarget: Boolean,
+ /** Source file path, used by transaction-backed persistence. */
+ val sourceFilePath: String,
+ /** Kotlin package containing the source declaration. */
+ val sourcePackageName: String,
+ /** Top-level-to-owner source class/object path used to expand the target tree. */
+ val sourceContainerPath: List,
+ ) : Outcome
+
+ /** An unexpected PSI or K2 failure occurred. */
+ data class Error(/** Underlying failure. */ val error: Throwable) : Outcome
+ }
+
+ /** One named class/object that the controller may offer as a move target. */
+ data class TargetCandidate(
+ /** Absolute path of the K2 session file containing this target. */
+ val filePath: String,
+ /** Stable offset inside [filePath] used to resolve the target immediately before apply. */
+ val offset: Int,
+ /** Readable simple type label shown by the passive NetBeans view. */
+ val presentation: String,
+ /** Kotlin package containing the target, empty for the default package. */
+ val packageName: String,
+ /** Top-level-to-leaf Kotlin class/object path used to build the passive target tree. */
+ val containerPath: List,
+ /** Whether the move destination is this target's companion object. */
+ val requiresCompanionTarget: Boolean,
+ )
+
+ /** Result of a pre-mutation validation pass. */
+ sealed interface ConflictCheck {
+ /** The requested target is valid for this portable move subset. */
+ data object Clear : ConflictCheck
+
+ /** The operation must not mutate PSI; messages are ready for NetBeans preview. */
+ data class Conflicts(/** Human-readable validation failures. */ val messages: List) : ConflictCheck
+
+ /** The declaration or target no longer resolves. */
+ data object NotApplicable : ConflictCheck
+
+ /** An unexpected PSI or K2 failure occurred. */
+ data class Error(/** Underlying failure. */ val error: Throwable) : ConflictCheck
+ }
+
+ /** Result of executing one K2-native nested move. */
+ sealed interface Apply {
+ /** The declaration or target no longer resolves. */
+ data object NotApplicable : Apply
+
+ /** Validation failed without changing standalone PSI. */
+ data class Conflicts(/** Human-readable validation failures. */ val messages: List) : Apply
+
+ /** Final source, target, and external-usage texts to persist atomically. */
+ data class Success(
+ /** Every session file changed by the K2 move/retargeting operation. */
+ val changedFiles: Map,
+ /** Final text of the declaration's original file. */
+ val sourceText: String,
+ /** Final text of the file containing the selected target class. */
+ val targetText: String,
+ ) : Apply
+
+ /** An unexpected mutation or retargeting failure occurred. */
+ data class Error(/** Underlying failure. */ val error: Throwable) : Apply
+ }
+
+ /**
+ * Resolves the declaration at the invocation caret without modifying PSI.
+ *
+ * @return a supported-move description, a non-applicability result, or an analysis error.
+ */
+ fun compute(): Outcome = runCatching {
+ val declaration = findDeclaration() ?: return Outcome.NotApplicable
+ val kind = supportedKind(declaration) ?: return Outcome.NotApplicable
+ val owner = declaration.getStrictParentOfType() ?: return Outcome.NotApplicable
+ Outcome.Ready(
+ declarationRange = declaration.textRange,
+ declarationName = declaration.name ?: return Outcome.NotApplicable,
+ requiresCompanionTarget = kind == Kind.COMPANION_MEMBER,
+ sourceFilePath = declaration.containingKtFile.virtualFile?.path.orEmpty(),
+ sourcePackageName = declaration.containingKtFile.packageFqName.asString(),
+ sourceContainerPath = owner.parentsWithSelf
+ .filterIsInstance()
+ .mapNotNull(KtClassOrObject::getName)
+ .toList()
+ .asReversed(),
+ )
+ }.getOrElse(Outcome::Error)
+
+ /**
+ * Finds named class/object targets in [files] that can safely receive the declaration at the caret.
+ *
+ * The method is intentionally K2/PSI-only: it provides immutable data for a controller-owned
+ * target chooser and never resolves NetBeans files, opens dialogs, or mutates source.
+ *
+ * @param files K2 session files to inspect for named class/object targets.
+ * @return supported targets, sorted by their readable presentation; empty when not applicable.
+ */
+ fun discoverTargets(files: Iterable): List = runCatching {
+ val declaration = findDeclaration() ?: return emptyList()
+ val kind = supportedKind(declaration) ?: return emptyList()
+ val owner = declaration.getStrictParentOfType() ?: return emptyList()
+ val excludedOwners = buildList {
+ add(declaration)
+ add(owner)
+ declaration.getStrictParentOfType()
+ ?.getStrictParentOfType()
+ ?.let(::add)
+ }.map { it.containingKtFile.virtualFile?.path to it.textOffset }.toSet()
+ files.flatMap { candidateFile ->
+ candidateFile.collectDescendantsOfType()
+ .asSequence()
+ .filter { target ->
+ target.name != null &&
+ (target.containingKtFile.virtualFile?.path to target.textOffset) !in excludedOwners
+ }
+ .filter { target ->
+ when (kind) {
+ Kind.NESTED_CLASS -> true
+ Kind.COMPANION_MEMBER -> target.companionObjects.singleOrNull() != null
+ }
+ }
+ .mapNotNull { target ->
+ target.containingKtFile.virtualFile?.path?.let { path ->
+ TargetCandidate(
+ filePath = path,
+ offset = target.textOffset,
+ presentation = target.name!!,
+ packageName = target.containingKtFile.packageFqName.asString(),
+ containerPath = target.parentsWithSelf
+ .filterIsInstance()
+ .mapNotNull(KtClassOrObject::getName)
+ .toList()
+ .asReversed(),
+ requiresCompanionTarget = kind == Kind.COMPANION_MEMBER,
+ )
+ }
+ }
+ .toList()
+ }.distinctBy { it.filePath to it.offset }.sortedWith(compareBy(TargetCandidate::presentation, TargetCandidate::filePath))
+ }.getOrElse { emptyList() }
+
+ /**
+ * Validates [targetOffset] in [targetFile] without changing source or target PSI.
+ *
+ * @param targetFile K2 session file containing the selected target class/object.
+ * @param targetOffset stable offset inside that target.
+ * @return a clear, conflicting, non-applicable, or error result.
+ */
+ fun checkConflicts(targetFile: KtFile, targetOffset: Int): ConflictCheck = runCatching {
+ val declaration = findDeclaration() ?: return ConflictCheck.NotApplicable
+ val target = resolveTarget(targetFile, targetOffset) ?: return ConflictCheck.NotApplicable
+ validationMessages(declaration, target).let { messages ->
+ if (messages.isEmpty()) ConflictCheck.Clear else ConflictCheck.Conflicts(messages)
+ }
+ }.getOrElse(ConflictCheck::Error)
+
+ /**
+ * Moves the supported declaration into [targetFile]'s target class at [targetOffset].
+ *
+ * The method only mutates session PSI. Its caller persists [Apply.Success.changedFiles] through
+ * `KotlinRefactoringTransaction`, which supplies atomic rollback and Undo Last Refactoring.
+ *
+ * @param targetFile K2 session file containing the selected target class/object.
+ * @param targetOffset stable offset inside that target.
+ * @return final file text, conflicts, non-applicability, or an error.
+ */
+ fun move(targetFile: KtFile, targetOffset: Int): Apply = runCatching {
+ val declaration = findDeclaration() ?: return Apply.NotApplicable
+ val target = resolveTarget(targetFile, targetOffset) ?: return Apply.NotApplicable
+ val messages = validationMessages(declaration, target)
+ if (messages.isNotEmpty()) return Apply.Conflicts(messages)
+
+ val usageInfos = KotlinMoveUsageSearchService.getInstance()?.findUsages(declaration).orEmpty()
+ .filterIsInstance()
+ .filter { reference -> !com.intellij.psi.util.PsiTreeUtil.isAncestor(declaration, reference.element, false) }
+ .mapNotNull { reference ->
+ val element = reference.element as? org.jetbrains.kotlin.psi.KtElement ?: return@mapNotNull null
+ K2MoveRenameUsageInfo.Source(element, reference, declaration, false)
+ }
+ val usageFiles = usageInfos.mapNotNull { usage ->
+ usage.element?.containingFile as? KtFile
+ }.distinct()
+
+ K2MoveRenameUsageInfo.markInternalUsages(declaration, declaration)
+ val targetContainer = targetContainer(declaration, target) ?: return Apply.NotApplicable
+ val sourceFile = declaration.containingKtFile
+ val psiFactory = KtPsiFactory(targetContainer.project)
+ val targetBody = targetContainer.body ?: run {
+ targetContainer.add(psiFactory.createWhiteSpace(" "))
+ targetContainer.add(psiFactory.createEmptyClassBody()) as? org.jetbrains.kotlin.psi.KtClassBody
+ ?: return Apply.NotApplicable
+ }
+ val declarationName = declaration.name ?: return Apply.NotApplicable
+ val targetRightBrace = targetBody.rBrace ?: return Apply.NotApplicable
+ if (targetBody.declarations.isEmpty()) targetBody.addBefore(psiFactory.createNewLine(), targetRightBrace)
+ // `KtClassBody.add()` accepts an arbitrary PSI element in standalone mode and can attach it
+ // to JavaDummyHolder instead of the Kotlin body. Anchoring immediately before the real `}`
+ // keeps the copy inside the destination KtFile and gives K2 a stable parent chain.
+ val inserted = targetBody.addBefore(declaration.copy(), targetRightBrace) as? KtNamedDeclaration
+ ?: return Apply.NotApplicable
+ targetBody.addAfter(psiFactory.createNewLine(), inserted)
+ val copied = targetBody.declarations.filterIsInstance()
+ .lastOrNull { it === inserted || it.name == declarationName }
+ ?: return Apply.NotApplicable
+ val oldToNew = declaration.collectDescendantsOfType()
+ .zip(copied.collectDescendantsOfType())
+ .toMap(mutableMapOf().apply { put(declaration, copied) })
+ val sourceBody = declaration.getStrictParentOfType()?.body
+ declaration.delete()
+ normalizeEmptyBody(sourceBody)
+ K2MoveRenameUsageInfo.retargetUsages(usageInfos, oldToNew)
+
+ val changed = (listOf(sourceFile, targetFile) + usageFiles)
+ .distinct()
+ .mapNotNull { changedFile -> changedFile.virtualFile?.path?.let { it to changedFile.text } }
+ .toMap()
+ Apply.Success(changed, sourceFile.text, targetFile.text)
+ }.getOrElse(Apply::Error)
+
+ /** Restores one well-formed newline before an empty source body's closing brace. */
+ private fun normalizeEmptyBody(body: org.jetbrains.kotlin.psi.KtClassBody?) {
+ if (body == null || body.declarations.isNotEmpty()) return
+ val whitespace = body.rBrace?.prevSibling as? PsiWhiteSpace ?: return
+ if (whitespace.text == "\n") return
+ whitespace.replace(KtPsiFactory(body.project).createWhiteSpace("\n"))
+ }
+
+ /** Resolves a declaration directly at the caret; usage-site invocation is deliberately excluded. */
+ private fun findDeclaration(): KtNamedDeclaration? = sequenceOf(caretOffset, caretOffset + 1)
+ .mapNotNull(file::findElementAt)
+ .flatMap { it.parentsWithSelf }
+ .filterIsInstance()
+ .firstOrNull { supportedKind(it) != null }
+
+ /** Identifies only relocations whose receiver/qualification change is safe in standalone K2. */
+ private fun supportedKind(declaration: KtNamedDeclaration): Kind? = when (declaration) {
+ is KtClassOrObject -> declaration.takeIf { it.getStrictParentOfType() != null }
+ ?.let { Kind.NESTED_CLASS }
+ is KtNamedFunction, is KtProperty -> declaration.takeIf {
+ it.getStrictParentOfType()?.isCompanion() == true
+ }?.let { Kind.COMPANION_MEMBER }
+ else -> null
+ }
+
+ /** Locates the named class/object chosen by the NetBeans target-selection controller. */
+ private fun resolveTarget(targetFile: KtFile, targetOffset: Int): KtClassOrObject? =
+ targetFile.findElementAt(targetOffset)?.getNonStrictParentOfType()
+
+ /** Checks target kind, same-owner no-op, duplicate declarations, and Kotlin import aliases. */
+ private fun validationMessages(declaration: KtNamedDeclaration, target: KtClassOrObject): List {
+ val kind = supportedKind(declaration) ?: return listOf("This declaration cannot be moved safely.")
+ val sourceOwner = declaration.getStrictParentOfType()
+ ?: return listOf("Move source owner is unavailable.")
+ val messages = mutableListOf()
+ if (sourceOwner == target) messages += "The target class already owns ${declaration.name}."
+ if (kind == Kind.COMPANION_MEMBER && target.companionObjects.singleOrNull() == null) {
+ messages += "Target ${target.name ?: "class"} must declare exactly one companion object."
+ }
+ val destination = targetContainer(declaration, target)
+ val name = declaration.name
+ if (destination != null && name != null && destination.declarations.filterIsInstance().any { it.name == name }) {
+ messages += "Target ${target.name ?: "class"} already contains $name."
+ }
+ val aliases = KotlinMoveUsageSearchService.getInstance()?.findUsages(declaration).orEmpty()
+ .filterIsInstance()
+ .any { it.getImportAlias() != null }
+ if (aliases) messages += "Kotlin import aliases are not supported by Move Nested Member."
+ return messages
+ }
+
+ /** Selects the target class body or its companion body to match the declaration's source kind. */
+ private fun targetContainer(declaration: KtNamedDeclaration, target: KtClassOrObject): KtClassOrObject? = when (supportedKind(declaration)) {
+ Kind.NESTED_CLASS -> target
+ Kind.COMPANION_MEMBER -> target.companionObjects.singleOrNull()
+ null -> null
+ }
+
+ /** Supported source ownership kinds. */
+ private enum class Kind { NESTED_CLASS, COMPANION_MEMBER }
+}
diff --git a/Nbm/pom.xml b/Nbm/pom.xml
index 6cfc81a4..3c9d2ed1 100644
--- a/Nbm/pom.xml
+++ b/Nbm/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.256-SNAPSHOT
+ 0.11.260-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-nbm
@@ -453,6 +453,10 @@
org.netbeans.api
org-openide-nodes
+
+ org.netbeans.api
+ org-openide-explorer
+
org.netbeans.api
org-netbeans-modules-editor-indent
diff --git a/Nbm/src/main/java/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveNestedMemberUI.java b/Nbm/src/main/java/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveNestedMemberUI.java
new file mode 100644
index 00000000..f50ba3a4
--- /dev/null
+++ b/Nbm/src/main/java/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveNestedMemberUI.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.KaMoveNestedMemberComputer;
+import java.awt.BorderLayout;
+import java.awt.Component;
+import java.awt.GridBagConstraints;
+import java.awt.GridBagLayout;
+import java.awt.Insets;
+import java.beans.PropertyChangeEvent;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import javax.swing.BorderFactory;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import javax.swing.event.ChangeListener;
+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.explorer.ExplorerManager;
+import org.openide.explorer.view.BeanTreeView;
+import org.openide.nodes.AbstractNode;
+import org.openide.nodes.Children;
+import org.openide.nodes.Node;
+import org.openide.util.HelpCtx;
+import org.openide.util.Lookup;
+import org.openide.util.lookup.Lookups;
+
+/**
+ * Passive NetBeans view for choosing a Move Nested Member destination.
+ *
+ * It receives K2 discovery data from its controller and writes only the selected file/offset to the
+ * carrier; semantic conflict checks remain in {@link KotlinMoveNestedMemberPlugin}. The chooser uses
+ * NetBeans' explorer tree to retain the package and class-container structure of the supplied data.
+ */
+public final class KotlinMoveNestedMemberUI implements RefactoringUI {
+ private final KaMoveNestedMemberComputer.Outcome.Ready ready;
+ private final List candidates;
+ private final KotlinMoveNestedMemberRefactoring refactoring;
+ private TargetPanel panel;
+
+ /**
+ * Creates a target-selection view.
+ *
+ * @param ready source declaration presentation and source-tree location from the K2 controller
+ * @param candidates compatible targets computed by the K2 controller
+ * @param refactoring carrier populated on confirmation
+ */
+ public KotlinMoveNestedMemberUI(KaMoveNestedMemberComputer.Outcome.Ready ready,
+ List candidates,
+ KotlinMoveNestedMemberRefactoring refactoring) {
+ this.ready = ready;
+ this.candidates = candidates;
+ this.refactoring = refactoring;
+ }
+
+ /** @return operation label shown by NetBeans. */
+ @Override public String getName() { return "Move Nested Member"; }
+
+ /** @return description naming the declaration selected at the editor caret. */
+ @Override public String getDescription() { return "Move nested member '" + ready.getDeclarationName() + "'"; }
+
+ /** @return {@code false}; this operation changes Kotlin source. */
+ @Override public boolean isQuery() { return false; }
+
+ /** @return the reusable passive target chooser. */
+ @Override public CustomRefactoringPanel getPanel(ChangeListener parent) {
+ if (panel == null) panel = new TargetPanel(ready, candidates, parent);
+ return panel;
+ }
+
+ /** Copies the selected target candidate to the controller carrier. */
+ @Override public Problem setParameters() {
+ if (panel != null) panel.copyTo(refactoring);
+ return checkParameters();
+ }
+
+ /** @return fatal validation problem when no concrete target was selected. */
+ @Override public Problem checkParameters() {
+ if (panel != null && panel.selected() == null) {
+ return new Problem(true, "Select a target class or object.");
+ }
+ return null;
+ }
+
+ /** @return {@code true}; the user chooses a destination. */
+ @Override public boolean hasParameters() { return true; }
+
+ /** @return refactoring carrier accepted by the plugin factory. */
+ @Override public AbstractRefactoring getRefactoring() { return refactoring; }
+
+ /** @return no dedicated help page currently exists. */
+ @Override public HelpCtx getHelpCtx() { return null; }
+
+ /** Swing-only target chooser; deliberately contains no K2/project/session/filesystem calls. */
+ private static final class TargetPanel extends JPanel
+ implements CustomRefactoringPanel, ExplorerManager.Provider {
+ private final ExplorerManager explorerManager = new ExplorerManager();
+ private final BeanTreeView targets = new BeanTreeView();
+ private final Map nodesByPath = new LinkedHashMap<>();
+ private final KaMoveNestedMemberComputer.Outcome.Ready ready;
+
+ /** Creates a structured chooser from immutable controller-supplied candidate data. */
+ TargetPanel(KaMoveNestedMemberComputer.Outcome.Ready ready,
+ List candidates,
+ ChangeListener changeListener) {
+ this.ready = ready;
+ setLayout(new BorderLayout());
+
+ JPanel form = new JPanel(new GridBagLayout());
+ form.setBorder(BorderFactory.createEmptyBorder(8, 12, 4, 12));
+ GridBagConstraints label = new GridBagConstraints();
+ label.anchor = GridBagConstraints.WEST;
+ label.insets = new Insets(2, 0, 2, 8);
+ 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("Member:"), label);
+ form.add(new JLabel(ready.getDeclarationName()), field);
+ JLabel targetLabel = new JLabel(ready.getRequiresCompanionTarget()
+ ? "Target class/object with companion:" : "Target class/object:");
+ targetLabel.setLabelFor(targets);
+ form.add(targetLabel, label);
+ form.add(new JLabel("Choose a class or object in the tree below."), field);
+ add(form, BorderLayout.NORTH);
+
+ TargetNode root = targetTree(candidates);
+ explorerManager.setRootContext(root);
+ targets.setRootVisible(false);
+ targets.setSelectionMode(javax.swing.tree.TreeSelectionModel.SINGLE_TREE_SELECTION);
+ targets.setBorder(BorderFactory.createEmptyBorder(0, 12, 8, 12));
+ add(targets, BorderLayout.CENTER);
+ explorerManager.addPropertyChangeListener((PropertyChangeEvent event) -> {
+ if (ExplorerManager.PROP_SELECTED_NODES.equals(event.getPropertyName())) {
+ changeListener.stateChanged(null);
+ }
+ });
+ }
+
+ /** @return this panel's explorer context required by {@link BeanTreeView}. */
+ @Override public ExplorerManager getExplorerManager() { return explorerManager; }
+
+ /**
+ * Builds every explorer node only after all structural paths and selectable candidates are known.
+ *
+ * Nodes may have exactly one parent; constructing a replacement node after its structural node
+ * has acquired children makes {@code Children.Array} recursively assign already-owned nodes.
+ */
+ private TargetNode targetTree(List candidates) {
+ Map entries = new LinkedHashMap<>();
+ entries.put("", new TreeEntry("", "Targets", null, null));
+ candidates.forEach(candidate -> addCandidate(entries, candidate));
+ addSourcePath(entries);
+
+ List ordered = new ArrayList<>(entries.values());
+ ordered.sort(Comparator.comparingInt(TreeEntry::depth));
+ for (TreeEntry entry : ordered) {
+ TargetNode node = new TargetNode(entry.displayName, entry.candidate);
+ entry.node = node;
+ nodesByPath.put(entry.path, node);
+ if (entry.parentPath != null) entries.get(entry.parentPath).node.add(node);
+ }
+ return entries.get("").node;
+ }
+
+ /** Adds one selectable candidate and its package/container path to the immutable tree model. */
+ private void addCandidate(Map entries,
+ KaMoveNestedMemberComputer.TargetCandidate candidate) {
+ String parentPath = "";
+ String packagePath = packageKey(candidate.getPackageName());
+ final String rootPath = parentPath;
+ entries.computeIfAbsent(packagePath, path -> new TreeEntry(path,
+ candidate.getPackageName().isEmpty() ? "(default package)" : candidate.getPackageName(),
+ rootPath, null));
+ parentPath = packagePath;
+ List containers = candidate.getContainerPath();
+ for (int index = 0; index < containers.size(); index++) {
+ String container = containers.get(index);
+ String path = parentPath + '/' + container;
+ boolean leaf = index == containers.size() - 1;
+ final String finalParentPath = parentPath;
+ entries.compute(path, (ignored, existing) -> existing == null
+ ? new TreeEntry(path, container, finalParentPath, leaf ? candidate : null)
+ : leaf ? existing.withCandidate(candidate) : existing);
+ parentPath = path;
+ }
+ }
+
+ /** Adds the source path as structural entries so its package and top-level owner can expand. */
+ private void addSourcePath(Map entries) {
+ String parentPath = "";
+ String packagePath = packageKey(ready.getSourcePackageName());
+ final String rootPath = parentPath;
+ entries.computeIfAbsent(packagePath, path -> new TreeEntry(path,
+ ready.getSourcePackageName().isEmpty() ? "(default package)" : ready.getSourcePackageName(),
+ rootPath, null));
+ parentPath = packagePath;
+ for (String container : ready.getSourceContainerPath()) {
+ String path = parentPath + '/' + container;
+ final String finalParentPath = parentPath;
+ entries.computeIfAbsent(path, ignored -> new TreeEntry(path, container, finalParentPath, null));
+ parentPath = path;
+ }
+ }
+
+ /** Produces a collision-free internal path prefix for one package. */
+ private String packageKey(String packageName) { return "package:" + packageName; }
+
+ /** Copies only the selection; semantic validation is deferred to the plugin. */
+ void copyTo(KotlinMoveNestedMemberRefactoring refactoring) {
+ KaMoveNestedMemberComputer.TargetCandidate selected = selected();
+ refactoring.setTargetFilePath(selected == null ? "" : selected.getFilePath());
+ refactoring.setTargetOffset(selected == null ? -1 : selected.getOffset());
+ }
+
+ /** @return selected immutable target candidate, if exactly one selectable node is active. */
+ KaMoveNestedMemberComputer.TargetCandidate selected() {
+ Node[] selectedNodes = explorerManager.getSelectedNodes();
+ return selectedNodes.length == 1
+ ? selectedNodes[0].getLookup().lookup(KaMoveNestedMemberComputer.TargetCandidate.class)
+ : null;
+ }
+
+ /** Expands the source package and top-level container, then focuses the tree. */
+ @Override public void initialize() {
+ StringBuilder path = new StringBuilder(packageKey(ready.getSourcePackageName()));
+ TargetNode packageNode = nodesByPath.get(path.toString());
+ if (packageNode != null) targets.expandNode(packageNode);
+ if (!ready.getSourceContainerPath().isEmpty()) {
+ path.append('/').append(ready.getSourceContainerPath().get(0));
+ TargetNode topLevel = nodesByPath.get(path.toString());
+ if (topLevel != null) targets.expandNode(topLevel);
+ }
+ targets.requestFocusInWindow();
+ }
+
+ /** @return this panel itself. */
+ @Override public Component getComponent() { return this; }
+ }
+
+ /** Immutable path record used to allocate every NetBeans node with its final parent and lookup. */
+ private static final class TreeEntry {
+ private final String path;
+ private final String displayName;
+ private final String parentPath;
+ private final KaMoveNestedMemberComputer.TargetCandidate candidate;
+ private TargetNode node;
+
+ /** Creates one immutable package/container path record. */
+ TreeEntry(String path, String displayName, String parentPath,
+ KaMoveNestedMemberComputer.TargetCandidate candidate) {
+ this.path = path;
+ this.displayName = displayName;
+ this.parentPath = parentPath;
+ this.candidate = candidate;
+ }
+
+ /** @return a copy whose final path component is selectable. */
+ TreeEntry withCandidate(KaMoveNestedMemberComputer.TargetCandidate target) {
+ return new TreeEntry(path, displayName, parentPath, target);
+ }
+
+ /** @return component count used to construct parents before descendants. */
+ int depth() { return path.isEmpty() ? 0 : path.split("/").length; }
+ }
+
+ /** Explorer node that is selectable only when it contains a concrete target candidate. */
+ private static final class TargetNode extends AbstractNode {
+ private final Children.Array children;
+
+ /** Creates one package/container/target node backed by immutable controller data. */
+ TargetNode(String displayName, KaMoveNestedMemberComputer.TargetCandidate candidate) {
+ this(new Children.Array(), displayName, candidate);
+ }
+
+ /** Creates the node after allocating mutable children for the target hierarchy. */
+ private TargetNode(Children.Array children, String displayName,
+ KaMoveNestedMemberComputer.TargetCandidate candidate) {
+ super(children, candidate == null ? Lookup.EMPTY : Lookups.fixed(candidate));
+ this.children = children;
+ setDisplayName(displayName);
+ }
+
+ /** Appends one child while retaining the controller-determined sort order. */
+ void add(TargetNode child) { children.add(new Node[] { child }); }
+ }
+}
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 015ee5de..3f471373 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
@@ -42,6 +42,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.KotlinMoveNestedMemberPlugin;
+import io.github.nbplugins.kotlin.nbm.refactoring.KotlinMoveNestedMemberRefactoring;
import io.github.nbplugins.kotlin.nbm.refactoring.KotlinMoveFilePlugin;
import io.github.nbplugins.kotlin.nbm.refactoring.KotlinMoveFileRefactoring;
import io.github.nbplugins.kotlin.nbm.refactoring.KotlinChangePackagePlugin;
@@ -99,6 +101,7 @@
* {@link KotlinIntroduceFunctionalParameterRefactoring} — delegates to {@link KotlinIntroduceFunctionalParameterPlugin}
* {@link KotlinCopyDeclarationRefactoring} — delegates to {@link KotlinCopyDeclarationPlugin}
* {@link KotlinMoveDeclarationRefactoring} — delegates to {@link KotlinMoveDeclarationPlugin}
+ * {@link KotlinMoveNestedMemberRefactoring} — delegates to {@link KotlinMoveNestedMemberPlugin}
* {@link KotlinMoveFileRefactoring} — delegates to {@link KotlinMoveFilePlugin}
* {@link KotlinChangePackageRefactoring} — delegates to {@link KotlinChangePackagePlugin}
* {@link KotlinChangeSignatureRefactoring} — delegates to {@link KotlinChangeSignaturePlugin}
@@ -180,6 +183,9 @@ public RefactoringPlugin createInstance(AbstractRefactoring refactoring) {
if (refactoring instanceof KotlinMoveDeclarationRefactoring) {
return new KotlinMoveDeclarationPlugin((KotlinMoveDeclarationRefactoring) refactoring);
}
+ if (refactoring instanceof KotlinMoveNestedMemberRefactoring) {
+ return new KotlinMoveNestedMemberPlugin((KotlinMoveNestedMemberRefactoring) refactoring);
+ }
if (refactoring instanceof KotlinChangeSignatureRefactoring) {
return new KotlinChangeSignaturePlugin((KotlinChangeSignatureRefactoring) refactoring);
}
diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveNestedMemberAction.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveNestedMemberAction.kt
new file mode 100644
index 00000000..69ed6e08
--- /dev/null
+++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveNestedMemberAction.kt
@@ -0,0 +1,73 @@
+/*******************************************************************************
+ * 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.KaMoveNestedMemberComputer
+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
+
+/** Opens the target-selection UI for the portable K2 Move Nested Member subset. */
+class KotlinMoveNestedMemberAction : BaseAction(ACTION_NAME, SAVE_POSITION or ABBREV_RESET) {
+ init {
+ putValue(NAME, "Move Nested Member...")
+ putValue(SHORT_DESCRIPTION, "Move Nested Kotlin Class or Companion Member")
+ putValue(POPUP_MENU_TEXT, "Move Nested Member...")
+ }
+
+ /** Resolves the source declaration and controller-owned target candidates before opening the view. */
+ override fun actionPerformed(evt: ActionEvent, target: JTextComponent) {
+ val document = target.document as? StyledDocument ?: return
+ runCatching {
+ val source = ProjectUtils.getFileObjectForDocument(document)
+ ?: error("Move Nested Member requires a saved Kotlin source file.")
+ val project = ProjectUtils.getKotlinProjectForFileObject(source)
+ ?: ProjectUtils.getValidProject()
+ ?: error("Move Nested Member could not resolve the Kotlin project.")
+ // Target discovery scans the session's immutable source-root snapshot. Rebuild it now so
+ // Kotlin files created or saved since the previous editor analysis are selectable.
+ KotlinAnalysisAPISession.invalidate(project)
+ val session = KotlinAnalysisAPISession.getSession(project)
+ session.updateFileContent(source.path, document.getText(0, document.length))
+ val sourcePsi = session.getKtFileForPath(source.path)
+ ?: error("Move Nested Member could not resolve the Kotlin source file.")
+ val computer = KaMoveNestedMemberComputer(sourcePsi, target.caretPosition)
+ val ready = computer.compute() as? KaMoveNestedMemberComputer.Outcome.Ready
+ ?: error("Move Nested Member is not available at this caret location.")
+ val candidates = computer.discoverTargets(
+ session.fileMap.keys.mapNotNull(session::getKtFileForPath),
+ )
+ if (candidates.isEmpty()) error("Move Nested Member found no compatible target class or object.")
+ val refactoring = KotlinMoveNestedMemberRefactoring(document, target.caretPosition)
+ UI.openRefactoringUI(
+ KotlinMoveNestedMemberUI(ready, candidates, refactoring),
+ TopComponent.getRegistry().activated,
+ )
+ }.onFailure { KotlinLogger.INSTANCE.logException("Move Nested Member action failed", it) }
+ }
+
+ companion object {
+ /** Layer action identifier. */
+ const val ACTION_NAME = "kotlin-move-nested-member"
+ }
+}
diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveNestedMemberPlugin.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveNestedMemberPlugin.kt
new file mode 100644
index 00000000..23901079
--- /dev/null
+++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveNestedMemberPlugin.kt
@@ -0,0 +1,170 @@
+/*******************************************************************************
+ * 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.KaMoveNestedMemberComputer
+import org.jetbrains.kotlin.log.KotlinLogger
+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 java.io.File
+import javax.swing.text.Position.Bias
+
+/** Integrates the safe K2 Move Nested Member subset with NetBeans preview, apply, and undo. */
+class KotlinMoveNestedMemberPlugin(
+ private val refactoring: KotlinMoveNestedMemberRefactoring,
+) : ProgressProviderAdapter(), RefactoringPlugin {
+ override fun preCheck(): Problem? = null
+ override fun fastCheckParameters(): Problem? = null
+ override fun checkParameters(): Problem? = null
+ override fun cancelRequest() {}
+
+ /** Re-resolves the selection and adds preview/apply elements only after a conflict-free validation. */
+ override fun prepare(bag: RefactoringElementsBag): Problem? {
+ val source = ProjectUtils.getFileObjectForDocument(refactoring.document)
+ ?: return Problem(true, "Move Nested Member requires a saved Kotlin source file.")
+ val project = ProjectUtils.getKotlinProjectForFileObject(source)
+ ?: ProjectUtils.getValidProject()
+ ?: return Problem(true, "Move Nested Member could not resolve the Kotlin project.")
+ val request = refactoring.request()
+ ?: return Problem(true, "Select a target class or object.")
+ val target = fileObject(request.filePath)
+ ?: return Problem(true, "Move Nested Member could not resolve the target Kotlin file.")
+
+ KotlinAnalysisAPISession.invalidate(project)
+ val session = KotlinAnalysisAPISession.getSession(project)
+ val sourcePsi = session.getKtFileForPath(source.path)
+ ?: return Problem(true, "Move Nested Member could not resolve the Kotlin source PSI.")
+ val targetPsi = session.getKtFileForPath(target.path)
+ ?: return Problem(true, "Move Nested Member could not resolve the target Kotlin PSI.")
+ val computer = KaMoveNestedMemberComputer(sourcePsi, refactoring.caretOffset)
+ return when (val ready = computer.compute()) {
+ KaMoveNestedMemberComputer.Outcome.NotApplicable ->
+ Problem(true, "Move Nested Member is no longer applicable at this caret location.")
+ is KaMoveNestedMemberComputer.Outcome.Error -> Problem(true, ready.error.message ?: "Move Nested Member failed.")
+ is KaMoveNestedMemberComputer.Outcome.Ready -> when (val conflicts = computer.checkConflicts(targetPsi, request.offset)) {
+ KaMoveNestedMemberComputer.ConflictCheck.Clear -> {
+ runCatching {
+ bag.add(
+ refactoring,
+ KotlinFindUsagesResultElement(
+ OffsetRange(ready.declarationRange.startOffset, ready.declarationRange.endOffset),
+ source,
+ ),
+ )
+ }
+ bag.add(refactoring, KotlinMoveNestedMemberApplyElement(source, project, refactoring))
+ null
+ }
+ is KaMoveNestedMemberComputer.ConflictCheck.Conflicts -> Problem(true, conflicts.messages.joinToString("\n"))
+ KaMoveNestedMemberComputer.ConflictCheck.NotApplicable ->
+ Problem(true, "Move Nested Member target is no longer available.")
+ is KaMoveNestedMemberComputer.ConflictCheck.Error ->
+ Problem(true, conflicts.error.message ?: "Move Nested Member validation failed.")
+ }
+ }
+ }
+
+ /** Resolves an absolute K2 virtual-file path to the matching NetBeans file object. */
+ private fun fileObject(path: String): FileObject? =
+ FileUtil.toFileObject(FileUtil.normalizeFile(File(path)))
+}
+
+/** Applies all K2-mutated file texts through one atomic transaction and retains it for undo. */
+private class KotlinMoveNestedMemberApplyElement(
+ private val sourceFile: FileObject,
+ private val project: org.netbeans.api.project.Project,
+ private val refactoring: KotlinMoveNestedMemberRefactoring,
+) : SimpleRefactoringElementImplementation() {
+ private var transaction: KotlinRefactoringTransaction? = null
+
+ override fun getText(): String = "Move nested member"
+ override fun getDisplayText(): String = text
+ override fun getLookup(): Lookup = Lookups.fixed(sourceFile)
+ override fun getParentFile(): FileObject = sourceFile
+ override fun getPosition(): PositionBounds? = try {
+ val support = org.openide.loaders.DataObject.find(sourceFile)
+ .lookup.lookup(CloneableEditorSupport::class.java) ?: return null
+ PositionBounds(support.createPositionRef(0, Bias.Forward), support.createPositionRef(0, Bias.Backward))
+ } catch (_: Exception) { null }
+
+ /** Refreshes K2 PSI, stages every returned final text, and commits them atomically. */
+ override fun performChange() {
+ var pending: KotlinRefactoringTransaction? = null
+ runCatching {
+ val request = refactoring.request() ?: error("Move Nested Member target is incomplete.")
+ val target = fileObject(request.filePath) ?: error("Move Nested Member could not resolve target file.")
+ KotlinAnalysisAPISession.invalidate(project)
+ val session = KotlinAnalysisAPISession.getSession(project)
+ val sourcePsi = session.getKtFileForPath(sourceFile.path)
+ ?: error("Move Nested Member could not refresh source Kotlin PSI.")
+ val targetPsi = session.getKtFileForPath(target.path)
+ ?: error("Move Nested Member could not refresh target Kotlin PSI.")
+ val current = KotlinRefactoringTransaction()
+ pending = current
+ current.captureExisting(sourceFile)
+ if (target != sourceFile) current.captureExisting(target)
+
+ when (val result = KaMoveNestedMemberComputer(sourcePsi, refactoring.caretOffset).move(targetPsi, request.offset)) {
+ is KaMoveNestedMemberComputer.Apply.Success -> {
+ result.changedFiles.forEach { (path, text) ->
+ val changed = fileObject(path)
+ ?: error("Move Nested Member could not resolve changed file $path.")
+ current.captureExisting(changed)
+ current.stageHunkText(changed, text, project)
+ }
+ current.commit()
+ transaction = current
+ pending = null
+ }
+ is KaMoveNestedMemberComputer.Apply.Conflicts ->
+ error(result.messages.joinToString("\n"))
+ KaMoveNestedMemberComputer.Apply.NotApplicable ->
+ error("Move Nested Member is no longer applicable.")
+ is KaMoveNestedMemberComputer.Apply.Error -> throw result.error
+ }
+ }.onFailure { KotlinLogger.INSTANCE.logException("Move Nested Member apply failed", it) }
+ runCatching { pending?.rollback() }
+ .onFailure { KotlinLogger.INSTANCE.logException("Move Nested Member rollback failed", it) }
+ KotlinAnalysisAPISession.invalidate(project)
+ }
+
+ /** Restores every transaction participant for NetBeans Undo Last Refactoring. */
+ override fun undoChange() {
+ runCatching {
+ transaction?.undo()
+ transaction = null
+ KotlinAnalysisAPISession.invalidate(project)
+ }.onFailure { KotlinLogger.INSTANCE.logException("Undo Move Nested Member failed", it) }
+ }
+
+ /** Resolves a session virtual-file path to NetBeans persistence infrastructure. */
+ private fun fileObject(path: String): FileObject? =
+ FileUtil.toFileObject(FileUtil.normalizeFile(File(path)))
+}
diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveNestedMemberRefactoring.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveNestedMemberRefactoring.kt
new file mode 100644
index 00000000..d4c24716
--- /dev/null
+++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveNestedMemberRefactoring.kt
@@ -0,0 +1,56 @@
+/*******************************************************************************
+ * 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
+import javax.swing.text.StyledDocument
+
+/** Immutable target selection used by the move-nested-member controller and plugin. */
+data class KotlinMoveNestedMemberTarget(
+ /** Absolute path of the Kotlin file containing the selected target. */
+ val filePath: String,
+ /** Stable PSI offset inside [filePath] identifying the selected class/object. */
+ val offset: Int,
+)
+
+/**
+ * NetBeans refactoring carrier for one Kotlin Move Nested Member operation.
+ *
+ * @param document editor document containing the declaration at invocation time.
+ * @param caretOffset caret position used to resolve the source declaration again before apply.
+ */
+class KotlinMoveNestedMemberRefactoring(
+ /** Editor document containing the source declaration. */
+ val document: StyledDocument,
+ /** Caret position used to resolve the source declaration. */
+ val caretOffset: Int,
+) : AbstractRefactoring(Lookups.fixed(document)) {
+ /** Absolute selected target-file path, populated by the passive target chooser. */
+ var targetFilePath: String = ""
+
+ /** Selected target class/object offset, populated by the passive target chooser. */
+ var targetOffset: Int = -1
+
+ /**
+ * Returns the complete immutable target selection, or `null` when the dialog is incomplete.
+ *
+ * @return selected target file/offset when valid; otherwise `null`.
+ */
+ fun request(): KotlinMoveNestedMemberTarget? = targetFilePath.takeIf(String::isNotBlank)
+ ?.let { path -> targetOffset.takeIf { it >= 0 }?.let { KotlinMoveNestedMemberTarget(path, it) } }
+}
diff --git a/Nbm/src/main/resources/org/jetbrains/kotlin/layer.xml b/Nbm/src/main/resources/org/jetbrains/kotlin/layer.xml
index b8420384..0bbac40d 100644
--- a/Nbm/src/main/resources/org/jetbrains/kotlin/layer.xml
+++ b/Nbm/src/main/resources/org/jetbrains/kotlin/layer.xml
@@ -111,6 +111,10 @@
+
+
+
+
@@ -149,6 +153,10 @@
+
+
+
+
diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaMoveNestedMemberComputerTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaMoveNestedMemberComputerTest.kt
new file mode 100644
index 00000000..d5b6a39f
--- /dev/null
+++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaMoveNestedMemberComputerTest.kt
@@ -0,0 +1,257 @@
+/*******************************************************************************
+ * 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.KaMoveNestedMemberComputer
+import org.jetbrains.kotlin.psi.KtClass
+import org.jetbrains.kotlin.psi.KtFile
+import org.jetbrains.kotlin.psi.KtNamedDeclaration
+import org.jetbrains.kotlin.psi.psiUtil.collectDescendantsOfType
+import utils.KotlinTestCase
+import java.nio.file.Files
+import java.nio.file.Path
+
+/** Tests the portable standalone K2 contract for moving nested Kotlin declarations. */
+class KaMoveNestedMemberComputerTest : KotlinTestCase("KaMoveNestedMemberComputerTest", "moveNestedMember") {
+
+ /** Moves a nested class between two named Kotlin classes. */
+ fun testMove_nestedClass_movesToTargetClass() {
+ val fixture = createFixture(
+ sourceBody = "class Nested(val value: Int)",
+ targetBody = "",
+ ) ?: return
+ try {
+ val result = fixture.computer("Nested").move(fixture.targetFile, fixture.target.textOffset)
+
+ assertTrue("Expected successful nested-class move, got $result", result is KaMoveNestedMemberComputer.Apply.Success)
+ result as KaMoveNestedMemberComputer.Apply.Success
+ assertFalse("Source must no longer contain moved nested class:\n${result.sourceText}", result.sourceText.contains("class Nested"))
+ assertTrue("Source closing brace must remain on its own line:\n${result.sourceText}", result.sourceText.contains("class Source {\n}"))
+ assertTrue(
+ "Target must contain the moved class inside its body:\n${result.targetText}",
+ result.targetText.indexOf("class Target {") < result.targetText.indexOf("class Nested") &&
+ result.targetText.indexOf("class Nested") < result.targetText.lastIndexOf("}"),
+ )
+ } finally {
+ fixture.directory.toFile().deleteRecursively()
+ }
+ }
+
+ /** Discovers named class/object targets other than the nested class's current owner. */
+ fun testDiscoverTargets_nestedClass_listsEligibleNamedContainers() {
+ val fixture = createFixture(
+ sourceBody = "class Nested(val value: Int)",
+ targetBody = "",
+ ) ?: return
+ try {
+ val targets = fixture.computer("Nested").discoverTargets(listOf(fixture.sourceFile, fixture.targetFile))
+
+ assertEquals(1, targets.size)
+ assertEquals("Target", targets.single().presentation)
+ assertEquals("nestedmove", targets.single().packageName)
+ assertEquals(listOf("Target"), targets.single().containerPath)
+ assertEquals(fixture.targetFile.virtualFile?.path, targets.single().filePath)
+ assertEquals(fixture.target.textOffset, targets.single().offset)
+ } finally {
+ fixture.directory.toFile().deleteRecursively()
+ }
+ }
+
+ /** Discovers and populates a bodyless top-level target declared in the same Kotlin file. */
+ fun testMove_nestedClass_populatesBodylessTargetInSameFile() {
+ val stdlib = findKotlinStdlib() ?: return
+ val directory = Files.createTempDirectory("nbkotlin-move-nested-member-bodyless-target")
+ val sourcePath = directory.resolve("Source.kt")
+ Files.writeString(
+ sourcePath,
+ "package nestedmove\n\nclass Source {\n class Nested(val value: Int)\n}\n\nclass Target\n",
+ )
+ try {
+ val session = KotlinAnalysisAPISession.createWithJars(
+ moduleName = "move-nested-member-bodyless-target",
+ binaryJars = listOf(stdlib),
+ sourceRoots = listOf(directory),
+ )
+ val file = session.getKtFileForPath(sourcePath.toString()) ?: return
+ val source = file.declarations.filterIsInstance().single { it.name == "Source" }
+ val target = file.declarations.filterIsInstance().single { it.name == "Target" }
+ val computer = KaMoveNestedMemberComputer(
+ file,
+ source.collectDescendantsOfType().first { it.name == "Nested" }.textOffset,
+ )
+
+ val candidates = computer.discoverTargets(listOf(file))
+ assertEquals(listOf("Target"), candidates.map { it.presentation })
+
+ val result = computer.move(file, target.textOffset)
+ assertTrue("Expected successful move to a bodyless target, got $result", result is KaMoveNestedMemberComputer.Apply.Success)
+ result as KaMoveNestedMemberComputer.Apply.Success
+ assertTrue("Target must gain a body:\n${result.targetText}", result.targetText.contains("class Target {"))
+ assertTrue(
+ "Moved class must be inside Target's body:\n${result.targetText}",
+ result.targetText.indexOf("class Target {") < result.targetText.indexOf("class Nested") &&
+ result.targetText.indexOf("class Nested") < result.targetText.lastIndexOf("}"),
+ )
+ } finally {
+ directory.toFile().deleteRecursively()
+ }
+ }
+
+ /** Discovers only class/object targets with exactly one companion for companion-member moves. */
+ fun testDiscoverTargets_companionMember_requiresSingleTargetCompanion() {
+ val fixture = createFixture(
+ sourceBody = "companion object { fun greet(): String = \"hello\" }",
+ targetBody = "companion object { }",
+ ) ?: return
+ try {
+ val withoutCompanionPath = fixture.directory.resolve("NoCompanion.kt")
+ Files.writeString(withoutCompanionPath, "package nestedmove\n\nclass NoCompanion { }\n")
+ val withTwoCompanionsPath = fixture.directory.resolve("TwoCompanions.kt")
+ Files.writeString(
+ withTwoCompanionsPath,
+ "package nestedmove\n\nclass TwoCompanions { companion object First { } companion object Second { } }\n",
+ )
+ val session = KotlinAnalysisAPISession.createWithJars(
+ moduleName = "move-nested-member-target-discovery",
+ binaryJars = listOf(findKotlinStdlib() ?: return),
+ sourceRoots = listOf(fixture.directory),
+ )
+ val sourceFile = session.getKtFileForPath(fixture.directory.resolve("Source.kt").toString()) ?: return
+ val targetFile = session.getKtFileForPath(fixture.directory.resolve("Target.kt").toString()) ?: return
+ val noCompanionFile = session.getKtFileForPath(withoutCompanionPath.toString()) ?: return
+ val twoCompanionsFile = session.getKtFileForPath(withTwoCompanionsPath.toString()) ?: return
+ val source = sourceFile.declarations.filterIsInstance().single { it.name == "Source" }
+ val computer = KaMoveNestedMemberComputer(
+ sourceFile,
+ source.collectDescendantsOfType().first { it.name == "greet" }.textOffset,
+ )
+
+ val targets = computer.discoverTargets(listOf(sourceFile, targetFile, noCompanionFile, twoCompanionsFile))
+
+ assertEquals(listOf("Target"), targets.map { it.presentation })
+ assertTrue(targets.single().requiresCompanionTarget)
+ } finally {
+ fixture.directory.toFile().deleteRecursively()
+ }
+ }
+
+ /** Moves a companion function into the selected target companion object. */
+ fun testMove_companionFunction_movesToTargetCompanion() {
+ val fixture = createFixture(
+ sourceBody = "companion object { fun greet(): String = \"hello\" }",
+ targetBody = "companion object { }",
+ ) ?: return
+ try {
+ val result = fixture.computer("greet").move(fixture.targetFile, fixture.target.textOffset)
+
+ assertTrue("Expected successful companion-function move, got $result", result is KaMoveNestedMemberComputer.Apply.Success)
+ result as KaMoveNestedMemberComputer.Apply.Success
+ assertFalse("Source must no longer contain moved function:\n${result.sourceText}", result.sourceText.contains("fun greet"))
+ assertTrue("Target must contain moved function:\n${result.targetText}", result.targetText.contains("fun greet"))
+ } finally {
+ fixture.directory.toFile().deleteRecursively()
+ }
+ }
+
+ /** Moves a companion property into the target companion object. */
+ fun testMove_companionProperty_movesToTargetCompanion() {
+ val fixture = createFixture(
+ sourceBody = "companion object { val answer: Int = 42 }",
+ targetBody = "companion object { }",
+ ) ?: return
+ try {
+ val result = fixture.computer("answer").move(fixture.targetFile, fixture.target.textOffset)
+
+ assertTrue("Expected successful companion-property move, got $result", result is KaMoveNestedMemberComputer.Apply.Success)
+ result as KaMoveNestedMemberComputer.Apply.Success
+ assertFalse("Source must no longer contain moved property:\n${result.sourceText}", result.sourceText.contains("val answer"))
+ assertTrue("Target must contain moved property:\n${result.targetText}", result.targetText.contains("val answer"))
+ } finally {
+ fixture.directory.toFile().deleteRecursively()
+ }
+ }
+
+ /** Reports a target member collision before mutating standalone K2 PSI. */
+ fun testCheckConflicts_duplicateTargetMember_reportsConflict() {
+ val fixture = createFixture(
+ sourceBody = "class Nested",
+ targetBody = "class Nested",
+ ) ?: return
+ try {
+ val result = fixture.computer("Nested").checkConflicts(fixture.targetFile, fixture.target.textOffset)
+
+ assertTrue("Expected target collision, got $result", result is KaMoveNestedMemberComputer.ConflictCheck.Conflicts)
+ result as KaMoveNestedMemberComputer.ConflictCheck.Conflicts
+ assertTrue("Expected a duplicate-member message, got ${result.messages}", result.messages.any { it.contains("Nested") })
+ } finally {
+ fixture.directory.toFile().deleteRecursively()
+ }
+ }
+
+ /** Rejects an ordinary instance member because relocating its receiver is not mechanically safe. */
+ fun testCompute_instanceFunction_isNotApplicable() {
+ val fixture = createFixture(sourceBody = "fun greet(): String = \"hello\"", targetBody = "") ?: return
+ try {
+ assertSame(KaMoveNestedMemberComputer.Outcome.NotApplicable, fixture.computer("greet").compute())
+ } finally {
+ fixture.directory.toFile().deleteRecursively()
+ }
+ }
+
+ /** Creates a standalone K2 project with source, target, and optional external usage files. */
+ private fun createFixture(sourceBody: String, targetBody: String, usage: String? = null): Fixture? {
+ val stdlib = findKotlinStdlib() ?: return null
+ val directory = Files.createTempDirectory("nbkotlin-move-nested-member")
+ val sourcePath = directory.resolve("Source.kt")
+ val targetPath = directory.resolve("Target.kt")
+ Files.writeString(sourcePath, "package nestedmove\n\nclass Source {\n $sourceBody\n}\n")
+ Files.writeString(targetPath, "package nestedmove\n\nclass Target {\n $targetBody\n}\n")
+ usage?.let { Files.writeString(directory.resolve("Usage.kt"), "package nestedmove\n\n$it\n") }
+ val session = KotlinAnalysisAPISession.createWithJars(
+ moduleName = "move-nested-member-integration",
+ binaryJars = listOf(stdlib),
+ sourceRoots = listOf(directory),
+ )
+ val sourceFile = session.getKtFileForPath(sourcePath.toString()) ?: return null
+ val targetFile = session.getKtFileForPath(targetPath.toString()) ?: return null
+ val source = sourceFile.declarations.filterIsInstance().single { it.name == "Source" }
+ val target = targetFile.declarations.filterIsInstance().single { it.name == "Target" }
+ return Fixture(directory, sourceFile, targetFile, source, target)
+ }
+
+ /** Finds the Kotlin standard library required by standalone Analysis API fixtures. */
+ private fun findKotlinStdlib(): 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() }
+
+ /** Holds standalone K2 source and target inputs for one narrow move request. */
+ private data class Fixture(
+ val directory: Path,
+ val sourceFile: KtFile,
+ val targetFile: KtFile,
+ val source: KtClass,
+ val target: KtClass,
+ ) {
+ /** Resolves one source declaration by name into the move computer. */
+ fun computer(memberName: String): KaMoveNestedMemberComputer = KaMoveNestedMemberComputer(
+ sourceFile,
+ source.collectDescendantsOfType().first { it.name == memberName }.textOffset,
+ )
+ }
+}
diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveNestedMemberRefactoringTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveNestedMemberRefactoringTest.kt
new file mode 100644
index 00000000..4e7bbb58
--- /dev/null
+++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveNestedMemberRefactoringTest.kt
@@ -0,0 +1,49 @@
+/*******************************************************************************
+ * 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 javax.swing.text.DefaultStyledDocument
+
+/** Tests selection validation in [KotlinMoveNestedMemberRefactoring]. */
+class KotlinMoveNestedMemberRefactoringTest : NbTestCase("KotlinMoveNestedMemberRefactoringTest") {
+
+ /** Returns `null` until the target file path and offset are both supplied. */
+ fun testRequest_requiresCompleteTargetSelection() {
+ val refactoring = KotlinMoveNestedMemberRefactoring(DefaultStyledDocument(), 12)
+
+ assertNull(refactoring.request())
+ refactoring.targetFilePath = "/project/Target.kt"
+ assertNull(refactoring.request())
+ refactoring.targetOffset = 25
+
+ assertEquals(
+ KotlinMoveNestedMemberTarget("/project/Target.kt", 25),
+ refactoring.request(),
+ )
+ }
+
+ /** Verifies the plugin lifecycle guards are available before NetBeans resolves project PSI. */
+ fun testPlugin_lifecycleHooks_doNotThrow() {
+ val plugin = KotlinMoveNestedMemberPlugin(KotlinMoveNestedMemberRefactoring(DefaultStyledDocument(), 0))
+
+ assertNull(plugin.preCheck())
+ assertNull(plugin.fastCheckParameters())
+ assertNull(plugin.checkParameters())
+ plugin.cancelRequest()
+ }
+}
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 7b00d3ac..b4ef56bf 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, 22 partial, 2 absent, 24 total."
+ val totals = "**Baseline counts:** 0 complete, 23 partial, 1 absent, 24 total."
assertTrue("Visible baseline totals must match the records", Files.readString(matrix).contains(totals))
- assertEquals(22, records.count { it.status == "partial" })
- assertEquals(2, records.count { it.status == "absent" })
+ assertEquals(23, records.count { it.status == "partial" })
+ assertEquals(1, records.count { it.status == "absent" })
}
/** Finds the checkout root from Surefire's module working directory. */
diff --git a/Nbm/src/test/resources/projForTest/src/moveNestedMember/.gitkeep b/Nbm/src/test/resources/projForTest/src/moveNestedMember/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/docs/development-plan.md b/docs/development-plan.md
index 2c1715e3..ac0bf24e 100644
--- a/docs/development-plan.md
+++ b/docs/development-plan.md
@@ -779,6 +779,20 @@ Priority order: E1 → E2 → E3 → E4 → E5 → E6 → E7 → E8 → E9 → E
document. Java/comment/text/index-only references, full IDEA project-model conflicts, and
arbitrary multi-selection remain outside the standalone scope.
+ - [x] **F4.3** — Move Nested Kotlin Member (Refactor menu)
+ - Adds **Refactor → Move Nested Member...** for the portable K2-native subset: a nested named
+ class/object can move to a selected named class/object; a companion-object function/property
+ can move only to a selected target declaring exactly one companion object.
+ - `KaMoveNestedMemberComputer` retains IDEA K2 internal-reference marking, rebinding, and
+ shortening support while the passive NetBeans target chooser receives controller-computed
+ candidates and never invokes K2 directly.
+ - `KotlinMoveNestedMemberPlugin` validates a freshly resolved source/target immediately before
+ mutation, stages every returned changed file through `KotlinRefactoringTransaction`, rolls back
+ on a persistence failure, and supports **Refactor → Undo Last Refactoring** for source and target.
+ - The intentionally narrow standalone boundary rejects instance members, constructors, enum entries,
+ initializers, alias imports, duplicate targets, Java/non-code/comment/index-only scenarios, and
+ external qualified usages whose rewrite has not been validated.
+
- [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/refactoring-coverage.md b/docs/refactoring-coverage.md
index f7297e1d..9f467193 100644
--- a/docs/refactoring-coverage.md
+++ b/docs/refactoring-coverage.md
@@ -30,7 +30,7 @@ netbeans=; test=; milestone=
-
+
## Purpose and scope
@@ -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, 22 partial, 2 absent, 24 total.
+**Baseline counts:** 0 complete, 23 partial, 1 absent, 24 total.
## Current NetBeans refactorings
@@ -72,6 +72,7 @@ The corresponding record comments above are deliberately machine-readable. `Refa
| `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 |
+| `move-nested-member-method` | Move nested class/member/method | Refactor → Move Nested Member... | Partial | Moves a nested named Kotlin class/object to a selected named class/object, or a companion function/property to a selected target with exactly one companion object. Source/target changes commit atomically and support Undo Last Refactoring. Instance members, constructors, enum entries, aliases, Java/non-code/comment/index-only cases, and unvalidated external qualified-use rewrites 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 |
@@ -81,7 +82,6 @@ The corresponding record comments above are deliberately machine-readable. `Refa
| ID | IDEA family | Why absent | Target |
|---|---|---|---|
-| `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 |
## Cross-cutting milestones
diff --git a/pom.xml b/pom.xml
index 48feaee7..f1ed97fd 100644
--- a/pom.xml
+++ b/pom.xml
@@ -4,7 +4,7 @@
4.0.0
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.256-SNAPSHOT
+ 0.11.260-SNAPSHOT
pom
Netbeans Plugin Kotlin
@@ -582,6 +582,11 @@
org-openide-nodes
${netbeans.version}
+
+ org.netbeans.api
+ org-openide-explorer
+ ${netbeans.version}
+
org.netbeans.api
org-netbeans-modules-editor-indent