diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9bd339ad..8708da2e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,8 +1,10 @@
+- Fixed Kotlin package nodes under **Kotlin Source Packages** and **Kotlin Test Packages** retaining the standard **Refactor** submenu in the Projects window, so Rename opens **Rename Kotlin Package Directory...** for a non-empty Kotlin package; Java, resource, generated, empty, and non-Kotlin folders retain their existing handling, while package-tree headings are unchanged.
+- Added Kotlin filesystem rename commands: choose **Refactor → Rename Kotlin File...** to rename the active `.kt` basename, or right-click a Kotlin-containing folder and choose **Rename Kotlin Package Directory...** to rename the physical directory and all descendants while preserving Kotlin package directives and code references; use **Change Kotlin Package...** for package/reference changes. **Undo Last Refactoring** restores original paths and text. Java file-facade, automatic-renamer, Java/comment/text/index-only, Unicode/quoted-name, and full IDEA project-model cases remain unavailable.
- Added Move Nested Member refactoring: choose **Refactor → Move Nested Member...** on a nested Kotlin class/object or a companion function/property, select a compatible target from a structured package/class tree, and apply the atomic source/target change; nested classes/objects can move into a target class/object without a body, which gains one automatically, while companion functions/properties require an existing companion object. **Undo Last Refactoring** restores both files. Instance members, import aliases, and unvalidated external qualified-use rewrites remain unavailable.
- Added Inline Anonymous Function/Lambda refactoring: place the caret in an immediately invoked Kotlin lambda or anonymous function, press **Ctrl+Alt+N** or choose **Refactor → Inline...**, preview the one-call rewrite, and apply the IntelliJ IDEA K2-generated substitution; **Undo Last Refactoring** restores the original document.
- Added separate Java and Kotlin package trees in Project Explorer: conventional `src/main/java` and `src/test/java` remain under **Source Packages** and **Test Packages**, while `src/main/kotlin` and `src/test/kotlin` appear under **Kotlin Source Packages** and **Kotlin Test Packages** in Maven, Gradle, and Ant/J2SE projects.
- Added Inline Type Alias refactoring: place the caret on a Kotlin `typealias` declaration or one of its type uses, press **Ctrl+Alt+N** or choose **Refactor → Inline...**, then expand every supported Kotlin occurrence or only the selected use and optionally retain the alias declaration; generic arguments, imports, and parenthesisation use the IntelliJ IDEA K2 engine, while **Undo Last Refactoring** restores all changed Kotlin files.
-- Added Change Kotlin Package refactoring: use **Refactor → Change Kotlin Package...** for the active Kotlin file or a folder's recursive Kotlin descendants to update package directives in place; nested files retain their relative package hierarchy, supported Kotlin code references and direct imports update, and **Undo Last Refactoring** restores the original contents.
+- Improved Change Kotlin Package refactoring: **Refactor → Change Kotlin Package...** now opens from Kotlin package nodes as well as the active Kotlin file and reliably updates package directives in place for a file or its recursive Kotlin descendants; nested files retain their relative package hierarchy, supported Kotlin code references and direct imports update, physical paths remain unchanged, and **Undo Last Refactoring** restores the original contents.
- Added Move Kotlin File and Directory refactoring: move the active `.kt` file from **Refactor → Move Kotlin File...**, or right-click a folder and choose **Move Kotlin Directory...** to recursively move its Kotlin files while retaining the selected directory and nested structure below a selected source root/package; matching package directives and supported Kotlin code references are updated, and **Undo Last Refactoring** restores the original locations and contents.
- Improved Kotlin Inline Variable: local `var` declarations with one initializer and no subsequent writes can now be inlined from either a declaration or selected usage; assignments and increment/decrement writes are rejected before changing the source with the conflicting expression identified.
- Improved Kotlin Introduce Type Alias: selecting a complete generic type now creates a concrete alias, while selecting only its type constructor creates a parameterized alias that preserves nested and qualified type structures and each same-file usage's own type arguments; star projections and local type parameters that cannot form a valid top-level alias are rejected.
diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml
index fa88e892..417fb27e 100644
--- a/CoreImpl/pom.xml
+++ b/CoreImpl/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.260-SNAPSHOT
+ 0.11.280-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-core-impl
diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml
index 242bf08d..71b62852 100644
--- a/KotlinCompilerCliBase/pom.xml
+++ b/KotlinCompilerCliBase/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.260-SNAPSHOT
+ 0.11.280-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-compiler-cli-base
diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml
index 2fed4f62..97b74f31 100644
--- a/KotlinFixesImpl/pom.xml
+++ b/KotlinFixesImpl/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.260-SNAPSHOT
+ 0.11.280-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-fixes-impl
diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml
index 13a1b95d..3d015215 100644
--- a/KotlinFormatter/pom.xml
+++ b/KotlinFormatter/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.260-SNAPSHOT
+ 0.11.280-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-formatter
diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml
index 13b07995..d124dc5e 100644
--- a/KotlinHighlighting/pom.xml
+++ b/KotlinHighlighting/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.260-SNAPSHOT
+ 0.11.280-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-highlighting
diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml
index 696be996..01235d77 100644
--- a/KotlinIcons/pom.xml
+++ b/KotlinIcons/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.260-SNAPSHOT
+ 0.11.280-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-icons
diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml
index ace7696e..71366dea 100644
--- a/KotlinIntentionUtils/pom.xml
+++ b/KotlinIntentionUtils/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.260-SNAPSHOT
+ 0.11.280-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-intention-utils
diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml
index 2a1469fa..b5c41082 100644
--- a/KotlinRefactoring/pom.xml
+++ b/KotlinRefactoring/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.260-SNAPSHOT
+ 0.11.280-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-refactoring
@@ -1637,7 +1637,7 @@ if (extractableDescriptor.exists()) {
}
// 14. K2MoveRenameUsageInfo.kt: the IDEA Copy Declaration retargeting engine (E9.19).
-// Three adaptations for standalone use:
+// Four adaptations for standalone use:
// a. Drop the Java-reference `Light` path and the external-usage helpers
// (find/findExternalUsages/preProcessUsages). They pull in platform move handlers
// (MoveClassHandler/MoveMemberHandler/MoveMembersProcessor) and asJava light classes
@@ -1647,6 +1647,10 @@ if (extractableDescriptor.exists()) {
// (same as patch #1 for shortenUtils.kt).
// c. ProgressManager.getInstance().progressIndicator is null in the standalone container —
// fall back to an EmptyProgressIndicator so pushState()/text/fraction are no-ops.
+// d. Standalone FIR can reject a shortening range containing local declarations with
+// "ClassId should not be local" after usages have already been rebound. Retain the valid
+// qualified reference for that known failure, but always remove the internal root prefix.
+// Regression coverage: KaChangePackageTest.local-shortener fixture in the Nbm module.
def k2MoveUsage = new File(srcDir, 'org/jetbrains/kotlin/idea/k2/refactoring/move/processor/usages/K2MoveRenameUsageInfo.kt')
if (k2MoveUsage.exists()) {
def text = k2MoveUsage.text
@@ -1703,8 +1707,47 @@ if (k2MoveUsage.exists()) {
text = text.replace(' val mainReference = expr.mainReference\n',
' val mainReference = expr.references.filterIsInstance().firstOrNull() ?: return\n')
+ // e. K2's standalone reference shortener cannot establish a FIR designation for a range that
+ // contains local declarations (it throws "ClassId should not be local"). At this point
+ // Source.retarget has already rebound every affected reference in NO_SHORTENING mode, so the
+ // fully-qualified text is semantically correct. Keep that fallback narrow: only this known
+ // shortener exception is absorbed, while all other K2 failures still abort the refactoring.
+ // Root-prefix removal must run whether shortening succeeds or falls back, otherwise the
+ // internal IDE-resolution marker could be written to a user document.
+ text = text.replace(
+''' if (file is KtFile) {
+ shortenReferences(usageMap.keys.filterIsInstance())
+ }
+ // There are some circumstances where the reference shortener does not shorten an expression we added the root prefix to.
+ // This can happen if the reference shortener is unable to add an import because the declaration's name is already
+ // being used in the target package.
+ // In these cases, we need to manually remove the added root prefix.
+ for (pointer in qualifiedExpressionPointers) {
+ val qualifiedExpression = pointer.element ?: continue
+ qualifiedExpression.removeRootPrefix()
+ }''',
+''' try {
+ if (file is KtFile) {
+ try {
+ shortenReferences(usageMap.keys.filterIsInstance())
+ } catch (error: Throwable) {
+ if (error.message != "ClassId should not be local") throw error
+ }
+ }
+ } finally {
+ // There are some circumstances where the reference shortener does not shorten an expression we added the root prefix to.
+ // This can happen if the reference shortener is unable to add an import because the declaration's name is already
+ // being used in the target package.
+ // In these cases, we need to manually remove the added root prefix.
+ for (pointer in qualifiedExpressionPointers) {
+ val qualifiedExpression = pointer.element ?: continue
+ qualifiedExpression.removeRootPrefix()
+ }
+ }'''
+ )
+
k2MoveUsage.text = text
- log.info("Patched K2MoveRenameUsageInfo.kt: dropped Light/external paths, analyze swap, null-safe progress")
+ log.info("Patched K2MoveRenameUsageInfo.kt: dropped Light/external paths, standalone shortener fallback, analyze swap, null-safe progress")
}
// 15. K2ReferenceMutateService is `internal`; make it public so the Nbm session can register it
diff --git a/Nbm/pom.xml b/Nbm/pom.xml
index 3c9d2ed1..8bd32ac0 100644
--- a/Nbm/pom.xml
+++ b/Nbm/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.260-SNAPSHOT
+ 0.11.280-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-nbm
diff --git a/Nbm/src/main/java/io/github/nbplugins/kotlin/nbm/refactoring/KotlinFilesystemRenameUI.java b/Nbm/src/main/java/io/github/nbplugins/kotlin/nbm/refactoring/KotlinFilesystemRenameUI.java
new file mode 100644
index 00000000..e611d9d8
--- /dev/null
+++ b/Nbm/src/main/java/io/github/nbplugins/kotlin/nbm/refactoring/KotlinFilesystemRenameUI.java
@@ -0,0 +1,211 @@
+/*******************************************************************************
+ * Copyright 2026 nbplugins contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ *******************************************************************************/
+package io.github.nbplugins.kotlin.nbm.refactoring;
+
+import java.awt.BorderLayout;
+import java.awt.Component;
+import java.awt.GridBagConstraints;
+import java.awt.GridBagLayout;
+import java.awt.Insets;
+import javax.swing.BorderFactory;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import javax.swing.JTextField;
+import javax.swing.event.ChangeListener;
+import javax.swing.event.DocumentEvent;
+import javax.swing.event.DocumentListener;
+import org.netbeans.modules.refactoring.api.AbstractRefactoring;
+import org.netbeans.modules.refactoring.api.Problem;
+import org.netbeans.modules.refactoring.spi.ui.CustomRefactoringPanel;
+import org.netbeans.modules.refactoring.spi.ui.RefactoringUI;
+import org.openide.util.HelpCtx;
+
+/**
+ * Configures an explicit physical Kotlin file or package-directory rename.
+ *
+ * @param refactoring mutable typed rename carrier
+ * @param directory whether the selected source is a directory rather than one Kotlin file
+ * @param displayName selected source name shown in the panel
+ */
+public final class KotlinFilesystemRenameUI implements RefactoringUI {
+
+ private final AbstractRefactoring refactoring;
+ private final boolean directory;
+ private final String displayName;
+ private RenamePanel panel;
+
+ /**
+ * Creates a filesystem rename form.
+ *
+ * @param refactoring typed file or directory carrier
+ * @param directory whether this renames a package directory
+ * @param displayName source name initially displayed to the user
+ */
+ public KotlinFilesystemRenameUI(AbstractRefactoring refactoring, boolean directory, String displayName) {
+ this.refactoring = refactoring;
+ this.directory = directory;
+ this.displayName = displayName;
+ }
+
+ /** @return user-visible operation title. */
+ @Override
+ public String getName() {
+ return directory ? "Rename Kotlin Package Directory" : "Rename Kotlin File";
+ }
+
+ /** @return concise description of the physical source to rename. */
+ @Override
+ public String getDescription() {
+ return (directory ? "Rename Kotlin package directory '" : "Rename Kotlin file '") + displayName + "'";
+ }
+
+ /** @return false because the operation changes project files. */
+ @Override
+ public boolean isQuery() {
+ return false;
+ }
+
+ /**
+ * Creates the rename form on first use.
+ *
+ * @param parent listener notified after a field or option changes
+ * @return cached configuration panel
+ */
+ @Override
+ public CustomRefactoringPanel getPanel(ChangeListener parent) {
+ if (panel == null) {
+ String currentName = directory
+ ? ((KotlinRenameDirectoryRefactoring) refactoring).getNewName()
+ : ((KotlinRenameFileRefactoring) refactoring).getNewName();
+ panel = new RenamePanel(directory, displayName, currentName, parent);
+ }
+ return panel;
+ }
+
+ /**
+ * Copies and validates panel settings into the typed carrier.
+ *
+ * @return a fatal problem for an empty or invalid filesystem name, otherwise {@code null}
+ */
+ @Override
+ public Problem setParameters() {
+ if (panel == null) {
+ return null;
+ }
+ String newName = panel.getNewName().trim();
+ if (!newName.matches("[A-Za-z_][A-Za-z0-9_]*")) {
+ return new Problem(true, "The new Kotlin file or package-directory name must be a Kotlin identifier.");
+ }
+ if (directory) {
+ ((KotlinRenameDirectoryRefactoring) refactoring).setNewName(newName);
+ } else {
+ ((KotlinRenameFileRefactoring) refactoring).setNewName(newName);
+ }
+ return null;
+ }
+
+ /** @return current validation result. */
+ @Override
+ public Problem checkParameters() {
+ return setParameters();
+ }
+
+ /** @return true because physical rename requires a target name. */
+ @Override
+ public boolean hasParameters() {
+ return true;
+ }
+
+ /** @return the typed refactoring handled by this UI. */
+ @Override
+ public AbstractRefactoring getRefactoring() {
+ return refactoring;
+ }
+
+ /** @return no dedicated help page. */
+ @Override
+ public HelpCtx getHelpCtx() {
+ return null;
+ }
+
+ /** Swing parameters panel shared by file and directory rename. */
+ private static final class RenamePanel implements CustomRefactoringPanel {
+ private final JPanel component;
+ private final JTextField nameField;
+
+ /**
+ * Builds the form.
+ *
+ * @param directory whether the form represents a package directory
+ * @param displayName source name shown to the user
+ * @param currentName initial basename or folder name
+ * @param listener recipient of state-change notifications
+ */
+ RenamePanel(boolean directory, String displayName, String currentName, ChangeListener listener) {
+ nameField = new JTextField(currentName, 30);
+ JPanel form = new JPanel(new GridBagLayout());
+ form.setBorder(BorderFactory.createEmptyBorder(8, 12, 8, 12));
+ GridBagConstraints label = new GridBagConstraints();
+ label.anchor = GridBagConstraints.WEST;
+ label.insets = new Insets(2, 0, 2, 6);
+ GridBagConstraints field = new GridBagConstraints();
+ field.fill = GridBagConstraints.HORIZONTAL;
+ field.weightx = 1.0;
+ field.gridwidth = GridBagConstraints.REMAINDER;
+ field.insets = new Insets(2, 0, 2, 0);
+ form.add(new JLabel(directory ? "Kotlin package directory:" : "Kotlin file:"), label);
+ form.add(new JLabel(displayName), field);
+ JLabel nameLabel = new JLabel(directory ? "New directory name:" : "New file name:");
+ nameLabel.setLabelFor(nameField);
+ form.add(nameLabel, label);
+ form.add(nameField, field);
+ form.add(new JLabel(""), label);
+ form.add(
+ new JLabel(directory
+ ? "The directory and its descendants are renamed; Kotlin packages and references are unchanged."
+ : "The .kt basename changes; packages and Java file-facade references are unchanged."),
+ field
+ );
+ component = new JPanel(new BorderLayout());
+ component.add(form, BorderLayout.NORTH);
+ DocumentListener documentListener = new DocumentListener() {
+ @Override public void insertUpdate(DocumentEvent event) { listener.stateChanged(null); }
+ @Override public void removeUpdate(DocumentEvent event) { listener.stateChanged(null); }
+ @Override public void changedUpdate(DocumentEvent event) { listener.stateChanged(null); }
+ };
+ nameField.getDocument().addDocumentListener(documentListener);
+ }
+
+ /** @return requested basename or folder name. */
+ String getNewName() {
+ return nameField.getText();
+ }
+
+ /** Requests initial focus for the editable name. */
+ @Override
+ public void initialize() {
+ nameField.requestFocusInWindow();
+ nameField.selectAll();
+ }
+
+ /** @return component displayed by NetBeans. */
+ @Override
+ public Component getComponent() {
+ return component;
+ }
+ }
+}
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 d11194a7..6a4428cd 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
@@ -21,6 +21,11 @@
import com.intellij.psi.PsiElement;
import io.github.nbplugins.kotlin.nbm.navigation.KotlinWhereUsedRefactoringUI;
+import io.github.nbplugins.kotlin.nbm.refactoring.KotlinFilesystemRenameUI;
+import io.github.nbplugins.kotlin.nbm.refactoring.KotlinMoveSourceSelection;
+import io.github.nbplugins.kotlin.nbm.refactoring.KotlinProjectFolderRenameSelection;
+import io.github.nbplugins.kotlin.nbm.refactoring.KotlinRenameDirectoryRefactoring;
+import java.awt.EventQueue;
import javax.swing.JEditorPane;
import javax.swing.text.StyledDocument;
import org.jetbrains.kotlin.builder.KotlinPsiManager;
@@ -29,6 +34,7 @@
import org.netbeans.modules.refactoring.api.RenameRefactoring;
import org.netbeans.modules.refactoring.api.SafeDeleteRefactoring;
import org.netbeans.modules.refactoring.api.WhereUsedQuery;
+import org.netbeans.modules.refactoring.api.ui.ExplorerContext;
import org.netbeans.modules.refactoring.spi.ui.ActionsImplementationProvider;
import org.netbeans.modules.refactoring.spi.ui.UI;
import org.openide.cookies.EditorCookie;
@@ -45,25 +51,33 @@
* annotation processing is disabled by {@code -proc:none} and {@code @ServiceProvider} alone
* does not generate {@code META-INF/services} entries in this mixed Kotlin+Java build.
*/
-@org.openide.util.lookup.ServiceProvider(service = ActionsImplementationProvider.class, position = 400)
+@org.openide.util.lookup.ServiceProvider(service = ActionsImplementationProvider.class, position = 99)
public class KotlinActionsImplementationProvider extends ActionsImplementationProvider {
+ /**
+ * Returns whether the context is an eligible Kotlin package folder or an 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) {
- EditorCookie ec = lookup.lookup(EditorCookie.class);
- if (ec == null) {
- return false;
- }
- StyledDocument doc = ec.getDocument();
- if (doc == null) {
- return false;
- }
- FileObject fo = ProjectUtils.getFileObjectForDocument(doc);
- return fo != null && fo.hasExt("kt");
+ return KotlinProjectFolderRenameSelection.INSTANCE.resolve(lookup) != null || canRenameEditor(lookup);
}
+ /**
+ * Opens physical directory rename for Kotlin package folders or symbol rename for Kotlin editors.
+ *
+ * @param lookup lookup supplied by the Refactor action
+ */
@Override
public void doRename(Lookup lookup) {
+ KotlinMoveSourceSelection folderSelection = KotlinProjectFolderRenameSelection.INSTANCE.resolve(lookup);
+ if (folderSelection != null) {
+ openDirectoryRename(lookup, folderSelection);
+ 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,
@@ -80,6 +94,47 @@ public void doRename(Lookup lookup) {
TopComponent.getRegistry().getActivated());
}
+ /**
+ * Tests the pre-existing declaration-rename editor context without involving folder selection.
+ *
+ * @param lookup lookup supplied by NetBeans
+ * @return whether the lookup identifies an open Kotlin document
+ */
+ private boolean canRenameEditor(Lookup lookup) {
+ EditorCookie ec = lookup.lookup(EditorCookie.class);
+ if (ec == null) {
+ return false;
+ }
+ StyledDocument doc = ec.getDocument();
+ if (doc == null) {
+ return false;
+ }
+ FileObject fo = ProjectUtils.getFileObjectForDocument(doc);
+ return fo != null && fo.hasExt("kt");
+ }
+
+ /**
+ * Opens the existing physical directory-rename lifecycle on the event-dispatch thread.
+ *
+ * @param lookup lookup that may contain an inline Explorer rename request
+ * @param selection eligible Kotlin package directory and its Kotlin descendants
+ */
+ private void openDirectoryRename(Lookup lookup, KotlinMoveSourceSelection selection) {
+ FileObject directory = selection.getSelectedDirectory();
+ if (directory == null) {
+ return;
+ }
+ ExplorerContext explorerContext = lookup.lookup(ExplorerContext.class);
+ String requestedName = explorerContext == null ? null : explorerContext.getNewName();
+ String initialName = KotlinProjectFolderRenameSelection.INSTANCE.initialName(directory, requestedName);
+ EventQueue.invokeLater(() -> UI.openRefactoringUI(
+ new KotlinFilesystemRenameUI(
+ new KotlinRenameDirectoryRefactoring(directory, initialName, selection),
+ true,
+ directory.getName()),
+ TopComponent.getRegistry().getActivated()));
+ }
+
/**
* Returns {@code true} when the active editor contains a {@code .kt} file.
*
diff --git a/Nbm/src/main/java/org/jetbrains/kotlin/refactorings/rename/KotlinRefactoringsFactory.java b/Nbm/src/main/java/org/jetbrains/kotlin/refactorings/rename/KotlinRefactoringsFactory.java
index 3f471373..7fc3cc8c 100644
--- a/Nbm/src/main/java/org/jetbrains/kotlin/refactorings/rename/KotlinRefactoringsFactory.java
+++ b/Nbm/src/main/java/org/jetbrains/kotlin/refactorings/rename/KotlinRefactoringsFactory.java
@@ -46,6 +46,10 @@
import io.github.nbplugins.kotlin.nbm.refactoring.KotlinMoveNestedMemberRefactoring;
import io.github.nbplugins.kotlin.nbm.refactoring.KotlinMoveFilePlugin;
import io.github.nbplugins.kotlin.nbm.refactoring.KotlinMoveFileRefactoring;
+import io.github.nbplugins.kotlin.nbm.refactoring.KotlinRenameFilePlugin;
+import io.github.nbplugins.kotlin.nbm.refactoring.KotlinRenameFileRefactoring;
+import io.github.nbplugins.kotlin.nbm.refactoring.KotlinRenameDirectoryPlugin;
+import io.github.nbplugins.kotlin.nbm.refactoring.KotlinRenameDirectoryRefactoring;
import io.github.nbplugins.kotlin.nbm.refactoring.KotlinChangePackagePlugin;
import io.github.nbplugins.kotlin.nbm.refactoring.KotlinChangePackageRefactoring;
import io.github.nbplugins.kotlin.nbm.refactoring.KotlinChangeSignaturePlugin;
@@ -118,6 +122,12 @@ public RefactoringPlugin createInstance(AbstractRefactoring refactoring) {
if (refactoring instanceof KotlinMoveFileRefactoring) {
return new KotlinMoveFilePlugin((KotlinMoveFileRefactoring) refactoring);
}
+ if (refactoring instanceof KotlinRenameFileRefactoring) {
+ return new KotlinRenameFilePlugin((KotlinRenameFileRefactoring) refactoring);
+ }
+ if (refactoring instanceof KotlinRenameDirectoryRefactoring) {
+ return new KotlinRenameDirectoryPlugin((KotlinRenameDirectoryRefactoring) refactoring);
+ }
if (refactoring instanceof KotlinChangePackageRefactoring) {
return new KotlinChangePackagePlugin((KotlinChangePackageRefactoring) refactoring);
}
diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageAction.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageAction.kt
index fca038dc..4f842cc5 100644
--- a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageAction.kt
+++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageAction.kt
@@ -45,8 +45,7 @@ class KotlinChangePackageAction : BaseAction(ACTION_NAME, SAVE_POSITION or ABBRE
val document = target.document as? StyledDocument ?: return
runCatching {
val source = ProjectUtils.getFileObjectForDocument(document) ?: return@runCatching
- val project = ProjectUtils.getKotlinProjectForFileObject(source)
- ?: ProjectUtils.getValidProject()
+ val project = ProjectUtils.getKotlinProjectForFileObject(source) ?: ProjectUtils.getValidProject()
?: return@runCatching
val selection = KotlinChangePackageSourceSelection.from(source) ?: return@runCatching
val packageTarget = KotlinPackageTarget(project, source)
diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageDirectoryAction.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageDirectoryAction.kt
index fd67a885..055a7cc7 100644
--- a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageDirectoryAction.kt
+++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageDirectoryAction.kt
@@ -22,7 +22,6 @@ import org.jetbrains.kotlin.utils.ProjectUtils
import org.netbeans.modules.refactoring.spi.ui.UI
import org.openide.awt.ActionID
import org.openide.awt.ActionRegistration
-import org.openide.loaders.DataObject
import org.openide.nodes.Node
import org.openide.util.HelpCtx
import org.openide.util.actions.NodeAction
@@ -53,7 +52,7 @@ open class KotlinChangePackageDirectoryAction : NodeAction() {
runCatching {
val project = ProjectUtils.getKotlinProjectForFileObject(selection.representativeFile)
?: ProjectUtils.getValidProject()
- ?: return@runCatching
+ ?: error("Change Kotlin Package could not resolve the Kotlin project.")
val packageTarget = KotlinPackageTarget(project, selection.representativeFile)
openRefactoringUi(
KotlinChangePackageUI(
@@ -72,8 +71,9 @@ open class KotlinChangePackageDirectoryAction : NodeAction() {
*/
internal fun openRefactoringUi(ui: KotlinChangePackageUI) {
EventQueue.invokeLater {
- runCatching { UI.openRefactoringUI(ui, TopComponent.getRegistry().activated) }
- .onFailure { error -> KotlinLogger.INSTANCE.logException("KotlinChangePackageDirectoryAction UI opening failed", error) }
+ runCatching {
+ UI.openRefactoringUI(ui, TopComponent.getRegistry().activated)
+ }.onFailure { error -> KotlinLogger.INSTANCE.logException("KotlinChangePackageDirectoryAction UI opening failed", error) }
}
}
@@ -82,16 +82,16 @@ open class KotlinChangePackageDirectoryAction : NodeAction() {
?.let(KotlinChangePackageSourceSelection::from)
?.isDirectory == true
+ /** Allows selection and project preparation to run off the Event Dispatch Thread. */
+ override fun asynchronous(): Boolean = true
+
/** @return NetBeans default help context. */
override fun getHelpCtx(): HelpCtx = HelpCtx.DEFAULT_HELP
/** @return stable action name used by the manual layer registration. */
override fun getName(): String = "Change Kotlin Package"
- /** Resolves exactly one physical folder from NetBeans' selected node. */
- private fun selectedFolder(nodes: Array): org.openide.filesystems.FileObject? {
- if (nodes.size != 1) return null
- val dataObject = nodes.single().lookup.lookup(DataObject::class.java) ?: return null
- return dataObject.primaryFile.takeIf { it.isValid && it.isFolder }
- }
+ /** Resolves exactly one physical or package-tree folder node. */
+ private fun selectedFolder(nodes: Array) = KotlinFolderNodeSelection.resolve(nodes)
+
}
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 ed7b9cc1..99a65604 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
@@ -118,9 +118,16 @@ class KotlinChangePackageApplyElement(
val current = KotlinRefactoringTransaction()
pending = current
when (val outcome = KaChangePackageComputer(targets.keys.first()).apply(targets, refactoring.updateReferences)) {
- is KaChangePackageComputer.ApplyOutcome.Conflicts ->
+ is KaChangePackageComputer.ApplyOutcome.Conflicts -> {
throw IllegalStateException(outcome.messages.joinToString("\n"))
- is KaChangePackageComputer.ApplyOutcome.Error -> throw outcome.error
+ }
+ is KaChangePackageComputer.ApplyOutcome.Error -> {
+ KotlinLogger.INSTANCE.logException(
+ "KotlinChangePackageApplyElement.performChange: semantic analysis failed before transaction commit",
+ outcome.error,
+ )
+ throw outcome.error
+ }
is KaChangePackageComputer.ApplyOutcome.Success -> {
outcome.changedFiles.forEach { (path, text) ->
val file = sourceByPath[path] ?: FileUtil.toFileObject(FileUtil.normalizeFile(java.io.File(path)))
@@ -145,13 +152,9 @@ class KotlinChangePackageApplyElement(
override fun undoChange() {
runCatching {
val current = transaction
- if (current == null) {
- KotlinLogger.INSTANCE.logWarning("KotlinChangePackageApplyElement.undoChange: no retained transaction")
- } else {
- KotlinLogger.INSTANCE.logInfo("KotlinChangePackageApplyElement.undoChange: restoring retained transaction")
+ if (current != null) {
current.undo()
transaction = null
- KotlinLogger.INSTANCE.logInfo("KotlinChangePackageApplyElement.undoChange: transaction restored")
}
KotlinAnalysisAPISession.invalidate(project)
}.onFailure { error ->
diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinFolderNodeSelection.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinFolderNodeSelection.kt
new file mode 100644
index 00000000..b07cadcd
--- /dev/null
+++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinFolderNodeSelection.kt
@@ -0,0 +1,44 @@
+/*******************************************************************************
+ * Copyright 2026 nbplugins contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ *******************************************************************************/
+package io.github.nbplugins.kotlin.nbm.refactoring
+
+import org.openide.filesystems.FileObject
+import org.openide.loaders.DataFolder
+import org.openide.loaders.DataObject
+import org.openide.nodes.Node
+
+/** Resolves a single folder selected through a physical or package-tree NetBeans node. */
+object KotlinFolderNodeSelection {
+
+ /**
+ * Resolves exactly one valid physical folder from selected nodes.
+ *
+ * Package-tree nodes wrap a [DataFolder], while physical explorer nodes can expose a direct
+ * [FileObject]. The ordered fallbacks keep folder refactorings independent of either view.
+ *
+ * @param nodes NetBeans nodes selected by the user
+ * @return the selected valid folder, or `null` unless exactly one folder was selected
+ */
+ fun resolve(nodes: Array): FileObject? {
+ if (nodes.size != 1) return null
+ val lookup = nodes.single().lookup
+ val file = lookup.lookup(FileObject::class.java)
+ ?: lookup.lookup(DataFolder::class.java)?.primaryFile
+ ?: lookup.lookup(DataObject::class.java)?.primaryFile
+ return file?.takeIf { it.isValid && it.isFolder }
+ }
+}
diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveDirectoryAction.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveDirectoryAction.kt
index 1a2160fc..43e8f114 100644
--- a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveDirectoryAction.kt
+++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveDirectoryAction.kt
@@ -24,9 +24,9 @@ import org.jetbrains.kotlin.utils.ProjectUtils
import org.netbeans.modules.refactoring.spi.ui.UI
import org.openide.awt.ActionID
import org.openide.awt.ActionRegistration
-import org.openide.loaders.DataObject
import org.openide.nodes.Node
import org.openide.util.HelpCtx
+import org.openide.util.RequestProcessor
import org.openide.util.actions.NodeAction
import org.openide.windows.TopComponent
@@ -54,22 +54,24 @@ open class KotlinMoveDirectoryAction : NodeAction() {
*/
override fun performAction(nodes: Array) {
val folder = selectedFolder(nodes) ?: return
- val selection = KotlinMoveSourceSelection.from(folder) ?: return
- runCatching {
- val project = ProjectUtils.getKotlinProjectForFileObject(selection.representativeFile)
- ?: ProjectUtils.getValidProject()
- ?: return@runCatching
- val file = KotlinAnalysisAPISession.getSession(project).getKtFileForPath(selection.representativeFile.path)
- ?: return@runCatching
- val result = (KaMoveFileComputer(file).compute() as? KaMoveFileComputer.Outcome.Ready)?.result
- ?: return@runCatching
- val refactoring = KotlinMoveFileRefactoring(selection)
- val target = KotlinPackageTarget(project, selection.representativeFile)
- openRefactoringUi(
- KotlinMoveFileUI(result, refactoring, target, target.packageForFolder(folder.parent ?: folder)),
- )
- }.onFailure { error ->
- KotlinLogger.INSTANCE.logException("KotlinMoveDirectoryAction failed", error)
+ REQUEST_PROCESSOR.post {
+ val selection = KotlinMoveSourceSelection.from(folder) ?: return@post
+ runCatching {
+ val project = ProjectUtils.getKotlinProjectForFileObject(selection.representativeFile)
+ ?: ProjectUtils.getValidProject()
+ ?: return@runCatching
+ val file = KotlinAnalysisAPISession.getSession(project).getKtFileForPath(selection.representativeFile.path)
+ ?: return@runCatching
+ val result = (KaMoveFileComputer(file).compute() as? KaMoveFileComputer.Outcome.Ready)?.result
+ ?: return@runCatching
+ val refactoring = KotlinMoveFileRefactoring(selection)
+ val target = KotlinPackageTarget(project, selection.representativeFile)
+ openRefactoringUi(
+ KotlinMoveFileUI(result, refactoring, target, target.packageForFolder(folder.parent ?: folder)),
+ )
+ }.onFailure { error ->
+ KotlinLogger.INSTANCE.logException("KotlinMoveDirectoryAction failed", error)
+ }
}
}
@@ -95,16 +97,20 @@ open class KotlinMoveDirectoryAction : NodeAction() {
?.let(KotlinMoveSourceSelection::from)
?.isDirectory == true
+ /** Declares the legacy action dispatcher contract; UI work is explicitly marshalled to EDT. */
+ override fun asynchronous(): Boolean = false
+
/** @return no dedicated help page. */
override fun getHelpCtx(): HelpCtx = HelpCtx.DEFAULT_HELP
/** @return stable action name used by the manual layer registration. */
override fun getName(): String = "Move Kotlin Directory"
- /** Resolves exactly one folder node's physical file object. */
- private fun selectedFolder(nodes: Array): org.openide.filesystems.FileObject? {
- if (nodes.size != 1) return null
- val dataObject = nodes.single().lookup.lookup(DataObject::class.java) ?: return null
- return dataObject.primaryFile.takeIf { it.isValid && it.isFolder }
+ /** Resolves exactly one physical or package-tree folder node. */
+ private fun selectedFolder(nodes: Array) = KotlinFolderNodeSelection.resolve(nodes)
+
+ private companion object {
+ /** Worker used for source discovery and K2 preparation before the Swing UI is shown. */
+ val REQUEST_PROCESSOR = RequestProcessor(KotlinMoveDirectoryAction::class.java.name, 1)
}
}
diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinProjectFolderRenameSelection.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinProjectFolderRenameSelection.kt
new file mode 100644
index 00000000..fdee9c15
--- /dev/null
+++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinProjectFolderRenameSelection.kt
@@ -0,0 +1,90 @@
+/*******************************************************************************
+ * Copyright 2026 nbplugins contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ *******************************************************************************/
+package io.github.nbplugins.kotlin.nbm.refactoring
+
+import io.github.nbplugins.kotlin.nbm.projectsextensions.KotlinProjectSourceGroups
+import io.github.nbplugins.kotlin.nbm.projectsextensions.KotlinProjectSourceRoot
+import org.netbeans.api.project.FileOwnerQuery
+import org.openide.filesystems.FileObject
+import org.openide.filesystems.FileUtil
+import org.openide.nodes.Node
+import org.openide.util.Lookup
+
+/**
+ * Selects a non-empty Kotlin package folder from a Projects-popup lookup.
+ *
+ * Only conventional Kotlin source and test trees are eligible. This keeps the Kotlin refactoring
+ * provider from taking Java, resource, generated, or arbitrary filesystem folders that belong to
+ * another NetBeans rename implementation.
+ */
+object KotlinProjectFolderRenameSelection {
+
+ /**
+ * Resolves one eligible conventional-Kotlin folder from [lookup].
+ *
+ * @param lookup Projects popup lookup containing exactly one selected [Node]
+ * @return non-empty directory selection, or `null` when the lookup is not a supported Kotlin
+ * package folder
+ */
+ fun resolve(lookup: Lookup): KotlinMoveSourceSelection? {
+ val folder = KotlinFolderNodeSelection.resolve(lookup.lookupAll(Node::class.java).toTypedArray())
+ ?: return null
+ val project = FileOwnerQuery.getOwner(folder) ?: return null
+ return resolve(folder, KotlinProjectSourceGroups.findRoots(project.projectDirectory))
+ }
+
+ /**
+ * Resolves [lookup] against explicitly supplied roots.
+ *
+ * This overload isolates the folder/root predicate for unit testing without a loaded project
+ * owner. Production code must use [resolve] so root discovery remains project-scoped.
+ *
+ * @param lookup Projects popup lookup containing exactly one selected [Node]
+ * @param roots conventional Kotlin roots belonging to the selected folder's project
+ * @return non-empty directory selection, or `null` when not below [roots]
+ */
+ fun resolve(lookup: Lookup, roots: Collection): KotlinMoveSourceSelection? {
+ val folder = KotlinFolderNodeSelection.resolve(lookup.lookupAll(Node::class.java).toTypedArray())
+ ?: return null
+ return resolve(folder, roots)
+ }
+
+ /**
+ * Resolves [folder] against explicitly supplied roots.
+ *
+ * @param folder physical package folder selected by the user
+ * @param roots conventional Kotlin roots belonging to the selected folder's project
+ * @return non-empty directory selection, or `null` when [folder] is outside [roots]
+ */
+ fun resolve(folder: FileObject, roots: Collection): KotlinMoveSourceSelection? {
+ if (roots.none { root -> folder == root.folder || FileUtil.isParentOf(root.folder, folder) }) {
+ return null
+ }
+ return KotlinMoveSourceSelection.from(folder)?.takeIf(KotlinMoveSourceSelection::isDirectory)
+ }
+
+ /**
+ * Chooses the initial directory name from an optional inline Explorer rename request.
+ *
+ * @param folder selected physical Kotlin package directory
+ * @param requestedName name entered through inline Explorer rename, if any
+ * @return nonblank requested name, otherwise the current simple folder name
+ */
+ fun initialName(folder: FileObject, requestedName: String?): String =
+ requestedName?.takeIf(String::isNotBlank) ?: folder.name
+}
+
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 29c93ae1..25588240 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
@@ -61,14 +61,26 @@ internal class KotlinRefactoringTransaction internal constructor(
val originalName: String,
val originalExtension: String,
val targetParent: FileObject,
+ val targetName: String,
+ val targetExtension: String,
var movedFile: FileObject? = null,
)
+ private data class FolderRenameEntry(
+ val originalFolder: FileObject,
+ val originalParent: FileObject,
+ val originalName: String,
+ val targetName: String,
+ var renamedFolder: FileObject? = null,
+ )
+
private val entries = linkedMapOf()
private val moves = mutableListOf()
+ private val folderRenames = mutableListOf()
private val ownedFolders = mutableListOf()
private var committed = false
private var movesApplied = false
+ private var folderRenamesApplied = false
private var restoring = false
/**
@@ -86,7 +98,47 @@ internal class KotlinRefactoringTransaction internal constructor(
"Target file already exists: ${targetParent.path}/${file.nameExt}"
}
captureExisting(file)
- moves += MoveEntry(file, originalParent, file.name, file.ext, targetParent)
+ moves += MoveEntry(
+ originalFile = file,
+ originalParent = originalParent,
+ originalName = file.name,
+ originalExtension = file.ext,
+ targetParent = targetParent,
+ targetName = file.name,
+ targetExtension = file.ext,
+ )
+ }
+
+ /**
+ * Records an in-place physical file rename to apply after staged documents are saved.
+ *
+ * @param file existing source file to rename
+ * @param name target basename without extension
+ * @param extension target extension without the leading dot
+ */
+ fun renameFile(file: FileObject, name: String, extension: String = file.ext) {
+ 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}")
+ val existing = parent.getFileObject(name, extension)
+ check(existing == null || existing == file) { "Target file already exists: ${parent.path}/$name.$extension" }
+ captureExisting(file)
+ moves += MoveEntry(file, parent, file.name, file.ext, parent, name, extension)
+ }
+
+ /**
+ * Records an in-place folder rename to apply after every staged document is saved.
+ *
+ * @param folder selected physical folder whose descendants must remain intact
+ * @param name target simple folder name
+ */
+ fun renameFolder(folder: FileObject, name: String) {
+ 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}")
+ val existing = parent.getFileObject(name)
+ check(existing == null || existing == folder) { "Target folder already exists: ${parent.path}/$name" }
+ folderRenames += FolderRenameEntry(folder, parent, folder.name, name)
}
/**
@@ -228,10 +280,12 @@ internal class KotlinRefactoringTransaction internal constructor(
throw Failure(entry.file, error)
}
}
- // NetBeans retains a FileLock for every modified editor document until it is saved.
- // Persist every staged document before FileObject.move() attempts to acquire its lock.
- stagedEntries.forEach { entry -> saveDocument(entry.file) }
+ // NetBeans retains a FileLock for every opened editor document until it is saved.
+ // Capture-only entries participate in a physical move too, so save every transaction
+ // document rather than only staged edits before FileObject.move() acquires its lock.
+ entries.values.forEach { entry -> saveDocument(entry.file) }
applyMoves()
+ applyFolderRenames()
committed = true
} catch (error: Throwable) {
rollback()
@@ -248,17 +302,17 @@ internal class KotlinRefactoringTransaction internal constructor(
/** Reverts a successfully committed refactoring using its original snapshots and paths. */
fun undo() = restoreOriginalState()
- /** Applies all registered physical moves after every document has been validated. */
+ /** Applies all registered physical file moves and renames after every document has been saved. */
private fun applyMoves() {
moves.forEach { move ->
val file = move.movedFile ?: move.originalFile
check(file.isValid) { "Move source is invalid: ${move.originalFile.path}" }
- check(move.targetParent.getFileObject(move.originalName, move.originalExtension) == null) {
- "Move target already exists: ${move.targetParent.path}/${move.originalFile.nameExt}"
+ 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.originalName, move.originalExtension)
+ move.movedFile = file.move(lock, 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
@@ -272,6 +326,35 @@ internal class KotlinRefactoringTransaction internal constructor(
}
}
+ /**
+ * Applies registered physical folder renames before K2 resolves final target PSI directories.
+ *
+ * Directory renames require the final physical target to exist before the ported IDEA K2 move
+ * handler can perform conflict discovery. The caller must still invoke [commit] afterwards so
+ * staged semantic document changes and Undo Last Refactoring remain part of this transaction.
+ */
+ fun applyFolderRenamesNow() = applyFolderRenames()
+
+ /** Renames selected folders only after staged descendant documents have been saved. */
+ private fun applyFolderRenames() {
+ folderRenames.forEach { rename ->
+ 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}")
+ folderRenamesApplied = true
+ } catch (error: Throwable) {
+ throw Failure(folder, error)
+ }
+ }
+ }
+
/** Restores documents before paths, retaining the first cleanup failure. */
private fun restoreOriginalState() {
if (restoring) return
@@ -305,6 +388,10 @@ internal class KotlinRefactoringTransaction internal constructor(
KotlinLogger.INSTANCE.logException("KotlinRefactoringTransaction.reverseMoves failed", error)
if (failure == null) failure = error
}
+ reverseFolderRenames().onFailure { error ->
+ KotlinLogger.INSTANCE.logException("KotlinRefactoringTransaction.reverseFolderRenames failed", error)
+ if (failure == null) failure = error
+ }
// FileObject.move() may replace the on-disk content from its own filesystem state after the
// pre-move editor save. Reapply each original snapshot through the FileObject at its final
// restored path so Undo persists the package directive, not merely the retained document.
@@ -368,6 +455,22 @@ internal class KotlinRefactoringTransaction internal constructor(
movesApplied = false
}
+ /** Restores renamed folders before source snapshots are written through their original paths. */
+ private fun reverseFolderRenames(): Result = runCatching {
+ if (!folderRenamesApplied) return@runCatching
+ 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}")
+ }
+ folderRenamesApplied = false
+ }
+
/** Produces bounded, one-line diagnostic text without logging full source contents. */
private fun describeText(text: String): String =
"length=${text.length}, head=${text.take(160).replace("\n", "\\n")}"
diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameDirectoryAction.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameDirectoryAction.kt
new file mode 100644
index 00000000..5ab6af61
--- /dev/null
+++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameDirectoryAction.kt
@@ -0,0 +1,353 @@
+/*******************************************************************************
+ * Copyright 2026 nbplugins contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ *******************************************************************************/
+package io.github.nbplugins.kotlin.nbm.refactoring
+
+import java.awt.EventQueue
+import javax.swing.Action
+import javax.swing.JSeparator
+import org.jetbrains.kotlin.log.KotlinLogger
+import org.netbeans.modules.refactoring.spi.ui.ActionsImplementationProvider
+import org.netbeans.modules.refactoring.spi.ui.UI
+import org.netbeans.api.project.SourceGroup
+import org.openide.awt.ActionID
+import org.openide.awt.ActionRegistration
+import org.openide.util.actions.Presenter
+import org.openide.cookies.InstanceCookie
+import org.openide.filesystems.FileObject
+import org.openide.filesystems.FileUtil
+import org.openide.loaders.DataFolder
+import org.openide.loaders.DataObject
+import org.openide.nodes.Node
+import org.openide.util.ContextAwareAction
+import org.openide.util.HelpCtx
+import org.openide.util.Lookup
+import org.openide.util.RequestProcessor
+import org.openide.util.Utilities
+import org.openide.util.actions.NodeAction
+import org.openide.windows.TopComponent
+
+/**
+ * Folder-node action that renames one Kotlin package directory and its physical descendants.
+ *
+ * The refactoring transaction performs one physical folder rename while preserving every descendant.
+ * Use Change Kotlin Package for package directives and supported Kotlin-reference updates.
+ */
+@ActionID(category = "Refactoring", id = "io.github.nbplugins.kotlin.nbm.refactoring.KotlinRenameDirectoryAction")
+@ActionRegistration(displayName = "Rename Kotlin Package Directory...")
+open class KotlinRenameDirectoryAction : NodeAction() {
+
+ init {
+ putValue(NAME, "Rename Kotlin Package Directory...")
+ putValue(SHORT_DESCRIPTION, "Rename Kotlin Package Directory")
+ }
+
+ /**
+ * Opens the package-directory rename UI for one folder containing Kotlin descendants.
+ *
+ * @param nodes selected NetBeans nodes
+ */
+ override fun performAction(nodes: Array) {
+ KotlinLogger.INSTANCE.logInfo("KotlinRenameDirectoryAction: invoked with ${nodes.size} selected node(s)")
+ logSelectedNodeActions(nodes)
+ logRenameEligibility(nodes)
+ logRefactoringMenuRegistration(nodes)
+ val selection = selectedSelection(nodes)
+ if (selection == null) {
+ KotlinLogger.INSTANCE.logInfo("KotlinRenameDirectoryAction: aborted because no Kotlin package directory was selected")
+ return
+ }
+ val folder = selection.selectedDirectory
+ if (folder == null) {
+ KotlinLogger.INSTANCE.logInfo("KotlinRenameDirectoryAction: aborted because the selected source is not a directory")
+ return
+ }
+ KotlinLogger.INSTANCE.logInfo(
+ "KotlinRenameDirectoryAction: context resolved for ${folder.path} with ${selection.sourceFiles.size} Kotlin file(s)",
+ )
+ KotlinLogger.INSTANCE.logInfo("KotlinRenameDirectoryAction: scheduling UI preparation")
+ REQUEST_PROCESSOR.post {
+ KotlinLogger.INSTANCE.logInfo("KotlinRenameDirectoryAction: scheduling refactoring UI on EDT")
+ EventQueue.invokeLater {
+ runCatching {
+ KotlinLogger.INSTANCE.logInfo("KotlinRenameDirectoryAction: opening refactoring UI on EDT")
+ UI.openRefactoringUI(
+ KotlinFilesystemRenameUI(
+ KotlinRenameDirectoryRefactoring(folder, folder.name, selection),
+ true,
+ folder.name,
+ ),
+ TopComponent.getRegistry().activated,
+ )
+ }.onFailure { error -> KotlinLogger.INSTANCE.logException("KotlinRenameDirectoryAction failed", error) }
+ }
+ }
+ }
+
+ /** Enables the action for exactly one physical folder containing at least one Kotlin file. */
+ override fun enable(nodes: Array): Boolean = selectedSelection(nodes) != null
+
+ /** Declares the legacy action dispatcher contract; UI work is explicitly marshalled to EDT. */
+ override fun asynchronous(): Boolean = false
+
+ /** @return no dedicated help page. */
+ override fun getHelpCtx(): HelpCtx = HelpCtx.DEFAULT_HELP
+
+ /** @return stable name used by the manual layer registration. */
+ override fun getName(): String = "Rename Kotlin Package Directory"
+
+ /**
+ * Records the selected Project Explorer node's lookup and effective popup actions.
+ *
+ * The record is intentionally emitted only while a known working directory action runs so it
+ * can compare Kotlin source/package nodes with Java source/package nodes without startup noise.
+ *
+ * @param nodes nodes selected when the directory action was invoked
+ */
+ private fun logSelectedNodeActions(nodes: Array) {
+ nodes.forEachIndexed { index, node ->
+ val lookup = node.lookup
+ val parent = node.parentNode
+ val dataObject = lookup.lookup(DataObject::class.java)
+ val dataFolder = lookup.lookup(DataFolder::class.java)
+ val fileObject = lookup.lookup(FileObject::class.java)
+ val sourceGroup = lookup.lookup(SourceGroup::class.java)
+ KotlinLogger.INSTANCE.logInfo(
+ "KotlinRenameDirectoryAction: selected node diagnostic index=$index " +
+ "class=${node.javaClass.name} name=${node.name} displayName=${node.displayName} " +
+ "parentClass=${parent?.javaClass?.name} parentName=${parent?.name} " +
+ "parentDisplayName=${parent?.displayName} " +
+ "dataObject=${dataObject?.javaClass?.name} " +
+ "dataFolder=${dataFolder?.primaryFile?.path} " +
+ "fileObject=${fileObject?.path} " +
+ "sourceGroup=${sourceGroup?.javaClass?.name} " +
+ "sourceRoot=${sourceGroup?.rootFolder?.path}",
+ )
+ node.getActions(false).forEachIndexed { actionIndex, action ->
+ if (action == null) {
+ KotlinLogger.INSTANCE.logInfo(
+ "KotlinRenameDirectoryAction: selected node action index=$index " +
+ "actionIndex=$actionIndex kind=separator",
+ )
+ return@forEachIndexed
+ }
+ KotlinLogger.INSTANCE.logInfo(
+ "KotlinRenameDirectoryAction: selected node action index=$index actionIndex=$actionIndex " +
+ "class=${action.javaClass.name} name=${action.getValue(Action.NAME)} " +
+ "enabled=${action.isEnabled} " +
+ "popupPresenter=${action is Presenter.Popup} " +
+ "contextAware=${action is ContextAwareAction} " +
+ "identity=${System.identityHashCode(action)}",
+ )
+ if (action.javaClass.name == REFACTOR_SUBMENU_ACTION_CLASS) {
+ logContextAwarePopupAction(index, actionIndex, action, lookup)
+ }
+ }
+ }
+ }
+
+ /**
+ * Records aggregate and per-provider rename eligibility for the two relevant action contexts.
+ *
+ * The Projects popup obtains a global context containing its selected node, while the earlier
+ * node diagnostic resolves actions from the node lookup directly. Both are recorded because a
+ * missing node in either context would make the provider result incomparable to the popup.
+ *
+ * @param nodes nodes selected when the directory action was invoked
+ */
+ private fun logRenameEligibility(nodes: Array) {
+ logRenameEligibility("active-projects", Utilities.actionsGlobalContext())
+ nodes.forEachIndexed { index, node ->
+ logRenameEligibility("selected-node-$index", node.lookup)
+ }
+ }
+
+ /**
+ * Records aggregate and individual provider results for one refactoring action context.
+ *
+ * Each provider is isolated because diagnostic inspection must not interfere with the already
+ * validated directory action if an unrelated provider fails while examining its context.
+ *
+ * @param contextLabel stable label identifying the action context being inspected
+ * @param context lookup supplied to NetBeans refactoring actions
+ */
+ private fun logRenameEligibility(contextLabel: String, context: Lookup) {
+ val contextNodes = context.lookupAll(Node::class.java)
+ val nodeDescription = contextNodes.joinToString { node ->
+ "${node.javaClass.name}(name=${node.name},displayName=${node.displayName})"
+ }
+ KotlinLogger.INSTANCE.logInfo(
+ "KotlinRenameDirectoryAction: rename eligibility context=$contextLabel " +
+ "nodeCount=${contextNodes.size} nodes=[$nodeDescription]",
+ )
+ runCatching { Lookup.getDefault().lookupAll(ActionsImplementationProvider::class.java) }
+ .onSuccess { providers ->
+ KotlinLogger.INSTANCE.logInfo(
+ "KotlinRenameDirectoryAction: rename eligibility providers context=$contextLabel " +
+ "count=${providers.size}",
+ )
+ providers.forEachIndexed { providerIndex, provider ->
+ runCatching { provider.canRename(context) }
+ .onSuccess { canRename ->
+ KotlinLogger.INSTANCE.logInfo(
+ "KotlinRenameDirectoryAction: rename eligibility provider " +
+ "context=$contextLabel providerIndex=$providerIndex " +
+ "class=${provider.javaClass.name} canRename=$canRename",
+ )
+ }
+ .onFailure { error ->
+ KotlinLogger.INSTANCE.logException(
+ "KotlinRenameDirectoryAction: rename eligibility provider failed " +
+ "context=$contextLabel providerIndex=$providerIndex " +
+ "class=${provider.javaClass.name}",
+ error,
+ )
+ }
+ }
+ }
+ .onFailure { error ->
+ KotlinLogger.INSTANCE.logException(
+ "KotlinRenameDirectoryAction: rename eligibility provider lookup failed " +
+ "context=$contextLabel",
+ error,
+ )
+ }
+ }
+
+ /**
+ * Records the action that NetBeans uses after it applies the selected node lookup.
+ *
+ * Projects invokes the same context-aware conversion before it asks an action for a popup
+ * presenter. In particular, the standard Refactor action can be replaced by Rename for one
+ * package node but remain a submenu for another.
+ *
+ * @param nodeIndex index of the selected node being diagnosed
+ * @param actionIndex index of the original action in the node action array
+ * @param action original action supplied by the node
+ * @param context selected node lookup used to resolve the action
+ */
+ private fun logContextAwarePopupAction(
+ nodeIndex: Int,
+ actionIndex: Int,
+ action: Action,
+ context: org.openide.util.Lookup,
+ ) {
+ if (action !is ContextAwareAction) {
+ return
+ }
+ runCatching { action.createContextAwareInstance(context) }
+ .onSuccess { resolvedAction ->
+ KotlinLogger.INSTANCE.logInfo(
+ "KotlinRenameDirectoryAction: selected node contextual action index=$nodeIndex " +
+ "actionIndex=$actionIndex originalClass=${action.javaClass.name} " +
+ "originalName=${action.getValue(Action.NAME)} " +
+ "resolvedClass=${resolvedAction.javaClass.name} " +
+ "resolvedName=${resolvedAction.getValue(Action.NAME)} " +
+ "resolvedEnabled=${resolvedAction.isEnabled} " +
+ "resolvedPopupPresenter=${resolvedAction is Presenter.Popup} " +
+ "sameInstance=${resolvedAction === action} " +
+ "resolvedIdentity=${System.identityHashCode(resolvedAction)}",
+ )
+ }
+ .onFailure { error ->
+ KotlinLogger.INSTANCE.logException(
+ "KotlinRenameDirectoryAction: selected node contextual action failed " +
+ "index=$nodeIndex actionIndex=$actionIndex class=${action.javaClass.name}",
+ error,
+ )
+ }
+ }
+
+ /**
+ * Logs the effective popup registry at the moment a known working Projects action runs.
+ *
+ * NetBeans' RefactoringSubMenuAction uses this same DataFolder/InstanceCookie walk to create
+ * the Refactor popup, so this reveals the actual action behind an ambiguous display label.
+ *
+ * @param nodes nodes selected when the Refactor popup action was invoked
+ */
+ private fun logRefactoringMenuRegistration(nodes: Array) {
+ val selectedNodes = nodes.joinToString { node -> "${node.javaClass.name}(name=${node.name})" }
+ KotlinLogger.INSTANCE.logInfo(
+ "KotlinRenameDirectoryAction: Refactor menu diagnostic selectedNodes=[$selectedNodes]",
+ )
+ val menuFolder = FileUtil.getConfigFile(REFACTOR_MENU_PATH)
+ if (menuFolder == null) {
+ KotlinLogger.INSTANCE.logWarning(
+ "KotlinRenameDirectoryAction: Refactor menu diagnostic missing $REFACTOR_MENU_PATH",
+ )
+ return
+ }
+ val folder = runCatching { DataFolder.findFolder(menuFolder) }.getOrElse { error ->
+ KotlinLogger.INSTANCE.logException(
+ "KotlinRenameDirectoryAction: Refactor menu diagnostic could not open ${menuFolder.path}",
+ error,
+ )
+ return
+ }
+ KotlinLogger.INSTANCE.logInfo(
+ "KotlinRenameDirectoryAction: Refactor menu diagnostic inspecting ${menuFolder.path} " +
+ "with ${folder.children.size} registration(s)",
+ )
+ folder.children.forEach { registration ->
+ val configFile = registration.primaryFile
+ val origin = configFile.getAttribute("originalFile")
+ val position = configFile.getAttribute("position")
+ val prefix = "KotlinRenameDirectoryAction: Refactor menu entry path=${configFile.path} " +
+ "originalFile=$origin position=$position"
+ val instanceCookie = registration.lookup.lookup(InstanceCookie::class.java)
+ if (instanceCookie == null) {
+ KotlinLogger.INSTANCE.logInfo("$prefix kind=no-instance-cookie")
+ return@forEach
+ }
+ runCatching { instanceCookie.instanceCreate() }
+ .onSuccess { instance ->
+ when (instance) {
+ is Action -> KotlinLogger.INSTANCE.logInfo(
+ "$prefix kind=action class=${instance.javaClass.name} " +
+ "name=${instance.getValue(Action.NAME)} " +
+ "shortDescription=${instance.getValue(Action.SHORT_DESCRIPTION)} " +
+ "enabled=${instance.isEnabled} identity=${System.identityHashCode(instance)}",
+ )
+ is JSeparator -> KotlinLogger.INSTANCE.logInfo("$prefix kind=separator")
+ else -> KotlinLogger.INSTANCE.logInfo(
+ "$prefix kind=non-action class=${instance?.javaClass?.name ?: "null"}",
+ )
+ }
+ }
+ .onFailure { error ->
+ KotlinLogger.INSTANCE.logException("$prefix kind=instance-creation-failed", error)
+ }
+ }
+ }
+
+ /** Resolves the non-empty Kotlin selection validated before the refactoring UI is opened. */
+ private fun selectedSelection(nodes: Array): KotlinMoveSourceSelection? = KotlinFolderNodeSelection.resolve(nodes)
+ ?.let(KotlinMoveSourceSelection::from)
+ ?.takeIf(KotlinMoveSourceSelection::isDirectory)
+
+ private companion object {
+ /** Effective configuration folder read lazily by NetBeans' Refactor popup implementation. */
+ const val REFACTOR_MENU_PATH = "Menu/Refactoring"
+
+ /** NetBeans action whose context-aware conversion decides whether Refactor remains a submenu. */
+ const val REFACTOR_SUBMENU_ACTION_CLASS =
+ "org.netbeans.modules.refactoring.spi.impl.RSMDataObjectAction"
+
+ /** Worker used for filesystem discovery before the Swing refactoring UI is shown. */
+ val REQUEST_PROCESSOR = RequestProcessor(KotlinRenameDirectoryAction::class.java.name, 1)
+ }
+}
diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameDirectoryRefactoring.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameDirectoryRefactoring.kt
new file mode 100644
index 00000000..fb3b9323
--- /dev/null
+++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameDirectoryRefactoring.kt
@@ -0,0 +1,37 @@
+/*******************************************************************************
+ * Copyright 2026 nbplugins contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ *******************************************************************************/
+package io.github.nbplugins.kotlin.nbm.refactoring
+
+import org.netbeans.modules.refactoring.api.AbstractRefactoring
+import org.openide.filesystems.FileObject
+import org.openide.util.lookup.Lookups
+
+/**
+ * Carries one explicit Kotlin package-directory rename through the NetBeans refactoring lifecycle.
+ *
+ * The directory and every descendant are renamed physically; Kotlin package directives and code
+ * references are unchanged. Use Change Kotlin Package for semantic package/reference updates.
+ *
+ * @param directory selected physical directory containing Kotlin descendants
+ * @param newName target simple directory name
+ * @param selection source files verified while the package-tree action was enabled, when available
+ */
+class KotlinRenameDirectoryRefactoring(
+ val directory: FileObject,
+ var newName: String,
+ internal val selection: KotlinMoveSourceSelection? = null,
+) : AbstractRefactoring(Lookups.fixed(directory))
diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameFileAction.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameFileAction.kt
new file mode 100644
index 00000000..f9ec8970
--- /dev/null
+++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameFileAction.kt
@@ -0,0 +1,64 @@
+/*******************************************************************************
+ * Copyright 2026 nbplugins contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ *******************************************************************************/
+package io.github.nbplugins.kotlin.nbm.refactoring
+
+import org.jetbrains.kotlin.log.KotlinLogger
+import org.jetbrains.kotlin.utils.ProjectUtils
+import org.netbeans.editor.BaseAction
+import org.netbeans.modules.refactoring.spi.ui.UI
+import org.openide.windows.TopComponent
+import java.awt.event.ActionEvent
+import javax.swing.text.JTextComponent
+import javax.swing.text.StyledDocument
+
+/**
+ * Editor action that renames only the physical basename of the active Kotlin source file.
+ *
+ * Package directives and references intentionally remain unchanged because Kotlin declarations do
+ * not derive their fully qualified names from a source-file basename.
+ */
+class KotlinRenameFileAction : BaseAction(ACTION_NAME, SAVE_POSITION or ABBREV_RESET) {
+
+ init {
+ putValue(NAME, "Rename Kotlin File...")
+ putValue(SHORT_DESCRIPTION, "Rename Kotlin File")
+ putValue(POPUP_MENU_TEXT, "Rename Kotlin File...")
+ }
+
+ /**
+ * Opens the explicit physical file-rename UI.
+ *
+ * @param evt NetBeans action event
+ * @param target active Kotlin editor component
+ */
+ override fun actionPerformed(evt: ActionEvent, target: JTextComponent) {
+ val document = target.document as? StyledDocument ?: return
+ runCatching {
+ val file = ProjectUtils.getFileObjectForDocument(document)?.takeIf { it.isValid && it.ext == "kt" }
+ ?: return@runCatching
+ UI.openRefactoringUI(
+ KotlinFilesystemRenameUI(KotlinRenameFileRefactoring(file, file.name), false, file.nameExt),
+ TopComponent.getRegistry().activated,
+ )
+ }.onFailure { error -> KotlinLogger.INSTANCE.logException("KotlinRenameFileAction failed", error) }
+ }
+
+ companion object {
+ /** Action name used by the manual layer.xml registration. */
+ const val ACTION_NAME = "kotlin-rename-file"
+ }
+}
diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameFileNodeAction.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameFileNodeAction.kt
new file mode 100644
index 00000000..1f32ed71
--- /dev/null
+++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameFileNodeAction.kt
@@ -0,0 +1,85 @@
+/*******************************************************************************
+ * Copyright 2026 nbplugins contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ *******************************************************************************/
+package io.github.nbplugins.kotlin.nbm.refactoring
+
+import java.awt.EventQueue
+import org.jetbrains.kotlin.log.KotlinLogger
+import org.netbeans.modules.refactoring.spi.ui.UI
+import org.openide.awt.ActionID
+import org.openide.awt.ActionRegistration
+import org.openide.filesystems.FileObject
+import org.openide.loaders.DataObject
+import org.openide.nodes.Node
+import org.openide.util.HelpCtx
+import org.openide.util.RequestProcessor
+import org.openide.util.actions.NodeAction
+import org.openide.windows.TopComponent
+
+/** Opens the explicit filesystem-rename UI for a Kotlin file selected in a project tree. */
+@ActionID(category = "Refactoring", id = "io.github.nbplugins.kotlin.nbm.refactoring.KotlinRenameFileNodeAction")
+@ActionRegistration(displayName = "Rename Kotlin File...")
+open class KotlinRenameFileNodeAction : NodeAction() {
+
+ init {
+ putValue(NAME, "Rename Kotlin File...")
+ putValue(SHORT_DESCRIPTION, "Rename Kotlin File")
+ }
+
+ /**
+ * Opens the file-rename UI for the selected Kotlin file.
+ *
+ * @param nodes selected NetBeans nodes
+ */
+ override fun performAction(nodes: Array) {
+ val file = selectedFile(nodes) ?: return
+ REQUEST_PROCESSOR.post {
+ EventQueue.invokeLater {
+ runCatching {
+ UI.openRefactoringUI(
+ KotlinFilesystemRenameUI(KotlinRenameFileRefactoring(file, file.name), false, file.nameExt),
+ TopComponent.getRegistry().activated,
+ )
+ }.onFailure { error -> KotlinLogger.INSTANCE.logException("KotlinRenameFileNodeAction failed", error) }
+ }
+ }
+ }
+
+ /** @return true only for one valid physical Kotlin source file. */
+ override fun enable(nodes: Array): Boolean = selectedFile(nodes) != null
+
+ /** Declares the legacy action dispatcher contract; UI work is explicitly marshalled to EDT. */
+ override fun asynchronous(): Boolean = false
+
+ /** @return no dedicated help page. */
+ override fun getHelpCtx(): HelpCtx = HelpCtx.DEFAULT_HELP
+
+ /** @return stable name used by the manual layer registration. */
+ override fun getName(): String = "Rename Kotlin File"
+
+ /** Resolves one selected physical Kotlin file from NetBeans file-tree node lookups. */
+ private fun selectedFile(nodes: Array): FileObject? {
+ if (nodes.size != 1) return null
+ val lookup = nodes.single().lookup
+ val file = lookup.lookup(FileObject::class.java) ?: lookup.lookup(DataObject::class.java)?.primaryFile
+ return file?.takeIf { it.isValid && !it.isFolder && it.ext == "kt" }
+ }
+
+ private companion object {
+ /** Worker used to hand off the action without making Swing UI from the dispatcher thread. */
+ val REQUEST_PROCESSOR = RequestProcessor(KotlinRenameFileNodeAction::class.java.name, 1)
+ }
+}
diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameFileRefactoring.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameFileRefactoring.kt
new file mode 100644
index 00000000..922378b4
--- /dev/null
+++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameFileRefactoring.kt
@@ -0,0 +1,35 @@
+/*******************************************************************************
+ * Copyright 2026 nbplugins contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ *******************************************************************************/
+package io.github.nbplugins.kotlin.nbm.refactoring
+
+import org.netbeans.modules.refactoring.api.AbstractRefactoring
+import org.openide.filesystems.FileObject
+import org.openide.util.lookup.Lookups
+
+/**
+ * Carries one explicit physical Kotlin-file rename through the NetBeans refactoring lifecycle.
+ *
+ * This carrier deliberately differs from [org.netbeans.modules.refactoring.api.RenameRefactoring]:
+ * the ordinary Rename refactoring remains responsible for caret-based declaration renames.
+ *
+ * @param file physical Kotlin source file to rename
+ * @param newName target basename without the `.kt` extension
+ */
+class KotlinRenameFileRefactoring(
+ val file: FileObject,
+ var newName: String,
+) : AbstractRefactoring(Lookups.fixed(file))
diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenamePlugin.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenamePlugin.kt
new file mode 100644
index 00000000..6022e8d0
--- /dev/null
+++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenamePlugin.kt
@@ -0,0 +1,270 @@
+/*******************************************************************************
+ * Copyright 2026 nbplugins contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ *******************************************************************************/
+package io.github.nbplugins.kotlin.nbm.refactoring
+
+import io.github.nbplugins.kotlin.nbm.navigation.KotlinFindUsagesResultElement
+import io.github.nbplugins.kotlin.nbm.resolve.KotlinAnalysisAPISession
+import org.jetbrains.kotlin.log.KotlinLogger
+import org.jetbrains.kotlin.utils.ProjectUtils
+import org.netbeans.modules.csl.api.OffsetRange
+import org.netbeans.modules.refactoring.api.Problem
+import org.netbeans.modules.refactoring.spi.ProgressProviderAdapter
+import org.netbeans.modules.refactoring.spi.RefactoringElementsBag
+import org.netbeans.modules.refactoring.spi.RefactoringPlugin
+import org.netbeans.modules.refactoring.spi.SimpleRefactoringElementImplementation
+import org.openide.filesystems.FileObject
+import org.openide.text.CloneableEditorSupport
+import org.openide.text.PositionBounds
+import org.openide.util.Lookup
+import org.openide.util.lookup.Lookups
+import javax.swing.text.Position.Bias
+
+/**
+ * Creates the NetBeans refactoring lifecycle for an explicit Kotlin file rename.
+ *
+ * @param refactoring physical file rename requested from the editor action
+ */
+class KotlinRenameFilePlugin(
+ private val refactoring: KotlinRenameFileRefactoring,
+) : ProgressProviderAdapter(), RefactoringPlugin {
+
+ /** @return a fatal problem when the selected source is not a valid Kotlin file. */
+ override fun preCheck(): Problem? = validateKotlinFile(refactoring.file)
+
+ /** @return a fatal problem when the requested basename is invalid. */
+ override fun fastCheckParameters(): Problem? = validateIdentifier(refactoring.newName)
+
+ /** @return the same validation used before UI confirmation. */
+ override fun checkParameters(): Problem? = fastCheckParameters()
+
+ /** Cancels no external work because the operation runs within the lifecycle. */
+ override fun cancelRequest() = Unit
+
+ /** Adds one preview and atomic transaction apply element. */
+ override fun prepare(bag: RefactoringElementsBag): Problem? {
+ preCheck()?.let { return it }
+ fastCheckParameters()?.let { return it }
+ bag.add(refactoring, KotlinFindUsagesResultElement(OffsetRange.NONE, refactoring.file))
+ bag.add(refactoring, KotlinRenameFileApplyElement(refactoring))
+ return null
+ }
+}
+
+/**
+ * Performs and undoes one physical Kotlin-file basename rename.
+ *
+ * @param refactoring file and target basename selected by the user
+ */
+class KotlinRenameFileApplyElement(
+ private val refactoring: KotlinRenameFileRefactoring,
+) : SimpleRefactoringElementImplementation() {
+ private var transaction: KotlinRefactoringTransaction? = null
+
+ /** @return preview label. */
+ override fun getText(): String = "Rename Kotlin file"
+
+ /** @return preview label. */
+ override fun getDisplayText(): String = text
+
+ /** @return renamed source lookup. */
+ override fun getLookup(): Lookup = Lookups.fixed(refactoring.file)
+
+ /** @return renamed physical Kotlin file. */
+ override fun getParentFile(): FileObject = refactoring.file
+
+ /** @return source bounds when an editor support is available. */
+ override fun getPosition(): PositionBounds? = positionFor(refactoring.file)
+
+ /** Renames the basename atomically and retains the completed transaction for undo. */
+ override fun performChange() {
+ var pending: KotlinRefactoringTransaction? = null
+ try {
+ val current = KotlinRefactoringTransaction()
+ pending = current
+ current.renameFile(refactoring.file, refactoring.newName, refactoring.file.ext)
+ current.commit()
+ transaction = current
+ pending = null
+ } catch (error: Throwable) {
+ KotlinLogger.INSTANCE.logException("KotlinRenameFileApplyElement.performChange failed", error)
+ rollbackAfterFailure(pending, error, "KotlinRenameFileApplyElement rollback failed")
+ throw error
+ }
+ }
+
+ /** Restores the original basename. */
+ override fun undoChange() {
+ runCatching { transaction?.undo(); transaction = null }
+ .onFailure { error -> KotlinLogger.INSTANCE.logException("KotlinRenameFileApplyElement.undoChange failed", error) }
+ }
+}
+
+/**
+ * Creates the NetBeans lifecycle for a Kotlin package-directory rename.
+ *
+ * @param refactoring folder rename and reference-update configuration from the folder action
+ */
+class KotlinRenameDirectoryPlugin(
+ private val refactoring: KotlinRenameDirectoryRefactoring,
+) : ProgressProviderAdapter(), RefactoringPlugin {
+
+ /** @return a fatal problem when the selected folder has no Kotlin sources. */
+ override fun preCheck(): Problem? = if (directorySelection() != null) {
+ null
+ } else {
+ Problem(true, "Rename Kotlin Package Directory requires a folder containing Kotlin files.")
+ }
+
+ /** @return a fatal problem when the target name is not a Kotlin identifier. */
+ override fun fastCheckParameters(): Problem? = validateIdentifier(refactoring.newName)
+
+ /** @return the same validation used before UI confirmation. */
+ override fun checkParameters(): Problem? = fastCheckParameters()
+
+ /** Cancels no external work because the operation runs within the lifecycle. */
+ override fun cancelRequest() = Unit
+
+ /** Adds source previews and one atomic physical folder-rename apply element. */
+ override fun prepare(bag: RefactoringElementsBag): Problem? {
+ val selection = directorySelection()
+ ?: return Problem(true, "Rename Kotlin Package Directory requires a folder containing Kotlin files.")
+ fastCheckParameters()?.let { return it }
+ val project = ProjectUtils.getKotlinProjectForFileObject(selection.representativeFile)
+ ?: ProjectUtils.getValidProject()
+ ?: return Problem(true, "Rename Kotlin Package Directory could not find the source project.")
+ val session = KotlinAnalysisAPISession.getSession(project)
+ selection.sourceFiles.forEach { source ->
+ val file = session.getKtFileForPath(source.path)
+ ?: return Problem(true, "Rename Kotlin Package Directory could not resolve ${source.nameExt}.")
+ bag.add(refactoring, KotlinFindUsagesResultElement(OffsetRange(0, file.textLength), source))
+ }
+ bag.add(refactoring, KotlinRenameDirectoryApplyElement(selection, project, refactoring))
+ return null
+ }
+
+ /** @return action-validated selection, or one resolved for compatibility callers. */
+ private fun directorySelection(): KotlinMoveSourceSelection? = refactoring.selection
+ ?.takeIf(KotlinMoveSourceSelection::isDirectory)
+ ?: KotlinMoveSourceSelection.from(refactoring.directory)
+ ?.takeIf(KotlinMoveSourceSelection::isDirectory)
+}
+
+/**
+ * Renames a package directory physically and refreshes K2 after its path changes.
+ *
+ * @param selection recursive Kotlin descendants of the selected directory
+ * @param project owning NetBeans project whose session must discover the new path
+ * @param refactoring directory rename request
+ */
+class KotlinRenameDirectoryApplyElement(
+ private val selection: KotlinMoveSourceSelection,
+ private val project: org.netbeans.api.project.Project,
+ private val refactoring: KotlinRenameDirectoryRefactoring,
+) : SimpleRefactoringElementImplementation() {
+ private var transaction: KotlinRefactoringTransaction? = null
+
+ /** @return preview label. */
+ override fun getText(): String = "Rename Kotlin package directory"
+
+ /** @return preview label. */
+ override fun getDisplayText(): String = text
+
+ /** @return selected directory lookup. */
+ override fun getLookup(): Lookup = Lookups.fixed(refactoring.directory)
+
+ /** @return representative Kotlin file for preview placement. */
+ override fun getParentFile(): FileObject = selection.representativeFile
+
+ /** @return representative source bounds when editor support is available. */
+ override fun getPosition(): PositionBounds? = positionFor(selection.representativeFile)
+
+ /**
+ * Renames the physical folder, recreates K2's session after the filesystem path changes, and
+ * commits the retained transaction without changing Kotlin package directives or references.
+ */
+ override fun performChange() {
+ var pending: KotlinRefactoringTransaction? = null
+ try {
+ val current = KotlinRefactoringTransaction()
+ pending = current
+ current.renameFolder(refactoring.directory, refactoring.newName)
+ current.applyFolderRenamesNow()
+ KotlinAnalysisAPISession.invalidate(project)
+ val refreshedSession = KotlinAnalysisAPISession.getSession(project)
+ selection.sourceFiles.forEach { source ->
+ check(refreshedSession.getKtFileForPath(source.path) != null) {
+ "Rename Kotlin Package Directory could not resolve ${source.nameExt} after folder rename."
+ }
+ }
+ current.commit()
+ transaction = current
+ pending = null
+ } catch (error: Throwable) {
+ KotlinLogger.INSTANCE.logException("KotlinRenameDirectoryApplyElement.performChange failed", error)
+ rollbackAfterFailure(pending, error, "KotlinRenameDirectoryApplyElement rollback failed")
+ throw error
+ } finally {
+ KotlinAnalysisAPISession.invalidate(project)
+ }
+ }
+
+ /** Restores the original physical directory name and refreshes K2's filesystem view. */
+ override fun undoChange() {
+ runCatching { transaction?.undo(); transaction = null; KotlinAnalysisAPISession.invalidate(project) }
+ .onFailure { error -> KotlinLogger.INSTANCE.logException("KotlinRenameDirectoryApplyElement.undoChange failed", error) }
+ }
+}
+
+/**
+ * Rolls back a transaction after an apply failure without concealing the originating failure.
+ *
+ * @param transaction transaction that may already have changed documents or paths
+ * @param originalError failure that NetBeans must receive
+ * @param logMessage diagnostic context for an additional rollback failure
+ */
+private fun rollbackAfterFailure(
+ transaction: KotlinRefactoringTransaction?,
+ originalError: Throwable,
+ logMessage: String,
+) {
+ try {
+ transaction?.rollback()
+ } catch (rollbackError: Throwable) {
+ originalError.addSuppressed(rollbackError)
+ KotlinLogger.INSTANCE.logException(logMessage, rollbackError)
+ }
+}
+
+/** Validates a physical Kotlin source before creating its filesystem rename lifecycle. */
+private fun validateKotlinFile(file: FileObject): Problem? = when {
+ !file.isValid || file.isFolder -> Problem(true, "Rename Kotlin File requires a valid Kotlin source file.")
+ file.ext != "kt" -> Problem(true, "Rename Kotlin File requires a .kt source file.")
+ else -> null
+}
+
+/** Validates an explicit Kotlin file or package-directory basename. */
+private fun validateIdentifier(name: String): Problem? = if (name.matches(Regex("[A-Za-z_][A-Za-z0-9_]*"))) {
+ null
+} else {
+ Problem(true, "The new Kotlin file or package-directory name must be a Kotlin identifier.")
+}
+
+/** Creates position bounds for one preview source when a CloneableEditorSupport is available. */
+private fun positionFor(file: FileObject): PositionBounds? = runCatching {
+ val support = org.openide.loaders.DataObject.find(file).lookup.lookup(CloneableEditorSupport::class.java) ?: return null
+ PositionBounds(support.createPositionRef(0, Bias.Forward), support.createPositionRef(0, Bias.Backward))
+}.getOrNull()
diff --git a/Nbm/src/main/resources/org/jetbrains/kotlin/layer.xml b/Nbm/src/main/resources/org/jetbrains/kotlin/layer.xml
index 0bbac40d..f558047f 100644
--- a/Nbm/src/main/resources/org/jetbrains/kotlin/layer.xml
+++ b/Nbm/src/main/resources/org/jetbrains/kotlin/layer.xml
@@ -32,10 +32,12 @@
+
-
+
@@ -124,6 +126,17 @@
+
+
+
+
+
+
+
+
+
+
+
@@ -165,8 +178,18 @@
-
-
+
+
+
+
+
+
+
+
+
+
+
@@ -238,19 +261,19 @@
-
+
-
+
-
+
-
+
@@ -362,17 +385,6 @@
-
-
-
-
-
-
-
-
-
-
-
@@ -404,10 +416,13 @@
+
+
+
+
+
-
-
-
@@ -704,6 +719,10 @@
+
+
+
+
diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/projectsextensions/KotlinProjectNodeFactoryTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/projectsextensions/KotlinProjectNodeFactoryTest.kt
index 0ed40da8..71874956 100644
--- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/projectsextensions/KotlinProjectNodeFactoryTest.kt
+++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/projectsextensions/KotlinProjectNodeFactoryTest.kt
@@ -18,14 +18,17 @@
package io.github.nbplugins.kotlin.nbm.projectsextensions
import java.beans.PropertyChangeListener
+import javax.swing.Action
import javax.swing.Icon
import javax.swing.event.ChangeListener
import org.netbeans.api.project.Project
import org.netbeans.api.project.SourceGroup
import org.netbeans.api.project.Sources
import org.netbeans.junit.NbTestCase
+import org.netbeans.spi.java.project.support.ui.PackageView
import org.openide.filesystems.FileObject
import org.openide.filesystems.FileUtil
+import org.openide.loaders.DataFolder
import org.openide.util.Lookup
import org.openide.util.lookup.Lookups
@@ -70,6 +73,30 @@ class KotlinProjectNodeFactoryTest : NbTestCase("KotlinProjectNodeFactoryTest")
)
}
+ /** Verifies the bare PackageView source root retains the folder context used for diagnostics. */
+ fun testPackageViewRoot_exposesDataFolderAndFixedRootActions() {
+ clearWorkDir()
+ val root = FileUtil.toFileObject(workDir)
+ val kotlinRoot = FileUtil.createFolder(root, "src/main/kotlin")
+ val sourceGroup = TestSourceGroup(kotlinRoot, KotlinProjectSourceGroups.KOTLIN_SOURCE_PACKAGES)
+
+ val node = PackageView.createPackageView(sourceGroup)
+ val actions = node.getActions(false)
+
+ assertNotNull(
+ "A package-view root must expose its backing folder through its lookup",
+ node.lookup.lookup(DataFolder::class.java),
+ )
+ assertTrue(
+ "The baseline PackageView root must provide actions to log and compare",
+ actions.isNotEmpty(),
+ )
+ assertFalse(
+ "The baseline PackageView root must not synthesize a Refactor submenu itself",
+ actions.any { action -> action?.getValue(Action.NAME) == "Refactor" },
+ )
+ }
+
/** Minimal source provider with preconfigured type-to-group mappings. */
private class TestSources(private val groups: Map>) : Sources {
override fun getSourceGroups(type: String): Array = groups[type] ?: emptyArray()
diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt
index 4dbe5b0a..1ee7fd44 100644
--- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt
+++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt
@@ -153,6 +153,56 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") {
}
}
+ /**
+ * Verifies Change Package retains a rebound qualified call when standalone FIR cannot shorten a
+ * range that also contains a local declaration.
+ */
+ fun testApply_realSession_keepsQualifiedReferenceWhenLocalDeclarationPreventsShortening() {
+ val stdlib = findStdlibJar() ?: run {
+ println("kotlin-stdlib not on test classpath — skipping Change Package integration test")
+ return
+ }
+ val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-local-shortener")
+ try {
+ val sourceDirectory = temporaryRoot.resolve("source")
+ val usageDirectory = temporaryRoot.resolve("usage")
+ Files.createDirectories(sourceDirectory)
+ Files.createDirectories(usageDirectory)
+ val sourcePath = sourceDirectory.resolve("Moved.kt")
+ val usagePath = usageDirectory.resolve("Usage.kt")
+ Files.writeString(sourcePath, "package old.source\n\nfun moved() = 1\n")
+ Files.writeString(
+ usagePath,
+ "package usage\n\nimport old.source.moved\n\nfun use(): Int {\n" +
+ " fun local() = moved()\n" +
+ " return local()\n" +
+ "}\n",
+ )
+ val session = KotlinAnalysisAPISession.createWithJars(
+ moduleName = "change-package-local-shortener-integration",
+ binaryJars = listOf(stdlib),
+ sourceRoots = listOf(temporaryRoot),
+ )
+ val source = session.getKtFileForPath(sourcePath.toString()) ?: error("Could not obtain source PSI")
+
+ val outcome = KaChangePackageComputer(source).apply(
+ mapOf(source to FqName("new.changed")),
+ updateReferences = true,
+ )
+
+ assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success)
+ val success = outcome as KaChangePackageComputer.ApplyOutcome.Success
+ assertTrue(success.changedFiles[sourcePath.toString()]?.contains("package new.changed") == true)
+ val usageText = success.changedFiles[usagePath.toString()] ?: error("Usage.kt was not changed")
+ assertTrue("Retargeted local call must retain a valid reference", usageText.contains("new.changed.moved()") || usageText.contains("moved()"))
+ assertFalse("Internal root-resolution prefix must never reach source text", usageText.contains(""))
+ assertTrue("Change Package must not move the declaration file", Files.isRegularFile(sourcePath))
+ assertTrue("Change Package must not move the usage file", Files.isRegularFile(usagePath))
+ } finally {
+ temporaryRoot.toFile().deleteRecursively()
+ }
+ }
+
/** Verifies recursive-style per-file packages retarget imports for every selected source together. */
fun testApply_realSession_supportsDistinctDescendantPackages() {
val stdlib = findStdlibJar() ?: run {
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 c389242f..d1589eb8 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
@@ -23,22 +23,129 @@ import java.nio.file.Path
/** Unit tests for [KotlinChangePackageDirectoryAction]. */
class KotlinChangePackageDirectoryActionTest : NbTestCase("KotlinChangePackageDirectoryActionTest") {
- /** Verifies source discovery can run outside the Event Dispatch Thread before UI marshalling. */
+ /** Verifies source discovery stays on the NodeAction worker before Swing UI is marshalled to EDT. */
fun testAsynchronous_allowsBackgroundSourceDiscovery() {
assertTrue(
- "K2 source discovery may run in NetBeans' RequestProcessor before UI is marshalled to EDT",
+ "Source discovery must run in NetBeans' worker before the action schedules its Swing UI",
TestAction().isAsynchronous(),
)
}
- /** Verifies the directory action has exactly one folder-context layer registration. */
- fun testLayer_registersFolderActionWithoutGlobalMenuDuplicate() {
+ /** Verifies folder actions are discovered only through the shared Refactor submenu. */
+ fun testLayer_keepsFolderActionsOutOfTheTopLevelContextMenu() {
val layer = repositoryRoot().resolve("Nbm/src/main/resources/org/jetbrains/kotlin/layer.xml")
val contents = Files.readString(layer)
- val actionName = "KotlinChangePackageDirectoryAction.shadow"
- assertEquals("Folder context action must have exactly one dedicated layer shadow", 1, contents.countOccurrences(actionName))
- assertTrue("Folder context action must be registered in Loaders/folder/any/Actions", contents.contains(""))
+ assertFalse(
+ "Change Package must not have a direct folder-context shadow",
+ contents.contains(""),
+ )
+ assertFalse(
+ "Rename Directory must not have a direct folder-context shadow",
+ contents.contains(""),
+ )
+ assertTrue(
+ "Change Package directory action must remain globally available to RefactoringAll",
+ contents.contains("KotlinChangePackageDirectoryAction.instance"),
+ )
+ assertTrue(
+ "Change Package editor action must remain registered for editor contexts",
+ contents.contains("KotlinChangePackageAction.instance"),
+ )
+ assertPositions(
+ contents,
+ "Folder Refactor menu must route Change Package to its node action",
+ "io-github-nbplugins-kotlin-nbm-refactoring-KotlinChangePackageDirectoryAction.shadow",
+ 267,
+ "io-github-nbplugins-kotlin-nbm-refactoring-KotlinChangeSignatureAction.shadow",
+ 270,
+ )
+ assertFalse(
+ "Folder Refactor menu must not route Change Package through the editor action",
+ contents.contains(""),
+ )
+ assertTrue(
+ "Rename Directory must remain globally available to RefactoringAll",
+ contents.contains("KotlinRenameDirectoryAction.instance"),
+ )
+ assertTrue(
+ "Kotlin file nodes must place the Refactor submenu inside their loader Actions folder",
+ contents.contains(
+ "\n" +
+ " ",
+ ),
+ )
+ assertTrue(
+ "Refactor must register the node-aware F7 action",
+ contents.contains(
+ "\n" +
+ " "
+ ),
+ )
+ assertFalse(
+ "The editor-only F7 action must not compete with the node action in RefactoringAll",
+ contents.contains(""),
+ )
+ assertPositions(
+ contents,
+ "F7 file rename must precede the undo separator",
+ "io-github-nbplugins-kotlin-nbm-refactoring-KotlinRenameFileNodeAction.shadow",
+ 2665,
+ "separator-before-undo-redo.instance",
+ 3000,
+ )
+ assertPositions(
+ contents,
+ "F7 directory rename must precede the undo separator",
+ "io-github-nbplugins-kotlin-nbm-refactoring-KotlinRenameDirectoryAction.shadow",
+ 2666,
+ "separator-before-undo-redo.instance",
+ 3000,
+ )
+ assertPositions(
+ contents,
+ "Undo must remain after the final separator",
+ "separator-before-undo-redo.instance",
+ 3000,
+ "org-jetbrains-kotlin-refactorings-KotlinRefactoringUndoAction.shadow",
+ 3100,
+ )
+ assertPositions(
+ contents,
+ "Redo must remain the final Refactor entry",
+ "org-jetbrains-kotlin-refactorings-KotlinRefactoringUndoAction.shadow",
+ 3100,
+ "org-jetbrains-kotlin-refactorings-KotlinRefactoringRedoAction.shadow",
+ 3200,
+ )
+ }
+
+ /** Verifies two layer entries retain the requested ordering and explicit positions. */
+ private fun assertPositions(
+ contents: String,
+ message: String,
+ firstName: String,
+ firstPosition: Int,
+ secondName: String,
+ secondPosition: Int,
+ ) {
+ val firstMarker = ""
+ val secondMarker = ""
+ val firstStart = contents.indexOf(firstMarker)
+ val secondStart = contents.indexOf(secondMarker)
+ assertTrue("$message: missing $firstName", firstStart >= 0)
+ assertTrue("$message: missing $secondName", secondStart >= 0)
+ assertTrue("$message: layer entries are out of order", firstStart < secondStart)
+ assertTrue(
+ "$message: $firstName position mismatch",
+ contents.substring(firstStart, contents.indexOf("", firstStart)).contains("intvalue=\"$firstPosition\""),
+ )
+ assertTrue(
+ "$message: $secondName position mismatch",
+ contents.substring(secondStart, contents.indexOf("", secondStart)).contains("intvalue=\"$secondPosition\""),
+ )
}
/** Finds the checkout root from the NBM module's Surefire working directory. */
@@ -54,10 +161,6 @@ class KotlinChangePackageDirectoryActionTest : NbTestCase("KotlinChangePackageDi
error("unreachable")
}
- /** Counts non-overlapping literal [needle] occurrences in this source text. */
- private fun String.countOccurrences(needle: String): Int =
- split(needle).size - 1
-
/** Exposes NodeAction's protected dispatcher contract for this focused test. */
private class TestAction : KotlinChangePackageDirectoryAction() {
fun isAsynchronous(): Boolean = asynchronous()
diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinFolderNodeSelectionTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinFolderNodeSelectionTest.kt
new file mode 100644
index 00000000..5a0d436b
--- /dev/null
+++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinFolderNodeSelectionTest.kt
@@ -0,0 +1,101 @@
+/*******************************************************************************
+ * Copyright 2026 nbplugins contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ *******************************************************************************/
+package io.github.nbplugins.kotlin.nbm.refactoring
+
+import org.netbeans.junit.NbTestCase
+import java.beans.PropertyChangeListener
+import javax.swing.Icon
+import org.netbeans.api.project.SourceGroup
+import org.netbeans.spi.java.project.support.ui.PackageView
+import org.openide.filesystems.FileObject
+import org.openide.filesystems.FileSystem
+import org.openide.filesystems.FileUtil
+import org.openide.loaders.DataFolder
+import org.openide.nodes.AbstractNode
+import org.openide.nodes.Children
+import org.openide.nodes.Node
+import org.openide.util.lookup.Lookups
+
+/** Unit tests for [KotlinFolderNodeSelection]. */
+class KotlinFolderNodeSelectionTest : NbTestCase("KotlinFolderNodeSelectionTest") {
+
+ /** In-memory filesystem used to model physical folder/file node selections. */
+ private val fileSystem: FileSystem = FileUtil.createMemoryFileSystem()
+
+ /** Verifies a physical folder exposed directly by a node is selected. */
+ fun testResolve_usesDirectFolderFileObject() {
+ val folder = fileSystem.root.createFolder("direct")
+
+ assertEquals(folder, KotlinFolderNodeSelection.resolve(arrayOf(node(folder))))
+ }
+
+ /** Verifies package-shaped nodes exposing a DataFolder resolve to the physical package folder. */
+ fun testResolve_usesDataFolderFromPackageNodeLookup() {
+ val folder = fileSystem.root.createFolder("package")
+ val packageNode = node(DataFolder.findFolder(folder))
+
+ assertEquals(folder, KotlinFolderNodeSelection.resolve(arrayOf(packageNode)))
+ }
+
+ /** Verifies files and multi-selection do not accidentally become folder operations. */
+ fun testResolve_rejectsFilesAndMultipleNodes() {
+ val folder = fileSystem.root.createFolder("folder")
+ val file = fileSystem.root.createData("Source.kt")
+
+ assertNull(KotlinFolderNodeSelection.resolve(arrayOf(node(file))))
+ assertNull(KotlinFolderNodeSelection.resolve(arrayOf(node(folder), node(folder))))
+ }
+
+ /** Verifies the real PackageView package-node lookup retains Kotlin directory selection. */
+ fun testResolve_usesRealPackageViewNodeAndDirectoryCarrierPrecheck() {
+ val sourceRoot = fileSystem.root.createFolder("src")
+ val packageFolder = sourceRoot.createFolder("example")
+ packageFolder.createData("Sample.kt")
+ val sourceGroup = TestSourceGroup(sourceRoot)
+ val packageRoot = PackageView.createPackageView(sourceGroup)
+ val packageNode = PackageView.findPath(packageRoot, packageFolder)
+
+ assertNotNull("PackageView must locate the physical package folder", packageNode)
+ val folder = KotlinFolderNodeSelection.resolve(arrayOf(packageNode!!))
+ assertEquals(packageFolder, folder)
+ val selection = KotlinMoveSourceSelection.from(folder!!)
+ assertNotNull("PackageView folder must discover its Kotlin source", selection)
+ assertTrue(selection!!.isDirectory)
+
+ val refactoring = KotlinRenameDirectoryRefactoring(packageFolder, "renamed", selection)
+ val ui = KotlinFilesystemRenameUI(refactoring, true, packageFolder.name)
+ ui.getPanel { }
+ assertNull("Filesystem rename UI must accept the captured package selection", ui.setParameters())
+
+ val problem = KotlinRenameDirectoryPlugin(refactoring).preCheck()
+ assertNull("Captured package selection must remain usable through the rename lifecycle", problem)
+ }
+
+ /** Creates a leaf node whose lookup exposes [value]. */
+ private fun node(value: Any): Node = AbstractNode(Children.LEAF, Lookups.singleton(value))
+
+ /** Minimal source group used to obtain a genuine NetBeans package-tree node. */
+ private class TestSourceGroup(private val root: FileObject) : SourceGroup {
+ override fun getRootFolder(): FileObject = root
+ override fun getName(): String = "test-source-root"
+ override fun getDisplayName(): String = "Test Source Root"
+ override fun getIcon(opened: Boolean): Icon? = null
+ override fun contains(file: FileObject): Boolean = file == root || file.path.startsWith("${root.path}/")
+ override fun addPropertyChangeListener(listener: PropertyChangeListener?) = Unit
+ override fun removePropertyChangeListener(listener: PropertyChangeListener?) = Unit
+ }
+}
diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveDirectoryActionTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveDirectoryActionTest.kt
index 49335357..11d7922d 100644
--- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveDirectoryActionTest.kt
+++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveDirectoryActionTest.kt
@@ -21,10 +21,10 @@ import org.netbeans.junit.NbTestCase
/** Unit tests for [KotlinMoveDirectoryAction]. */
class KotlinMoveDirectoryActionTest : NbTestCase("KotlinMoveDirectoryActionTest") {
- /** Verifies source discovery remains asynchronous while the action explicitly marshals UI later. */
- fun testAsynchronous_allowsBackgroundSourceDiscovery() {
- assertTrue(
- "K2 source discovery may run in NetBeans' RequestProcessor before UI is marshalled to EDT",
+ /** Verifies the legacy NodeAction dispatcher contract is explicitly synchronous. */
+ fun testAsynchronous_isExplicitlyFalse() {
+ assertFalse(
+ "The directory action must declare its dispatcher contract to NetBeans",
TestAction().isAsynchronous(),
)
}
diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinProjectFolderRenameSelectionTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinProjectFolderRenameSelectionTest.kt
new file mode 100644
index 00000000..b89c6872
--- /dev/null
+++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinProjectFolderRenameSelectionTest.kt
@@ -0,0 +1,120 @@
+/*******************************************************************************
+ * Copyright 2026 nbplugins contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ *******************************************************************************/
+package io.github.nbplugins.kotlin.nbm.refactoring
+
+import io.github.nbplugins.kotlin.nbm.projectsextensions.KotlinProjectSourceRoot
+import org.netbeans.junit.NbTestCase
+import org.openide.filesystems.FileObject
+import org.openide.filesystems.FileUtil
+import org.openide.loaders.DataFolder
+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 [KotlinProjectFolderRenameSelection]. */
+class KotlinProjectFolderRenameSelectionTest : NbTestCase("KotlinProjectFolderRenameSelectionTest") {
+
+ /** Verifies conventional main and test Kotlin roots and their package descendants are eligible. */
+ fun testResolve_acceptsConventionalKotlinRootsAndDescendants() {
+ val project = FileUtil.createMemoryFileSystem().root
+ val mainRoot = FileUtil.createFolder(project, "src/main/kotlin")
+ val mainPackage = FileUtil.createFolder(mainRoot, "sample/main")
+ mainPackage.createData("Main.kt")
+ val testRoot = FileUtil.createFolder(project, "src/test/kotlin")
+ val testPackage = FileUtil.createFolder(testRoot, "sample/test")
+ testPackage.createData("Test.kt")
+ val roots = roots(mainRoot, testRoot)
+
+ assertEquals(mainRoot, resolve(node(mainRoot), roots)?.selectedDirectory)
+ assertEquals(mainPackage, resolve(node(mainPackage), roots)?.selectedDirectory)
+ assertEquals(testRoot, resolve(node(testRoot), roots)?.selectedDirectory)
+ assertEquals(testPackage, resolve(node(testPackage), roots)?.selectedDirectory)
+ }
+
+ /** Verifies folders outside conventional roots and path-prefix siblings cannot be Kotlin renames. */
+ fun testResolve_rejectsNonKotlinRootsAndPrefixSiblings() {
+ val project = FileUtil.createMemoryFileSystem().root
+ val mainRoot = FileUtil.createFolder(project, "src/main/kotlin")
+ val kotlinSibling = FileUtil.createFolder(project, "src/main/kotlin2")
+ kotlinSibling.createData("Wrong.kt")
+ val javaPackage = FileUtil.createFolder(project, "src/main/java/sample")
+ javaPackage.createData("Java.kt")
+ val resources = FileUtil.createFolder(project, "src/main/resources/sample")
+ resources.createData("Resource.kt")
+ val build = FileUtil.createFolder(project, "build/generated/sample")
+ build.createData("Generated.kt")
+ val roots = roots(mainRoot)
+
+ assertNull(resolve(node(kotlinSibling), roots))
+ assertNull(resolve(node(javaPackage), roots))
+ assertNull(resolve(node(resources), roots))
+ assertNull(resolve(node(build), roots))
+ }
+
+ /** Verifies only folders with Kotlin descendants from exactly one supported node are eligible. */
+ fun testResolve_rejectsEmptyNonKotlinAndMultipleSelections() {
+ val project = FileUtil.createMemoryFileSystem().root
+ val mainRoot = FileUtil.createFolder(project, "src/main/kotlin")
+ val empty = mainRoot.createFolder("empty")
+ val resources = mainRoot.createFolder("resources")
+ resources.createData("config.json")
+ val packageFolder = mainRoot.createFolder("sample")
+ packageFolder.createData("Sample.kt")
+ val roots = roots(mainRoot)
+
+ assertNull(resolve(node(empty), roots))
+ assertNull(resolve(node(resources), roots))
+ assertNull(KotlinProjectFolderRenameSelection.resolve(Lookups.fixed(node(packageFolder), node(packageFolder)), roots))
+ }
+
+ /** Verifies inline Explorer names replace only the directory UI's initial name. */
+ fun testInitialName_prefersNonBlankExplorerName() {
+ val folder = FileUtil.createMemoryFileSystem().root.createFolder("original")
+
+ assertEquals("renamed", KotlinProjectFolderRenameSelection.initialName(folder, "renamed"))
+ assertEquals("original", KotlinProjectFolderRenameSelection.initialName(folder, ""))
+ assertEquals("original", KotlinProjectFolderRenameSelection.initialName(folder, " "))
+ assertEquals("original", KotlinProjectFolderRenameSelection.initialName(folder, null))
+ }
+
+ /** Verifies package-tree DataFolder nodes use the same folder and root eligibility rules. */
+ fun testResolve_acceptsDataFolderPackageNode() {
+ val project = FileUtil.createMemoryFileSystem().root
+ val mainRoot = FileUtil.createFolder(project, "src/main/kotlin")
+ val packageFolder = FileUtil.createFolder(mainRoot, "sample/package")
+ packageFolder.createData("Sample.kt")
+
+ val selection = resolve(node(DataFolder.findFolder(packageFolder)), roots(mainRoot))
+
+ assertNotNull(selection)
+ assertEquals(packageFolder, selection!!.selectedDirectory)
+ assertEquals(listOf("Sample.kt"), selection.sourceFiles.map(FileObject::getNameExt))
+ }
+
+ /** Resolves a one-node context using explicitly supplied conventional roots. */
+ private fun resolve(node: Node, roots: List): KotlinMoveSourceSelection? =
+ KotlinProjectFolderRenameSelection.resolve(Lookups.singleton(node), roots)
+
+ /** Creates main/test root descriptors without requiring a loaded NetBeans project. */
+ private fun roots(vararg folders: FileObject): List =
+ folders.mapIndexed { index, folder -> KotlinProjectSourceRoot(folder, index > 0) }
+
+ /** Creates a physical or package-tree node carrying [value]. */
+ private fun node(value: Any): Node = AbstractNode(Children.LEAF, Lookups.singleton(value))
+}
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 6294b6b1..53f601ac 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
@@ -490,6 +490,91 @@ class KotlinRefactoringTransactionTest : NbTestCase("KotlinRefactoringTransactio
assertEquals("fun source() = 1\n", fixture.text(source))
}
+ /** Verifies a same-folder Kotlin file rename participates in commit and undo. */
+ fun testCommitAndUndo_restoresRenamedFilePath() {
+ val fixture = fixture()
+ val source = fixture.existing("Before.kt", "fun renamed() = 1\n")
+
+ fixture.transaction.renameFile(source, "After", "kt")
+ fixture.transaction.commit()
+
+ assertNull("commit must remove the original file name", fixture.root.getFileObject("Before.kt"))
+ assertNotNull("commit must create the requested target name", fixture.root.getFileObject("After.kt"))
+ fixture.transaction.undo()
+
+ assertNotNull("undo must restore the original file name", fixture.root.getFileObject("Before.kt"))
+ assertNull("undo must remove the target file name", fixture.root.getFileObject("After.kt"))
+ }
+
+ /** Verifies file rename rejects an existing sibling before mutating the source. */
+ fun testRenameFile_rejectsExistingTarget() {
+ val fixture = fixture()
+ val source = fixture.existing("Before.kt", "fun before() = 1\n")
+ fixture.existing("Taken.kt", "fun taken() = 1\n")
+
+ try {
+ fixture.transaction.renameFile(source, "Taken", "kt")
+ fail("rename must reject an occupied sibling name")
+ } catch (_: IllegalStateException) {
+ // Expected: collision validation happens before commit.
+ }
+ assertTrue("source must remain valid after collision rejection", source.isValid)
+ assertNotNull("source must remain at its original path", fixture.root.getFileObject("Before.kt"))
+ }
+
+ /** Verifies an early folder rename is restored when the later transaction commit fails. */
+ fun testRollback_restoresFolderRenamedBeforeSemanticWrites() {
+ val fixture = fixture(failWriting = { it.nameExt == "Source.kt" })
+ val folder = fixture.root.createFolder("before")
+ val source = fixture.existingIn(folder, "Source.kt", "fun source() = 1\n")
+
+ fixture.transaction.renameFolder(folder, "after")
+ fixture.transaction.applyFolderRenamesNow()
+ val renamed = fixture.root.getFileObject("after/Source.kt") ?: error("Expected renamed source")
+ fixture.transaction.captureExisting(renamed)
+ fixture.transaction.stageText(renamed, "fun source() = 2\n")
+
+ try {
+ fixture.transaction.commit()
+ fail("commit must fail when the staged semantic write fails")
+ } catch (_: KotlinRefactoringTransaction.Failure) {
+ // Expected: rollback restores the physical source directory.
+ }
+ assertNotNull("rollback must restore the original folder", fixture.root.getFileObject("before/Source.kt"))
+ assertNull("rollback must remove the early renamed folder", fixture.root.getFileObject("after"))
+ assertTrue("the original FileObject remains valid after restoration", source.isValid)
+ assertEquals("rollback must restore the original file path", "before/Source.kt", source.path)
+ }
+
+ /** Verifies a folder rename preserves descendants and restores their physical paths on undo. */
+ fun testCommitAndUndo_restoresRenamedFolderAndDescendantText() {
+ val fixture = fixture()
+ val sourceFolder = fixture.root.createFolder("before")
+ val nested = sourceFolder.createFolder("nested")
+ val originalText = "package before.nested\n\nfun source() = 1\n"
+ val source = fixture.existingIn(nested, "Source.kt", originalText)
+ val readme = sourceFolder.createData("Readme.txt")
+
+ fixture.transaction.renameFolder(sourceFolder, "after")
+ fixture.transaction.captureExisting(source)
+ fixture.transaction.commit()
+
+ val renamedFolder = fixture.root.getFileObject("after")
+ val renamedSource = renamedFolder?.getFileObject("nested/Source.kt")
+ assertNotNull("commit must rename the selected folder", renamedFolder)
+ assertNotNull("commit must retain Kotlin descendants", renamedSource)
+ assertNotNull("commit must retain non-Kotlin descendants", renamedFolder?.getFileObject("Readme.txt"))
+ assertEquals("physical directory rename must not rewrite the package directive", originalText, fixture.textByPath(renamedSource!!))
+
+ fixture.transaction.undo()
+
+ val restored = fixture.root.getFileObject("before/nested/Source.kt")
+ assertNotNull("undo must restore the original folder path", restored)
+ assertNull("undo must remove the renamed folder", fixture.root.getFileObject("after"))
+ assertTrue("undo must preserve non-Kotlin descendants", readme.isValid)
+ assertEquals("undo must retain the original package directive", originalText, fixture.textByPath(restored!!))
+ }
+
/** Applies the same descending minimal-hunk sequence used by multi-file refactorings. */
private fun applyHunks(document: StyledDocument, originalText: String, finalText: String) {
TextRangeDiff.computeHunks(originalText, finalText)
diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameDirectoryActionTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameDirectoryActionTest.kt
new file mode 100644
index 00000000..9ca90b37
--- /dev/null
+++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameDirectoryActionTest.kt
@@ -0,0 +1,76 @@
+/*******************************************************************************
+ * Copyright 2026 nbplugins contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ *******************************************************************************/
+package io.github.nbplugins.kotlin.nbm.refactoring
+
+import java.nio.file.Files
+import java.nio.file.Path
+import org.netbeans.junit.NbTestCase
+
+/** Unit tests for [KotlinRenameDirectoryAction]. */
+class KotlinRenameDirectoryActionTest : NbTestCase("KotlinRenameDirectoryActionTest") {
+
+ /** Verifies the legacy NodeAction dispatcher contract is explicitly synchronous. */
+ fun testAsynchronous_isExplicitlyFalse() {
+ assertFalse(
+ "The directory action must declare its dispatcher contract to NetBeans",
+ TestAction().isAsynchronous(),
+ )
+ }
+
+ /** Verifies the Refactor menu routes a package node through the node-aware package action. */
+ fun testLayer_routesPackageNodeActionThroughRefactorMenu() {
+ val layer = repositoryRoot().resolve("Nbm/src/main/resources/org/jetbrains/kotlin/layer.xml")
+ val contents = Files.readString(layer)
+
+ assertTrue(
+ "The directory package action must be present in Menu/Refactoring for a package-node selection",
+ contents.contains("io-github-nbplugins-kotlin-nbm-refactoring-KotlinChangePackageDirectoryAction.shadow"),
+ )
+ assertFalse(
+ "The editor-only package action must not be a Refactor-menu shadow",
+ contents.contains(""),
+ )
+ assertTrue(
+ "The directory package action must remain globally registered for the Refactor submenu",
+ contents.contains("io-github-nbplugins-kotlin-nbm-refactoring-KotlinChangePackageDirectoryAction.instance"),
+ )
+ val provider = "org-jetbrains-kotlin-refactorings-rename-KotlinActionsImplementationProvider.instance"
+ val providerStart = contents.indexOf("")
+ val providerEnd = contents.indexOf("", providerStart)
+ assertTrue("The existing Kotlin rename provider must remain registered", providerStart >= 0)
+ assertTrue("The Kotlin rename provider must precede Java's generic position-100 provider",
+ contents.substring(providerStart, providerEnd).contains("intvalue=\"99\""))
+ }
+
+ /** Finds the checkout root from the NBM module's Surefire working directory. */
+ private fun repositoryRoot(): Path {
+ var candidate: Path? = Path.of("").toAbsolutePath()
+ while (candidate != null) {
+ if (Files.isRegularFile(candidate.resolve("pom.xml")) && Files.isDirectory(candidate.resolve("Nbm"))) {
+ return candidate
+ }
+ candidate = candidate.parent
+ }
+ fail("Could not locate repository root from ${Path.of("").toAbsolutePath()}")
+ error("unreachable")
+ }
+
+ /** Exposes NodeAction's protected dispatcher contract for this focused test. */
+ private class TestAction : KotlinRenameDirectoryAction() {
+ fun isAsynchronous(): Boolean = asynchronous()
+ }
+}
diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameFileNodeActionTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameFileNodeActionTest.kt
new file mode 100644
index 00000000..b665dc22
--- /dev/null
+++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameFileNodeActionTest.kt
@@ -0,0 +1,36 @@
+/*******************************************************************************
+ * Copyright 2026 nbplugins contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ *******************************************************************************/
+package io.github.nbplugins.kotlin.nbm.refactoring
+
+import org.netbeans.junit.NbTestCase
+
+/** Unit tests for [KotlinRenameFileNodeAction]. */
+class KotlinRenameFileNodeActionTest : NbTestCase("KotlinRenameFileNodeActionTest") {
+
+ /** Verifies the legacy NodeAction dispatcher contract is explicitly synchronous. */
+ fun testAsynchronous_isExplicitlyFalse() {
+ assertFalse(
+ "The file-node action must declare its dispatcher contract to NetBeans",
+ TestAction().isAsynchronous(),
+ )
+ }
+
+ /** Exposes NodeAction's protected dispatcher contract for this focused test. */
+ private class TestAction : KotlinRenameFileNodeAction() {
+ fun isAsynchronous(): Boolean = asynchronous()
+ }
+}
diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameRefactoringTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameRefactoringTest.kt
new file mode 100644
index 00000000..bf0e9b15
--- /dev/null
+++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameRefactoringTest.kt
@@ -0,0 +1,83 @@
+/*******************************************************************************
+ * Copyright 2026 nbplugins contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ *******************************************************************************/
+package io.github.nbplugins.kotlin.nbm.refactoring
+
+import org.jetbrains.kotlin.refactorings.rename.KotlinRefactoringsFactory
+import org.netbeans.junit.NbTestCase
+import org.openide.filesystems.FileUtil
+import org.openide.loaders.DataObject
+
+/** Unit tests for the typed Kotlin filesystem-rename refactoring carriers. */
+class KotlinRenameRefactoringTest : NbTestCase("KotlinRenameRefactoringTest") {
+
+ /** Verifies a file carrier retains the selected Kotlin source and requested basename. */
+ fun testFileCarrier_retainsSourceAndName() {
+ val file = FileUtil.createMemoryFileSystem().root.createData("Before.kt")
+
+ val refactoring = KotlinRenameFileRefactoring(file, "After")
+
+ assertEquals(file, refactoring.file)
+ assertEquals("After", refactoring.newName)
+ }
+
+ /** Verifies a directory carrier retains only the physical source and target folder name. */
+ fun testDirectoryCarrier_retainsPhysicalSourceAndName() {
+ val directory = FileUtil.createMemoryFileSystem().root.createFolder("before")
+
+ val refactoring = KotlinRenameDirectoryRefactoring(directory, "after")
+
+ assertEquals(directory, refactoring.directory)
+ assertEquals("after", refactoring.newName)
+ }
+
+ /** Verifies the file carrier bypasses the declaration-rename document applicability guard. */
+ fun testFactory_createsFilePluginWithoutStyledDocument() {
+ val file = FileUtil.createMemoryFileSystem().root.createData("Before.kt")
+
+ val plugin = KotlinRefactoringsFactory().createInstance(KotlinRenameFileRefactoring(file, "After"))
+
+ assertTrue("Filesystem file rename must use its typed plugin", plugin is KotlinRenameFilePlugin)
+ }
+
+ /** Verifies the folder carrier bypasses the declaration-rename document applicability guard. */
+ fun testFactory_createsDirectoryPluginWithoutStyledDocument() {
+ val directory = FileUtil.createMemoryFileSystem().root.createFolder("before")
+ directory.createData("Source.kt")
+
+ val plugin = KotlinRefactoringsFactory().createInstance(KotlinRenameDirectoryRefactoring(directory, "after"))
+
+ assertTrue("Filesystem directory rename must use its typed plugin", plugin is KotlinRenameDirectoryPlugin)
+ }
+
+ /** Verifies the default DataObject editor path releases its lock before a physical file rename. */
+ fun testFileApplyElement_renamesAndUndoesWithDefaultEditorTransaction() {
+ val root = FileUtil.createMemoryFileSystem().root
+ val before = root.createData("Before.kt")
+ before.getOutputStream().use { it.write("fun before() = Unit\n".toByteArray()) }
+ DataObject.find(before)
+
+ val apply = KotlinRenameFileApplyElement(KotlinRenameFileRefactoring(before, "After"))
+ apply.performChange()
+
+ assertNull("apply must remove the original physical filename", root.getFileObject("Before.kt"))
+ assertNotNull("apply must create the requested physical filename", root.getFileObject("After.kt"))
+ apply.undoChange()
+
+ assertNotNull("undo must restore the original physical filename", root.getFileObject("Before.kt"))
+ assertNull("undo must remove the renamed physical filename", root.getFileObject("After.kt"))
+ }
+}
diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/RefactoringCoverageBaselineTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/RefactoringCoverageBaselineTest.kt
index b4ef56bf..5a25cf63 100644
--- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/RefactoringCoverageBaselineTest.kt
+++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/RefactoringCoverageBaselineTest.kt
@@ -54,10 +54,10 @@ class RefactoringCoverageBaselineTest : NbTestCase("RefactoringCoverageBaselineT
}
}
- val totals = "**Baseline counts:** 0 complete, 23 partial, 1 absent, 24 total."
+ val totals = "**Baseline counts:** 0 complete, 24 partial, 0 absent, 24 total."
assertTrue("Visible baseline totals must match the records", Files.readString(matrix).contains(totals))
- assertEquals(23, records.count { it.status == "partial" })
- assertEquals(1, records.count { it.status == "absent" })
+ assertEquals(24, records.count { it.status == "partial" })
+ assertEquals(0, records.count { it.status == "absent" })
}
/** Finds the checkout root from Surefire's module working directory. */
diff --git a/docs/development-plan.md b/docs/development-plan.md
index ac0bf24e..e96c70dd 100644
--- a/docs/development-plan.md
+++ b/docs/development-plan.md
@@ -793,6 +793,23 @@ Priority order: E1 → E2 → E3 → E4 → E5 → E6 → E7 → E8 → E9 → E
initializers, alias imports, duplicate targets, Java/non-code/comment/index-only scenarios, and
external qualified usages whose rewrite has not been validated.
+ - [x] **F7** — Rename Kotlin File and Package Directory (Refactor menu / folder context menu)
+ - Adds an explicit **Refactor → Rename Kotlin File...** command for the active `.kt` file.
+ It uses `KotlinRefactoringTransaction` to rename only the physical basename and supports
+ **Undo Last Refactoring**; package declarations and Java file-facade references are deliberately
+ unchanged.
+ - Adds folder-node **Rename Kotlin Package Directory...**. The transaction renames the folder
+ first so real final `PsiDirectory` targets exist, refreshes the standalone K2 session, then
+ invokes the already ported `K2MoveFilesHandler` through `KaMoveFileComputer`. The K2 adapter
+ performs eligible package-directive rewrites, portable conflict detection, and supported Kotlin
+ reference retargeting; the transaction stages its text results and rolls both text and folder
+ paths back if any later step fails.
+ - The generic IDEA rename UI/lifecycle remains intentionally unported because it depends on
+ writable IntelliJ VFS, indexed Java/file-facade search, automatic-renamer extensions, and
+ action/command infrastructure unavailable in standalone NetBeans. Unicode/quoted names,
+ Java/comment/text/index-only references, automatic renamers, and full project-model behavior
+ remain outside this portable adapter.
+
- [x] **E9.8** — Change Signature (Ctrl+F6) — PRs #114, #115
- IDEA sources: `changeSignature/KotlinChangeSignatureUsageSearcher.kt`,
`changeSignature/KotlinChangeInfo.kt`, `changeSignature/KotlinChangeInfoBase.kt`,
diff --git a/docs/refactoring-coverage.md b/docs/refactoring-coverage.md
index 9f467193..7718348e 100644
--- a/docs/refactoring-coverage.md
+++ b/docs/refactoring-coverage.md
@@ -31,7 +31,7 @@ netbeans=; test=; milestone=
-
+
## Purpose and scope
@@ -48,13 +48,13 @@ The corresponding record comments above are deliberately machine-readable. `Refa
| **Absent** | IDEA exposes the Kotlin refactoring family but the NetBeans plugin has no corresponding command. |
| **Not applicable** | IDEA-only integration which is not a Kotlin refactoring target for this plugin. No such rows are currently tracked. |
-**Baseline counts:** 0 complete, 23 partial, 1 absent, 24 total.
+**Baseline counts:** 0 complete, 24 partial, 0 absent, 24 total.
## Current NetBeans refactorings
| ID | IDEA K2 family | NetBeans command | Status | Current coverage and material gap | Target |
|---|---|---|---|---|---|
-| `rename` | Rename | Rename (`Alt+Shift+R`) | Partial | K2 declaration/reference rename and override cascade are covered; file/package/directory renames and IDEA automatic renamers remain. | F7 |
+| `rename` | Rename | Rename (`Alt+Shift+R`) | Partial | K2 declaration/reference rename and override cascade are covered. Explicit filesystem rename commands cover Kotlin file basenames and package directories; IDEA automatic renamers and Java-bridge scenarios remain. | F7 |
| `safe-delete` | Safe Delete | Safe Delete (`Alt+Delete`) | Partial | Declaration usage check is available; IDEA supertype, type/value-argument, and Java-bridge cases remain. | F7 |
| `change-signature` | Change Signature | `Ctrl+F6` | Partial | K2 signature engine handles calls, overrides, constructors, destructuring and operators; every touched document now commits or rolls back through one hunk-preserving transaction, while broader IDEA scenarios remain. | F1 |
| `inline-variable-property` | Inline property | Inline (`Ctrl+Alt+N`) | Partial | IDEA code inliner supports local `val` and a local `var` with one declaration initializer and no later writes, invoked from a declaration or selected usage; a later assignment or increment/decrement is rejected before mutation with its expression identified. Receiver/member/accessor, comment, Java, and broader index-backed cases remain. | F3 |
@@ -72,18 +72,13 @@ The corresponding record comments above are deliberately machine-readable. `Refa
| `move-declaration` | Move top-level declaration | Refactor → Move Declaration | Partial | Real K2 move/retargeting engine is used; source/target mutations now roll back atomically and support Undo Last Refactoring, while only top-level declarations are exposed. | F1 |
| `move-file` | Move Kotlin file/directory | Refactor → Move Kotlin File / folder → Move Kotlin Directory | Partial | Moves one Kotlin file or recursively moves Kotlin descendants of a selected folder to a selectable source root/package, retaining the selected folder and nested hierarchy; matching package directives and supported Kotlin code references are updated, and Undo Last Refactoring restores every original path/text. Generic non-Kotlin content, Java/comment/text/index-only references, and full IDEA filesystem behavior remain. | F4 |
| `change-package` | Change Package | Refactor → Change Kotlin Package... / folder → Change Kotlin Package... | Partial | Changes the active Kotlin file or recursively selected folder's Kotlin descendants without moving physical paths; nested descendants retain relative package segments below the entered package, supported Kotlin code/direct imports update atomically, and Undo Last Refactoring restores every original text. Java/comment/text/index-only references, full IDEA project-model conflicts, and arbitrary multi-selection remain unavailable. | F4 |
+| `rename-file-package-directory` | Rename file/package/directory | Refactor → Rename Kotlin File / folder → Rename Kotlin Package Directory | Partial | Explicit file rename changes one `.kt` basename and Undo Last Refactoring restores it. Directory rename preserves every physical descendant and restores paths on undo without changing package directives or Kotlin references; Change Kotlin Package remains the separate semantic operation for those changes. The K2 session is recreated after the path change so it discovers the renamed directory. Java file-facade, automatic-renamer, Java/comment/text/index-only, Unicode/quoted-name, and full IDEA project-model cases remain. | F7 |
| `move-nested-member-method` | Move nested class/member/method | Refactor → Move Nested Member... | Partial | Moves a nested named Kotlin class/object to a selected named class/object, or a companion function/property to a selected target with exactly one companion object. Source/target changes commit atomically and support Undo Last Refactoring. Instance members, constructors, enum entries, aliases, Java/non-code/comment/index-only cases, and unvalidated external qualified-use rewrites remain unavailable. | F4 |
| `copy-declaration` | Copy declaration | Refactor → Copy / F5 | Partial | Top-level copy supports selectable source-root/package targets, internal retargeting, atomic rollback, and Undo Last Refactoring; nested declarations and broader IDEA target workflows remain. | F1 |
| `extract-interface-superclass` | Extract Interface / Superclass | Refactor menu | Partial | K2 Extract Super engine atomically creates or restores its target together with the source, including Undo Last Refactoring; advanced constructors, generics and full conflicts remain. | F6 |
| `pull-members-up` | Pull Members Up | `Ctrl+Alt+U` | Partial | Build-wide K2 hierarchy discovery covers Kotlin override chains; visibility, accidental-override, Java and full IDEA-index conflict checks remain. | F2 |
| `push-members-down` | Push Members Down | `Ctrl+Alt+O` | Partial | Build-wide K2 direct-subclass discovery works; visibility, accidental-override, Java and full IDEA-index conflict checks remain. | F2 |
-## Absent IDEA families
-
-| ID | IDEA family | Why absent | Target |
-|---|---|---|---|
-| `rename-file-package-directory` | Rename file/package/directory | Requires NetBeans filesystem refactoring integration and Kotlin-aware update rules. | F7 |
-
## Cross-cutting milestones
| Milestone | Outcome |
diff --git a/pom.xml b/pom.xml
index f1ed97fd..5954b533 100644
--- a/pom.xml
+++ b/pom.xml
@@ -4,7 +4,7 @@
4.0.0
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.260-SNAPSHOT
+ 0.11.280-SNAPSHOT
pom
Netbeans Plugin Kotlin