diff --git a/CHANGELOG.md b/CHANGELOG.md
index 269fef6a..f2c6ea80 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,5 @@
+- Fixed Move Nested Member to preserve unsaved source, target, and usage edits, reject unsafe `inner` and self-descendant targets, and report failed applies; Undo Last Refactoring remains available only after a successful move.
+- Fixed Kotlin file and package-directory renames to show one immediate **Refactor** submenu in the Projects/Files context menu, treat unchanged names as no-ops, preserve requested spelling for case-only renames with Undo Last Refactoring, retain each open editor caret when a Change Kotlin Package operation is undone, and distinguish a selected open file's physical rename from editor symbol Rename while rejecting multi-file rename requests.
- Added structural conversion hints for Kotlin: with the caret on an operator or convention-named call you can now convert `a.plus(b)` to `a + b`, `a.get(i)`/`a.set(i, v)` to `a[i]`/`a[i] = v`, `a.contains(b)` to `b in a`, `a.invoke(b)` to `a(b)`, `a.not()`/`a.unaryPlus()` to `!a`/`+a`, and `f() {}` to `f {}` directly from the hint popup.
# 0.11.54 (2026-10-12)
diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml
index d1390a6a..747b8962 100644
--- a/CoreImpl/pom.xml
+++ b/CoreImpl/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.284-SNAPSHOT
+ 0.11.288-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-core-impl
diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml
index 80b1ad9b..37779516 100644
--- a/KotlinCodeInsightImpl/pom.xml
+++ b/KotlinCodeInsightImpl/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.284-SNAPSHOT
+ 0.11.288-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-code-insight-impl
diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml
index f77043d0..9dd2537f 100644
--- a/KotlinCompilerCliBase/pom.xml
+++ b/KotlinCompilerCliBase/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.284-SNAPSHOT
+ 0.11.288-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-compiler-cli-base
diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml
index 36f0c5a4..7769e689 100644
--- a/KotlinFixesImpl/pom.xml
+++ b/KotlinFixesImpl/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.284-SNAPSHOT
+ 0.11.288-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-fixes-impl
diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml
index 6f399a52..a27d05d0 100644
--- a/KotlinFormatter/pom.xml
+++ b/KotlinFormatter/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.284-SNAPSHOT
+ 0.11.288-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-formatter
diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml
index e3b7b97c..78ea1bf2 100644
--- a/KotlinHighlighting/pom.xml
+++ b/KotlinHighlighting/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.284-SNAPSHOT
+ 0.11.288-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-highlighting
diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml
index c25cecf3..3b220ae4 100644
--- a/KotlinIcons/pom.xml
+++ b/KotlinIcons/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.284-SNAPSHOT
+ 0.11.288-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-icons
diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml
index be365f8a..6a024d1b 100644
--- a/KotlinIntentionUtils/pom.xml
+++ b/KotlinIntentionUtils/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.284-SNAPSHOT
+ 0.11.288-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-intention-utils
diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml
index 6faa1a81..f1860deb 100644
--- a/KotlinRefactoring/pom.xml
+++ b/KotlinRefactoring/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.284-SNAPSHOT
+ 0.11.288-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
index bd78d257..9d25ae79 100644
--- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaMoveNestedMemberComputer.kt
+++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaMoveNestedMemberComputer.kt
@@ -23,6 +23,7 @@ import org.jetbrains.kotlin.idea.k2.refactoring.move.KotlinMoveUsageSearchServic
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.KtClass
import org.jetbrains.kotlin.psi.KtClassOrObject
import org.jetbrains.kotlin.psi.KtFile
import org.jetbrains.kotlin.psi.KtNamedDeclaration
@@ -176,7 +177,8 @@ class KaMoveNestedMemberComputer(
.asSequence()
.filter { target ->
target.name != null &&
- (target.containingKtFile.virtualFile?.path to target.textOffset) !in excludedOwners
+ (target.containingKtFile.virtualFile?.path to target.textOffset) !in excludedOwners &&
+ !isDescendantTarget(declaration, target)
}
.filter { target ->
when (kind) {
@@ -299,14 +301,20 @@ class KaMoveNestedMemberComputer(
/** 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 KtClassOrObject -> declaration.takeIf {
+ it.getStrictParentOfType() != null &&
+ (it !is KtClass || !it.isInner())
+ }?.let { Kind.NESTED_CLASS }
is KtNamedFunction, is KtProperty -> declaration.takeIf {
it.getStrictParentOfType()?.isCompanion() == true
}?.let { Kind.COMPANION_MEMBER }
else -> null
}
+ /** Returns whether [target] is nested in [declaration] and would be deleted with it. */
+ private fun isDescendantTarget(declaration: KtNamedDeclaration, target: KtClassOrObject): Boolean =
+ com.intellij.psi.util.PsiTreeUtil.isAncestor(declaration, target, false)
+
/** Locates the named class/object chosen by the NetBeans target-selection controller. */
private fun resolveTarget(targetFile: KtFile, targetOffset: Int): KtClassOrObject? =
targetFile.findElementAt(targetOffset)?.getNonStrictParentOfType()
@@ -318,6 +326,9 @@ class KaMoveNestedMemberComputer(
?: return listOf("Move source owner is unavailable.")
val messages = mutableListOf()
if (sourceOwner == target) messages += "The target class already owns ${declaration.name}."
+ if (isDescendantTarget(declaration, target)) {
+ messages += "The target cannot be inside the declaration being moved."
+ }
if (kind == Kind.COMPANION_MEMBER && target.companionObjects.singleOrNull() == null) {
messages += "Target ${target.name ?: "class"} must declare exactly one companion object."
}
diff --git a/Nbm/pom.xml b/Nbm/pom.xml
index 25b876a7..056758be 100644
--- a/Nbm/pom.xml
+++ b/Nbm/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.284-SNAPSHOT
+ 0.11.288-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-nbm
diff --git a/Nbm/src/main/java/org/jetbrains/kotlin/refactorings/rename/KotlinActionsImplementationProvider.java b/Nbm/src/main/java/org/jetbrains/kotlin/refactorings/rename/KotlinActionsImplementationProvider.java
index 6a4428cd..07fd861e 100644
--- a/Nbm/src/main/java/org/jetbrains/kotlin/refactorings/rename/KotlinActionsImplementationProvider.java
+++ b/Nbm/src/main/java/org/jetbrains/kotlin/refactorings/rename/KotlinActionsImplementationProvider.java
@@ -26,6 +26,7 @@
import io.github.nbplugins.kotlin.nbm.refactoring.KotlinProjectFolderRenameSelection;
import io.github.nbplugins.kotlin.nbm.refactoring.KotlinRenameDirectoryRefactoring;
import java.awt.EventQueue;
+import java.util.Collection;
import javax.swing.JEditorPane;
import javax.swing.text.StyledDocument;
import org.jetbrains.kotlin.builder.KotlinPsiManager;
@@ -39,6 +40,8 @@
import org.netbeans.modules.refactoring.spi.ui.UI;
import org.openide.cookies.EditorCookie;
import org.openide.filesystems.FileObject;
+import org.openide.loaders.DataObject;
+import org.openide.nodes.Node;
import org.openide.util.Lookup;
import org.openide.util.lookup.Lookups;
import org.openide.windows.TopComponent;
@@ -55,18 +58,24 @@
public class KotlinActionsImplementationProvider extends ActionsImplementationProvider {
/**
- * Returns whether the context is an eligible Kotlin package folder or an open Kotlin editor.
+ * Returns whether the context is an eligible Kotlin package folder, physical Kotlin file, or open Kotlin editor.
*
* @param lookup lookup supplied by the Refactor action
* @return {@code true} when this provider owns the Rename operation
*/
@Override
public boolean canRename(Lookup lookup) {
- return KotlinProjectFolderRenameSelection.INSTANCE.resolve(lookup) != null || canRenameEditor(lookup);
+ if (KotlinProjectFolderRenameSelection.INSTANCE.resolve(lookup) != null) {
+ return true;
+ }
+ if (hasNodeSelection(lookup)) {
+ return selectedKotlinFile(lookup) != null;
+ }
+ return canRenameEditor(lookup);
}
/**
- * Opens physical directory rename for Kotlin package folders or symbol rename for Kotlin editors.
+ * Opens physical file/directory rename for a project-tree selection or symbol rename for an editor selection.
*
* @param lookup lookup supplied by the Refactor action
*/
@@ -78,20 +87,77 @@ public void doRename(Lookup lookup) {
return;
}
+ if (hasNodeSelection(lookup)) {
+ FileObject file = selectedKotlinFile(lookup);
+ if (file != null) {
+ openFileRename(file);
+ }
+ return;
+ }
+
EditorCookie ec = lookup.lookup(EditorCookie.class);
- JEditorPane pane = ec.getOpenedPanes()[0];
- // Use selection start so that renaming a fully-selected word positions inside the word,
- // not past it. When no selection, getSelectionStart() == getCaretPosition().
- int caretPosition = pane.getSelectionStart();
- StyledDocument doc = ec.getDocument();
- FileObject fo = ProjectUtils.getFileObjectForDocument(doc);
- final KtFile ktFile = KotlinPsiManager.INSTANCE.getParsedFile(fo);
- final PsiElement psi = ktFile.findElementAt(caretPosition);
- // Do NOT include fo (FileObject) in the lookup — a built-in NetBeans rename plugin
- // would see it and rename the file on disk instead of renaming the code symbol.
- UI.openRefactoringUI(
- new KotlinRenameRefactoringUI(psi, new RenameRefactoring(Lookups.fixed(psi, doc, caretPosition))),
- TopComponent.getRegistry().getActivated());
+ if (ec != null && ec.getOpenedPanes() != null && ec.getOpenedPanes().length > 0) {
+ JEditorPane pane = ec.getOpenedPanes()[0];
+ // Use selection start so that renaming a fully-selected word positions inside the word,
+ // not past it. When no selection, getSelectionStart() == getCaretPosition().
+ int caretPosition = pane.getSelectionStart();
+ StyledDocument doc = ec.getDocument();
+ FileObject fo = doc == null ? null : ProjectUtils.getFileObjectForDocument(doc);
+ final KtFile ktFile = fo == null ? null : KotlinPsiManager.INSTANCE.getParsedFile(fo);
+ final PsiElement psi = ktFile == null ? null : ktFile.findElementAt(caretPosition);
+ if (psi != null) {
+ // Do NOT include fo (FileObject) in the lookup — a built-in NetBeans rename plugin
+ // would see it and rename the file on disk instead of renaming the code symbol.
+ UI.openRefactoringUI(
+ new KotlinRenameRefactoringUI(psi, new RenameRefactoring(Lookups.fixed(psi, doc, caretPosition))),
+ TopComponent.getRegistry().getActivated());
+ return;
+ }
+ }
+
+ }
+
+ /** Opens the explicit filesystem rename UI for one Kotlin file selected in a project tree. */
+ private void openFileRename(FileObject file) {
+ EventQueue.invokeLater(() -> UI.openRefactoringUI(
+ new KotlinFilesystemRenameUI(
+ new io.github.nbplugins.kotlin.nbm.refactoring.KotlinRenameFileRefactoring(file, file.getName()),
+ false,
+ file.getNameExt()),
+ TopComponent.getRegistry().getActivated()));
+ }
+
+ /**
+ * Resolves one physical Kotlin source file from a file-node lookup without requiring an editor.
+ *
+ * @param lookup lookup supplied by NetBeans
+ * @return selected valid Kotlin file, or {@code null} for folders and unrelated selections
+ */
+ static FileObject selectedKotlinFile(Lookup lookup) {
+ Collection extends Node> nodes = lookup.lookupAll(Node.class);
+ if (nodes.size() != 1) {
+ return null;
+ }
+ Lookup nodeLookup = nodes.iterator().next().getLookup();
+ FileObject file = nodeLookup.lookup(FileObject.class);
+ if (file == null) {
+ DataObject dataObject = nodeLookup.lookup(DataObject.class);
+ file = dataObject == null ? null : dataObject.getPrimaryFile();
+ }
+ return file != null && file.isValid() && !file.isFolder() && file.hasExt("kt") ? file : null;
+ }
+
+ /**
+ * Returns whether the lookup originated from an Explorer node selection.
+ *
+ * An Explorer selection must not fall back to the active editor because Refactor was invoked
+ * for the selected node, even when that file happens to be open.
+ *
+ * @param lookup lookup supplied by NetBeans
+ * @return {@code true} when one or more nodes are selected
+ */
+ static boolean hasNodeSelection(Lookup lookup) {
+ return !lookup.lookupAll(Node.class).isEmpty();
}
/**
diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/HunkedRefactoringTransaction.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/HunkedRefactoringTransaction.kt
index 110cd1b0..2f0cce73 100644
--- a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/HunkedRefactoringTransaction.kt
+++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/HunkedRefactoringTransaction.kt
@@ -31,11 +31,14 @@ import org.openide.text.NbDocument
* @param file transaction participant to update
* @param finalText resulting text produced by the K2 refactoring engine
* @param project Kotlin project whose formatter settings apply to each changed hunk
+ * @param caretOffset pre-refactoring offset to restore after native editor undo, or `null` when the
+ * file has no open editor pane
*/
internal fun KotlinRefactoringTransaction.stageHunkText(
file: FileObject,
finalText: String,
project: Project,
+ caretOffset: Int? = null,
) {
stageText(file, finalText) { document, originalText, targetText ->
val hunks = TextRangeDiff.computeHunks(originalText, targetText).sortedByDescending { it.oldStart }
@@ -57,6 +60,7 @@ internal fun KotlinRefactoringTransaction.stageHunkText(
}
}
}
+ if (hunks.isNotEmpty()) caretOffset?.let { joinCaretRestoreOnUndo(document, file, it) }
}
}
}
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
index 99a65604..15b5be23 100644
--- 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
@@ -28,8 +28,10 @@ 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.cookies.EditorCookie
import org.openide.filesystems.FileObject
import org.openide.filesystems.FileUtil
+import org.openide.loaders.DataObject
import org.openide.text.CloneableEditorSupport
import org.openide.text.PositionBounds
import org.openide.util.Lookup
@@ -135,7 +137,7 @@ class KotlinChangePackageApplyElement(
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.stageHunkText(file, text, project, openCaretOffset(file))
}
current.commit()
transaction = current
@@ -148,6 +150,17 @@ class KotlinChangePackageApplyElement(
KotlinAnalysisAPISession.invalidate(project)
}
+ /**
+ * Returns the caret offset from an already-open editor for [file] without opening a new editor.
+ *
+ * Native undo must restore each modified open editor independently; unopened files do not have a
+ * user-visible caret and therefore deliberately receive no restoration edit.
+ */
+ private fun openCaretOffset(file: FileObject): Int? = runCatching {
+ val cookie = DataObject.find(file).lookup.lookup(EditorCookie::class.java) ?: return null
+ cookie.openedPanes?.firstOrNull()?.caretPosition
+ }.getOrNull()
+
/** Restores all original package directives and usage texts. */
override fun undoChange() {
runCatching {
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
index 69ed6e08..46b44227 100644
--- 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
@@ -48,7 +48,7 @@ class KotlinMoveNestedMemberAction : BaseAction(ACTION_NAME, SAVE_POSITION or AB
// 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))
+ session.synchronizeOpenDocuments()
val sourcePsi = session.getKtFileForPath(source.path)
?: error("Move Nested Member could not resolve the Kotlin source file.")
val computer = KaMoveNestedMemberComputer(sourcePsi, target.caretPosition)
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
index 23901079..c2bedf88 100644
--- 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
@@ -59,6 +59,7 @@ class KotlinMoveNestedMemberPlugin(
KotlinAnalysisAPISession.invalidate(project)
val session = KotlinAnalysisAPISession.getSession(project)
+ session.synchronizeOpenDocuments()
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)
@@ -117,11 +118,12 @@ private class KotlinMoveNestedMemberApplyElement(
/** Refreshes K2 PSI, stages every returned final text, and commits them atomically. */
override fun performChange() {
var pending: KotlinRefactoringTransaction? = null
- runCatching {
+ try {
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)
+ session.synchronizeOpenDocuments()
val sourcePsi = session.getKtFileForPath(sourceFile.path)
?: error("Move Nested Member could not refresh source Kotlin PSI.")
val targetPsi = session.getKtFileForPath(target.path)
@@ -149,10 +151,18 @@ private class KotlinMoveNestedMemberApplyElement(
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)
+ } catch (error: Throwable) {
+ KotlinLogger.INSTANCE.logException("Move Nested Member apply failed", error)
+ try {
+ pending?.rollback()
+ } catch (rollbackError: Throwable) {
+ error.addSuppressed(rollbackError)
+ KotlinLogger.INSTANCE.logException("Move Nested Member rollback failed", rollbackError)
+ }
+ throw error
+ } finally {
+ KotlinAnalysisAPISession.invalidate(project)
+ }
}
/** Restores every transaction participant for NetBeans Undo Last Refactoring. */
diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRefactoringTransaction.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRefactoringTransaction.kt
index 25588240..3d4102c6 100644
--- a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRefactoringTransaction.kt
+++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRefactoringTransaction.kt
@@ -21,6 +21,7 @@ import org.openide.cookies.EditorCookie
import org.openide.filesystems.FileObject
import org.openide.loaders.DataObject
import org.openide.text.NbDocument
+import java.util.UUID
import javax.swing.text.StyledDocument
/**
@@ -38,6 +39,13 @@ internal class KotlinRefactoringTransaction internal constructor(
private val openDocument: (FileObject) -> StyledDocument? = ::openEditorDocument,
private val writeDocument: (FileObject, StyledDocument, String) -> Unit = ::replaceDocumentText,
private val saveDocument: (FileObject) -> Unit = ::saveEditorDocument,
+ private val moveFileOnDisk: (FileObject, FileObject, String, String) -> FileObject = { file, parent, name, extension ->
+ file.lock().use { lock -> file.move(lock, parent, name, extension) }
+ },
+ private val renameFolderOnDisk: (FileObject, FileObject, String) -> FileObject = { folder, parent, name ->
+ folder.lock().use { lock -> folder.rename(lock, name, "") }
+ parent.getFileObject(name) ?: error("Folder rename did not produce ${parent.path}/$name")
+ },
) {
/** Identifies a file whose transaction operation could not be completed. */
@@ -120,6 +128,7 @@ internal class KotlinRefactoringTransaction internal constructor(
check(file.isValid && !file.isFolder) { "Cannot rename invalid or folder file ${file.path}" }
check(name.isNotBlank()) { "Target file name must not be blank." }
val parent = file.parent ?: error("Cannot rename root file ${file.path}")
+ if (file.name == name && file.ext == extension) return
val existing = parent.getFileObject(name, extension)
check(existing == null || existing == file) { "Target file already exists: ${parent.path}/$name.$extension" }
captureExisting(file)
@@ -136,6 +145,7 @@ internal class KotlinRefactoringTransaction internal constructor(
check(folder.isValid && folder.isFolder) { "Cannot rename invalid or non-folder path ${folder.path}" }
check(name.isNotBlank()) { "Target folder name must not be blank." }
val parent = folder.parent ?: error("Cannot rename root folder ${folder.path}")
+ if (folder.name == name) return
val existing = parent.getFileObject(name)
check(existing == null || existing == folder) { "Target folder already exists: ${parent.path}/$name" }
folderRenames += FolderRenameEntry(folder, parent, folder.name, name)
@@ -307,18 +317,14 @@ internal class KotlinRefactoringTransaction internal constructor(
moves.forEach { move ->
val file = move.movedFile ?: move.originalFile
check(file.isValid) { "Move source is invalid: ${move.originalFile.path}" }
- check(move.targetParent.getFileObject(move.targetName, move.targetExtension) == null) {
- "Move target already exists: ${move.targetParent.path}/${move.targetName}.${move.targetExtension}"
- }
try {
- file.lock().use { lock ->
- move.movedFile = file.move(lock, move.targetParent, move.targetName, move.targetExtension)
- }
+ val moved = moveFileCaseAware(file, move.targetParent, move.targetName, move.targetExtension)
// FileObject identity changes across a NetBeans move, but the editor document is
// intentionally retained. Re-key the snapshot so staged writes and later undo use
// the physical object now located at the destination.
val entry = entries.remove(file)
- if (entry != null) entries[move.movedFile!!] = entry.copy(file = move.movedFile!!)
+ if (entry != null) entries[moved] = entry.copy(file = moved)
+ move.movedFile = moved
movesApplied = true
} catch (error: Throwable) {
throw Failure(file, error)
@@ -326,6 +332,37 @@ internal class KotlinRefactoringTransaction internal constructor(
}
}
+ /** Moves [file] directly unless a same-parent case-only rename needs a temporary sibling. */
+ private fun moveFileCaseAware(file: FileObject, parent: FileObject, name: String, extension: String): FileObject {
+ val caseOnly = file.parent == parent &&
+ file.name.equals(name, ignoreCase = true) && file.ext.equals(extension, ignoreCase = true) &&
+ (file.name != name || file.ext != extension)
+ val existing = parent.getFileObject(name, extension)
+ check(existing == null || caseOnly && existing == file) {
+ "Move target already exists: ${parent.path}/$name.$extension"
+ }
+ if (!caseOnly) return moveFileOnDisk(file, parent, name, extension)
+
+ val originalParent = file.parent ?: error("Cannot move root file ${file.path}")
+ val originalName = file.name
+ val originalExtension = file.ext
+ val temporaryName = temporaryFileName(parent, extension)
+ val temporary = moveFileOnDisk(file, parent, temporaryName, extension)
+ try {
+ return moveFileOnDisk(temporary, parent, name, extension)
+ } catch (error: Throwable) {
+ runCatching { moveFileOnDisk(temporary, originalParent, originalName, originalExtension) }
+ .exceptionOrNull()
+ ?.let(error::addSuppressed)
+ throw error
+ }
+ }
+
+ /** Generates a transaction-private basename that cannot collide with a sibling file. */
+ private fun temporaryFileName(parent: FileObject, extension: String): String = generateSequence {
+ ".nbkotlin-${UUID.randomUUID()}"
+ }.first { parent.getFileObject(it, extension) == null }
+
/**
* Applies registered physical folder renames before K2 resolves final target PSI directories.
*
@@ -341,13 +378,8 @@ internal class KotlinRefactoringTransaction internal constructor(
if (rename.renamedFolder != null) return@forEach
val folder = rename.originalFolder
check(folder.isValid) { "Folder rename source is invalid: ${rename.originalFolder.path}" }
- check(rename.originalParent.getFileObject(rename.targetName) == null) {
- "Folder rename target already exists: ${rename.originalParent.path}/${rename.targetName}"
- }
try {
- folder.lock().use { lock -> folder.rename(lock, rename.targetName, "") }
- rename.renamedFolder = rename.originalParent.getFileObject(rename.targetName)
- ?: error("Folder rename did not produce ${rename.originalParent.path}/${rename.targetName}")
+ rename.renamedFolder = renameFolderCaseAware(folder, rename.originalParent, rename.targetName)
folderRenamesApplied = true
} catch (error: Throwable) {
throw Failure(folder, error)
@@ -355,6 +387,34 @@ internal class KotlinRefactoringTransaction internal constructor(
}
}
+ /** Renames [folder] through a temporary sibling when only its spelling case changes. */
+ private fun renameFolderCaseAware(folder: FileObject, parent: FileObject, name: String): FileObject {
+ val caseOnly = folder.parent == parent && folder.name.equals(name, ignoreCase = true) && folder.name != name
+ val existing = parent.getFileObject(name)
+ check(existing == null || caseOnly && existing == folder) {
+ "Folder rename target already exists: ${parent.path}/$name"
+ }
+ if (!caseOnly) return renameFolderOnDisk(folder, parent, name)
+
+ val originalParent = folder.parent ?: error("Cannot rename root folder ${folder.path}")
+ val originalName = folder.name
+ val temporaryName = temporaryFolderName(parent)
+ val temporary = renameFolderOnDisk(folder, parent, temporaryName)
+ try {
+ return renameFolderOnDisk(temporary, parent, name)
+ } catch (error: Throwable) {
+ runCatching { renameFolderOnDisk(temporary, originalParent, originalName) }
+ .exceptionOrNull()
+ ?.let(error::addSuppressed)
+ throw error
+ }
+ }
+
+ /** Generates a transaction-private folder name that cannot collide with an existing sibling. */
+ private fun temporaryFolderName(parent: FileObject): String = generateSequence {
+ ".nbkotlin-${UUID.randomUUID()}"
+ }.first { parent.getFileObject(it) == null }
+
/** Restores documents before paths, retaining the first cleanup failure. */
private fun restoreOriginalState() {
if (restoring) return
@@ -435,21 +495,17 @@ internal class KotlinRefactoringTransaction internal constructor(
moves.asReversed().forEach { move ->
val moved = move.movedFile ?: return@forEach
if (!moved.isValid) return@forEach
- check(move.originalParent.getFileObject(move.originalName, move.originalExtension) == null) {
- "Cannot restore moved file because original path is occupied: ${move.originalParent.path}/${move.originalName}.${move.originalExtension}"
- }
KotlinLogger.INSTANCE.logInfo(
"KotlinRefactoringTransaction.reverseMoves: moving ${moved.path} to ${move.originalParent.path}, " +
"document=${entries[moved]?.let { describeDocument(it.document) } ?: "missing"}"
)
- moved.lock().use { lock ->
- move.movedFile = moved.move(lock, move.originalParent, move.originalName, move.originalExtension)
- }
+ val restored = moveFileCaseAware(moved, move.originalParent, move.originalName, move.originalExtension)
val entry = entries.remove(moved)
- if (entry != null) entries[move.movedFile!!] = entry.copy(file = move.movedFile!!)
+ if (entry != null) entries[restored] = entry.copy(file = restored)
+ move.movedFile = restored
KotlinLogger.INSTANCE.logInfo(
- "KotlinRefactoringTransaction.reverseMoves: moved to ${move.movedFile!!.path}, " +
- "document=${entries[move.movedFile!!]?.let { describeDocument(it.document) } ?: "missing"}"
+ "KotlinRefactoringTransaction.reverseMoves: moved to ${restored.path}, " +
+ "document=${entries[restored]?.let { describeDocument(it.document) } ?: "missing"}"
)
}
movesApplied = false
@@ -461,12 +517,7 @@ internal class KotlinRefactoringTransaction internal constructor(
folderRenames.asReversed().forEach { rename ->
val folder = rename.renamedFolder ?: return@forEach
if (!folder.isValid) return@forEach
- check(rename.originalParent.getFileObject(rename.originalName) == null) {
- "Cannot restore renamed folder because original path is occupied: ${rename.originalParent.path}/${rename.originalName}"
- }
- folder.lock().use { lock -> folder.rename(lock, rename.originalName, "") }
- rename.renamedFolder = rename.originalParent.getFileObject(rename.originalName)
- ?: error("Folder restore did not produce ${rename.originalParent.path}/${rename.originalName}")
+ rename.renamedFolder = renameFolderCaseAware(folder, rename.originalParent, rename.originalName)
}
folderRenamesApplied = false
}
diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/resolve/KotlinAnalysisAPISession.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/resolve/KotlinAnalysisAPISession.kt
index cbfad9c2..8ad8a053 100644
--- a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/resolve/KotlinAnalysisAPISession.kt
+++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/resolve/KotlinAnalysisAPISession.kt
@@ -798,6 +798,28 @@ class KotlinAnalysisAPISession private constructor(
* @param path absolute path of the source file (must be a key in [fileMap])
* @param text current editor content
*/
+ /**
+ * Synchronizes every already-open NetBeans editor document represented by this session.
+ *
+ * This deliberately uses [org.openide.cookies.EditorCookie.getDocument] rather than opening
+ * documents: a refactoring must preserve unsaved user buffers without loading an entire source root.
+ *
+ * @return `Unit` after every available in-memory source buffer has been installed into the session.
+ */
+ fun synchronizeOpenDocuments() {
+ fileMap.keys.forEach { path ->
+ val file = org.openide.filesystems.FileUtil.toFileObject(
+ org.openide.filesystems.FileUtil.normalizeFile(java.io.File(path)),
+ ) ?: return@forEach
+ val document = runCatching {
+ org.openide.loaders.DataObject.find(file).lookup
+ .lookup(org.openide.cookies.EditorCookie::class.java)
+ ?.document
+ }.getOrNull() ?: return@forEach
+ updateFileContent(path, document.getText(0, document.length))
+ }
+ }
+
fun updateFileContent(path: String, text: String) {
val lvf = fileMap[path] ?: return
if (lvf.content.toString() == text) return
diff --git a/Nbm/src/main/resources/org/jetbrains/kotlin/layer.xml b/Nbm/src/main/resources/org/jetbrains/kotlin/layer.xml
index 8bfbc7e4..598007d0 100644
--- a/Nbm/src/main/resources/org/jetbrains/kotlin/layer.xml
+++ b/Nbm/src/main/resources/org/jetbrains/kotlin/layer.xml
@@ -385,6 +385,16 @@
+
+
+
+
+
+
+
+
+
+
@@ -416,10 +426,13 @@
-
-
-
+
+
+
+
+
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
index d5b6a39f..1fbdf7e9 100644
--- 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
@@ -72,6 +72,25 @@ class KaMoveNestedMemberComputerTest : KotlinTestCase("KaMoveNestedMemberCompute
}
}
+ /** Excludes every descendant of the declaration from target discovery and direct application. */
+ fun testMove_nestedClass_rejectsDescendantTarget() {
+ val fixture = createFixture(
+ sourceBody = "class Nested { class Descendant }",
+ targetBody = "",
+ ) ?: return
+ try {
+ val descendant = fixture.source.collectDescendantsOfType().single { it.name == "Descendant" }
+ val computer = fixture.computer("Nested")
+
+ assertEquals(listOf("Target"), computer.discoverTargets(listOf(fixture.sourceFile, fixture.targetFile)).map { it.presentation })
+ assertTrue(computer.checkConflicts(fixture.sourceFile, descendant.textOffset) is KaMoveNestedMemberComputer.ConflictCheck.Conflicts)
+ assertTrue(computer.move(fixture.sourceFile, descendant.textOffset) is KaMoveNestedMemberComputer.Apply.Conflicts)
+ assertTrue("Source must remain unchanged", fixture.sourceFile.text.contains("class Descendant"))
+ } 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
@@ -213,6 +232,22 @@ class KaMoveNestedMemberComputerTest : KotlinTestCase("KaMoveNestedMemberCompute
}
}
+ /** Rejects an inner class because moving it would change its enclosing-instance receiver. */
+ fun testCompute_innerClass_isNotApplicable() {
+ val fixture = createFixture(
+ sourceBody = "inner class Nested { fun outer() = this@Source.toString() }",
+ targetBody = "",
+ ) ?: return
+ try {
+ val computer = fixture.computer("Nested")
+
+ assertSame(KaMoveNestedMemberComputer.Outcome.NotApplicable, computer.compute())
+ assertTrue(computer.discoverTargets(listOf(fixture.sourceFile, fixture.targetFile)).isEmpty())
+ } 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
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
index d1589eb8..35323b6f 100644
--- 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
@@ -69,13 +69,28 @@ class KotlinChangePackageDirectoryActionTest : NbTestCase("KotlinChangePackageDi
contents.contains("KotlinRenameDirectoryAction.instance"),
)
assertTrue(
- "Kotlin file nodes must place the Refactor submenu inside their loader Actions folder",
+ "Generic folder nodes must place the Refactor submenu inside their loader Actions folder",
contents.contains(
- "\n" +
+ "\n" +
+ " \n" +
+ " \n" +
+ " \n" +
" ",
),
)
+ assertTrue(
+ "Kotlin file nodes must create the Refactor submenu in their loader Actions folder",
+ contents.contains(
+ "\n" +
+ " ",
+ ),
+ )
+ assertTrue(
+ "Kotlin file nodes must hide the ordinary Rename entry instead of showing it beside Refactor",
+ contents.contains(""),
+ )
assertTrue(
"Refactor must register the node-aware F7 action",
contents.contains(
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 53f601ac..aeffa58e 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
@@ -33,6 +33,8 @@ class KotlinRefactoringTransactionTest : NbTestCase("KotlinRefactoringTransactio
/** Creates a transaction test fixture backed by a NetBeans memory filesystem. */
private fun fixture(
failWriting: ((FileObject) -> Boolean)? = null,
+ moveFileOnDisk: ((FileObject, FileObject, String, String) -> FileObject)? = null,
+ renameFolderOnDisk: ((FileObject, FileObject, String) -> FileObject)? = null,
): Fixture {
val root = FileUtil.createMemoryFileSystem().root
val documents = linkedMapOf()
@@ -43,6 +45,16 @@ class KotlinRefactoringTransactionTest : NbTestCase("KotlinRefactoringTransactio
if (document.length > 0) document.remove(0, document.length)
document.insertString(0, text, null)
},
+ moveFileOnDisk = moveFileOnDisk ?: { file, parent, name, extension ->
+ file.lock().use { lock -> file.move(lock, parent, name, extension) }
+ },
+ renameFolderOnDisk = renameFolderOnDisk ?: { folder, _, name ->
+ folder.lock().use { lock ->
+ folder.rename(lock, name, "")
+ folder.parent.getFileObject(name)
+ ?: error("Folder rename did not produce ${folder.parent.path}/$name")
+ }
+ },
)
return Fixture(root, documents, transaction)
}
@@ -506,6 +518,64 @@ class KotlinRefactoringTransactionTest : NbTestCase("KotlinRefactoringTransactio
assertNull("undo must remove the target file name", fixture.root.getFileObject("After.kt"))
}
+ /** Verifies an exact file rename is a no-op through commit and undo. */
+ fun testRenameFile_sameNameIsNoOp() {
+ val fixture = fixture()
+ val source = fixture.existing("Before.kt", "fun before() = 1\n")
+
+ fixture.transaction.renameFile(source, "Before", "kt")
+ fixture.transaction.commit()
+ fixture.transaction.undo()
+
+ assertNotNull("original file must remain", fixture.root.getFileObject("Before.kt"))
+ assertEquals("fun before() = 1\n", fixture.text(source))
+ }
+
+ /** Verifies a case-only file rename uses the requested spelling and undo restores it. */
+ fun testCommitAndUndo_renamesFileByCaseOnly() {
+ val fixture = fixture()
+ val source = fixture.existing("Before.kt", "fun before() = 1\n")
+
+ fixture.transaction.renameFile(source, "before", "kt")
+ fixture.transaction.commit()
+
+ assertNull("commit must remove original spelling", fixture.root.getFileObject("Before.kt"))
+ assertNotNull("commit must use requested spelling", fixture.root.getFileObject("before.kt"))
+ fixture.transaction.undo()
+
+ assertNotNull("undo must restore original spelling", fixture.root.getFileObject("Before.kt"))
+ assertNull("undo must remove case-only spelling", fixture.root.getFileObject("before.kt"))
+ }
+
+ /** Verifies a failed final case-only file rename restores the original spelling and removes its temporary path. */
+ fun testCommitFailure_restoresFileAfterSecondCaseOnlyRenameLegFails() {
+ var moveCalls = 0
+ val fixture = fixture(
+ moveFileOnDisk = { file, parent, name, extension ->
+ moveCalls += 1
+ if (moveCalls == 2) error("Injected final case-only file rename failure")
+ file.lock().use { lock -> file.move(lock, parent, name, extension) }
+ },
+ )
+ val source = fixture.existing("Before.kt", "fun before() = 1\n")
+
+ fixture.transaction.renameFile(source, "before", "kt")
+ try {
+ fixture.transaction.commit()
+ fail("commit must fail when the final case-only file rename leg fails")
+ } catch (_: KotlinRefactoringTransaction.Failure) {
+ // Expected: the helper restores its temporary sibling before the transaction rolls back.
+ }
+
+ assertNotNull("failure must restore the original file spelling", fixture.root.getFileObject("Before.kt"))
+ assertNull("failure must not create the requested spelling", fixture.root.getFileObject("before.kt"))
+ assertFalse(
+ "failure must remove the transaction-private temporary file",
+ fixture.root.children.any { it.name.startsWith(".nbkotlin-") },
+ )
+ assertEquals("failure must preserve the original document", "fun before() = 1\n", fixture.textByPath(source))
+ }
+
/** Verifies file rename rejects an existing sibling before mutating the source. */
fun testRenameFile_rejectsExistingTarget() {
val fixture = fixture()
@@ -546,6 +616,69 @@ class KotlinRefactoringTransactionTest : NbTestCase("KotlinRefactoringTransactio
assertEquals("rollback must restore the original file path", "before/Source.kt", source.path)
}
+ /** Verifies an exact folder rename is a no-op through commit and undo. */
+ fun testRenameFolder_sameNameIsNoOp() {
+ val fixture = fixture()
+ val folder = fixture.root.createFolder("before")
+ fixture.existingIn(folder, "Source.kt", "fun source() = 1\n")
+
+ fixture.transaction.renameFolder(folder, "before")
+ fixture.transaction.commit()
+ fixture.transaction.undo()
+
+ assertNotNull("original folder must remain", fixture.root.getFileObject("before/Source.kt"))
+ }
+
+ /** Verifies a case-only folder rename preserves descendants and restores original spelling. */
+ fun testCommitAndUndo_renamesFolderByCaseOnly() {
+ val fixture = fixture()
+ val folder = fixture.root.createFolder("Before")
+ fixture.existingIn(folder, "Source.kt", "fun source() = 1\n")
+
+ fixture.transaction.renameFolder(folder, "before")
+ fixture.transaction.commit()
+
+ assertNull("commit must remove original spelling", fixture.root.getFileObject("Before"))
+ assertNotNull("commit must use requested spelling", fixture.root.getFileObject("before/Source.kt"))
+ fixture.transaction.undo()
+
+ assertNotNull("undo must restore original spelling", fixture.root.getFileObject("Before/Source.kt"))
+ assertNull("undo must remove case-only spelling", fixture.root.getFileObject("before"))
+ }
+
+ /** Verifies a failed final case-only folder rename restores the original spelling and descendants. */
+ fun testCommitFailure_restoresFolderAfterSecondCaseOnlyRenameLegFails() {
+ var renameCalls = 0
+ val fixture = fixture(
+ renameFolderOnDisk = { folder, _, name ->
+ renameCalls += 1
+ if (renameCalls == 2) error("Injected final case-only folder rename failure")
+ folder.lock().use { lock ->
+ folder.rename(lock, name, "")
+ folder.parent.getFileObject(name)
+ ?: error("Folder rename did not produce ${folder.parent.path}/$name")
+ }
+ },
+ )
+ val folder = fixture.root.createFolder("Before")
+ fixture.existingIn(folder, "Source.kt", "fun source() = 1\n")
+
+ fixture.transaction.renameFolder(folder, "before")
+ try {
+ fixture.transaction.commit()
+ fail("commit must fail when the final case-only folder rename leg fails")
+ } catch (_: KotlinRefactoringTransaction.Failure) {
+ // Expected: the helper restores its temporary sibling before the transaction rolls back.
+ }
+
+ assertNotNull("failure must restore the original folder spelling", fixture.root.getFileObject("Before/Source.kt"))
+ assertNull("failure must not create the requested spelling", fixture.root.getFileObject("before"))
+ assertFalse(
+ "failure must remove the transaction-private temporary folder",
+ fixture.root.children.any { it.name.startsWith(".nbkotlin-") },
+ )
+ }
+
/** Verifies a folder rename preserves descendants and restores their physical paths on undo. */
fun testCommitAndUndo_restoresRenamedFolderAndDescendantText() {
val fixture = fixture()
diff --git a/Nbm/src/test/kotlin/org/jetbrains/kotlin/refactorings/rename/KotlinActionsImplementationProviderTest.kt b/Nbm/src/test/kotlin/org/jetbrains/kotlin/refactorings/rename/KotlinActionsImplementationProviderTest.kt
new file mode 100644
index 00000000..2a54da15
--- /dev/null
+++ b/Nbm/src/test/kotlin/org/jetbrains/kotlin/refactorings/rename/KotlinActionsImplementationProviderTest.kt
@@ -0,0 +1,57 @@
+/*******************************************************************************
+ * Copyright 2026 nbplugins contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *******************************************************************************/
+package org.jetbrains.kotlin.refactorings.rename
+
+import org.netbeans.junit.NbTestCase
+import org.openide.filesystems.FileUtil
+import org.openide.nodes.AbstractNode
+import org.openide.nodes.Children
+import org.openide.nodes.Node
+import org.openide.util.Lookup
+import org.openide.util.lookup.Lookups
+
+/** Unit tests for [KotlinActionsImplementationProvider]'s node-context routing. */
+class KotlinActionsImplementationProviderTest : NbTestCase("KotlinActionsImplementationProviderTest") {
+
+ /** Verifies one Kotlin file node is the only physical-file rename selection accepted. */
+ fun testSelectedKotlinFile_acceptsExactlyOneKotlinFileNode() {
+ val root = FileUtil.createMemoryFileSystem().root
+ val kotlinFile = root.createData("Sample.kt")
+ val textFile = root.createData("notes.txt")
+
+ assertEquals(kotlinFile, KotlinActionsImplementationProvider.selectedKotlinFile(Lookups.fixed(node(kotlinFile))))
+ assertNull(KotlinActionsImplementationProvider.selectedKotlinFile(Lookups.fixed(node(textFile))))
+ assertNull(
+ KotlinActionsImplementationProvider.selectedKotlinFile(
+ Lookups.fixed(node(kotlinFile), node(root.createData("Other.kt"))),
+ ),
+ )
+ }
+
+ /** Verifies any node selection blocks fallback to an unrelated open editor context. */
+ fun testHasNodeSelection_detectsSingleAndMultipleExplorerNodes() {
+ val root = FileUtil.createMemoryFileSystem().root
+ val first = node(root.createData("First.kt"))
+ val second = node(root.createData("Second.kt"))
+
+ assertFalse(KotlinActionsImplementationProvider.hasNodeSelection(Lookup.EMPTY))
+ assertTrue(KotlinActionsImplementationProvider.hasNodeSelection(Lookups.fixed(first)))
+ assertTrue(KotlinActionsImplementationProvider.hasNodeSelection(Lookups.fixed(first, second)))
+ }
+
+ /** Creates a file-tree node exposing the supplied filesystem object. */
+ private fun node(value: Any): Node = AbstractNode(Children.LEAF, Lookups.singleton(value))
+}
diff --git a/manual-test-plan-refactor-menu.md b/manual-test-plan-refactor-menu.md
new file mode 100644
index 00000000..3da99ef3
--- /dev/null
+++ b/manual-test-plan-refactor-menu.md
@@ -0,0 +1,63 @@
+# План ручного тестирования Refactor
+
+## Цель
+
+Проверить, что после установки новой версии плагина контекстное подменю **Refactor** снова отображается для Kotlin-файлов и Kotlin-папок/пакетов, а команды остаются внутри этого подменю.
+
+## Подготовка
+
+1. Закрыть NetBeans.
+2. Установить NBM:
+ `/home/oleg/my-projects/nbplugins/NetbeansPluginKotlin/Nbm/target/nbm/netbeans-plugin-kotlin-nbm-0.11.288-SNAPSHOT.nbm`
+3. Запустить NetBeans и открыть Maven, Gradle или Ant/J2SE проект с Kotlin-файлами.
+4. Убедиться, что Kotlin source root содержит хотя бы один `.kt` файл.
+
+## 1. Kotlin-файл
+
+1. В окне **Projects** выбрать Kotlin-файл.
+2. Открыть контекстное меню.
+3. Проверить, что присутствует пункт **Refactor**.
+4. Открыть **Refactor**.
+5. Проверить наличие доступных команд, например:
+ - **Rename Kotlin File...**;
+ - **Change Kotlin Package...** — если команда доступна для выбранного файла/контекста;
+ - **Undo Last Refactoring** и **Redo Last Refactoring**.
+6. Проверить, что эти команды не появились отдельными пунктами верхнего уровня.
+7. Открыть Kotlin-файл в редакторе, установить курсор на имя декларации и выполнить **Refactor → Rename...** либо `Alt+Shift+R`. Должен открыться диалог переименования символа, а не **Rename Kotlin File...**.
+8. Не закрывая этот файл, вновь выбрать его в **Projects** или **Files** и выполнить **Refactor → Rename Kotlin File...**. Должен открыться диалог физического переименования файла, а не диалог переименования символа в позиции курсора.
+9. Выбрать одновременно два Kotlin-файла в **Projects** или **Files**. Проверить, что для них не запускается и не предлагается одиночное **Rename Kotlin File...**.
+
+## 2. Kotlin-папка или пакет
+
+1. Выбрать Kotlin-папку в дереве проекта либо пакет в **Kotlin Source Packages** / **Kotlin Test Packages**.
+2. Открыть контекстное меню.
+3. Проверить наличие пункта **Refactor**.
+4. Внутри **Refactor** проверить команды:
+ - **Change Kotlin Package...**;
+ - **Rename Kotlin Package Directory...**;
+ - **Move Kotlin Directory...**;
+ - **Undo Last Refactoring** и **Redo Last Refactoring**.
+5. Убедиться, что ни одна из этих команд не отображается вне **Refactor**.
+
+## 3. Открытие Change Kotlin Package
+
+1. Выбрать папку/пакет, содержащий Kotlin-файлы.
+2. Открыть **Refactor → Change Kotlin Package...**.
+3. Проверить, что открывается диалог рефакторинга.
+4. Изменить пакет и применить операцию.
+5. Проверить package-директивы Kotlin-файлов и поддерживаемые ссылки.
+6. Выполнить **Refactor → Undo Last Refactoring**.
+7. Проверить, что изменения отменены.
+
+## 4. Регрессия F7
+
+1. На Kotlin-файле открыть **Refactor → Rename Kotlin File...**.
+2. Выполнить переименование и проверить изменение имени файла.
+3. На Kotlin-папке открыть **Refactor → Rename Kotlin Package Directory...**.
+4. Выполнить переименование и проверить изменение каталога и сохранение содержимого.
+5. Проверить **Undo Last Refactoring** для обеих операций.
+6. Для case-only переименований (например, `Before.kt` → `before.kt` и `Before` → `before`) проверить успешное применение и отмену. После операции и отмены рядом с исходными файлами/каталогами не должно оставаться путей с префиксом `.nbkotlin-`.
+
+## Ожидаемый результат
+
+Для Kotlin-файла и Kotlin-папки/пакета контекстное меню содержит **Refactor**. Все Kotlin-команды рефакторинга доступны внутри него; отдельного верхнеуровневого пункта вместо подменю нет.
diff --git a/pom.xml b/pom.xml
index 159d8691..ddbfba91 100644
--- a/pom.xml
+++ b/pom.xml
@@ -4,7 +4,7 @@
4.0.0
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.284-SNAPSHOT
+ 0.11.288-SNAPSHOT
pom
Netbeans Plugin Kotlin