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 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