diff --git a/build.gradle b/build.gradle index 10a4c25f..1223c1e5 100644 --- a/build.gradle +++ b/build.gradle @@ -43,12 +43,13 @@ dependencies { implementation 'ch.qos.logback:logback-classic:1.5.17' implementation 'org.eclipse.jgit:org.eclipse.jgit:6.10.0.202406032230-r' implementation 'com.brunomnsilva:smartgraph:2.3.0' + compileOnly 'org.jetbrains:annotations:24.0.0' testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.0' testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.10.0' testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.10.0' testImplementation 'org.testfx:testfx-core:4.0.18' testImplementation 'org.testfx:testfx-junit5:4.0.18' - testRuntimeOnly 'org.testfx:openjfx-monocle:jdk-12.0.1+2' + testRuntimeOnly 'org.testfx:openjfx-monocle:17.0.10' testImplementation 'org.mockito:mockito-core:5.7.0' testImplementation 'org.hamcrest:hamcrest:2.2' @@ -56,6 +57,17 @@ dependencies { test { useJUnitPlatform() + systemProperty 'testfx.robot', 'glass' + systemProperty 'testfx.headless', 'true' + systemProperty 'glass.platform', 'Monocle' + systemProperty 'monocle.platform', 'Headless' + systemProperty 'prism.order', 'sw' + systemProperty 'prism.text', 't2k' + systemProperty 'java.awt.headless', 'true' + systemProperty 'headless.geometry', '1920x1200-32' + jvmArgs '--add-exports', 'javafx.graphics/com.sun.javafx.application=ALL-UNNAMED' + jvmArgs '--add-exports', 'javafx.graphics/com.sun.glass.ui.monocle=ALL-UNNAMED' + jvmArgs '--add-opens', 'javafx.graphics/com.sun.glass.ui=ALL-UNNAMED' } runtime { diff --git a/src/main/java/com/daniel/jsoneditor/controller/AppService.java b/src/main/java/com/daniel/jsoneditor/controller/AppService.java index 6c053a84..aacb4944 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/AppService.java +++ b/src/main/java/com/daniel/jsoneditor/controller/AppService.java @@ -1,6 +1,17 @@ package com.daniel.jsoneditor.controller; import com.daniel.jsoneditor.controller.mcp.McpController; +import com.daniel.jsoneditor.controller.impl.ControllerImpl; +import com.daniel.jsoneditor.controller.impl.json.impl.JsonFileReaderAndWriterImpl; +import com.daniel.jsoneditor.model.WritableModel; +import com.daniel.jsoneditor.model.ReadableModel; +import com.daniel.jsoneditor.model.sessions.AttachResult; +import com.daniel.jsoneditor.model.sessions.EditorSession; +import com.daniel.jsoneditor.model.settings.Settings; +import com.daniel.jsoneditor.util.CanonicalPaths; +import javafx.scene.control.Alert; +import javafx.scene.control.ButtonType; +import javafx.stage.Stage; import com.daniel.jsoneditor.controller.settings.RecentFilesManager; import com.daniel.jsoneditor.controller.settings.SettingsController; import com.daniel.jsoneditor.controller.settings.impl.SettingsControllerImpl; @@ -37,6 +48,10 @@ public class AppService private final List windows = new CopyOnWriteArrayList<>(); private final AtomicBoolean shuttingDown = new AtomicBoolean(false); + private final WindowRegistry windowRegistry; + + private final FileOpenCoordinator fileOpenCoordinator; + /** Creates the service using the port configured in settings. */ public AppService() { @@ -55,6 +70,8 @@ public AppService(final int portOverride) this.recentFilesManager = new RecentFilesManager(); this.mcpController = new McpController(fileSessionManager, settingsController, this); startMcpServer(portOverride); + this.windowRegistry = new WindowRegistry(); + this.fileOpenCoordinator = new FileOpenCoordinator(windowRegistry, this); this.systemTrayManager = new SystemTrayManager(this); try { @@ -69,11 +86,7 @@ public AppService(final int portOverride) } } - /** - * Starts the MCP server if enabled in settings. - * Uses {@code portOverride} when positive; otherwise falls back to the settings port. - * Called automatically during construction so the server is available before any window opens. - */ + // Starts MCP server if enabled; uses portOverride when > 0, else settings port. private void startMcpServer(final int portOverride) { if (!settingsController.isMcpServerEnabled()) @@ -95,6 +108,7 @@ private void startMcpServer(final int portOverride) /** * Creates a new editor window. + * Must be called on the JavaFX Application Thread. * * @return the new {@link AppWindow}, or {@code null} if the application is shutting down */ @@ -105,33 +119,75 @@ public AppWindow createWindow() logger.info("Cannot create window — application is shutting down"); return null; } - final AppWindow window = new AppWindow(this); + final AppWindow window = AppWindow.bootstrap(this); windows.add(window); window.setOnClose(() -> onWindowClosed(window)); return window; } /** - * Opens a new editor window and immediately loads the given JSON+schema file pair. + * Opens the given JSON+schema file pair in a window. + * If a window is already showing this file, focuses it instead of creating a new one. * Must be called on the JavaFX Application Thread. */ public void openFileInNewWindow(final File jsonFile, final File schemaFile) { - final AppWindow window = createWindow(); - if (window == null) + fileOpenCoordinator.open(jsonFile, schemaFile); + } + + /** + * Opens a new editor window and immediately loads the given JSON+schema file pair. + * Attaches a (possibly shared) session via + * {@link com.daniel.jsoneditor.model.sessions.FileSessionManager#attachSession}. + * Must be called on the JavaFX Application Thread. + */ + public void openFileInNewWindowDirect(final File jsonFile, final File schemaFile) + { + if (shuttingDown.get()) { + logger.info("Cannot open file — application is shutting down"); return; } - window.getController().jsonAndSchemaSelected(jsonFile, schemaFile, null); + final AppWindow window = AppWindow.createBlank(this); + windows.add(window); + window.setOnClose(() -> onWindowClosed(window)); + try + { + final AttachResult result = attachLoadedSession(window, window.getStage(), jsonFile, schemaFile, null); + if (!result.success()) + { + windows.remove(window); + final String error = result.error(); + Platform.runLater(() -> + { + final Alert alert = new Alert(Alert.AlertType.ERROR, error != null ? error : "Failed to open file", ButtonType.OK); + alert.setTitle("Cannot open file"); + alert.showAndWait(); + }); + } + } + catch (final RuntimeException e) + { + windows.remove(window); + if (window.getStage().isShowing()) + { + window.getStage().close(); + } + logger.error("Unexpected error while opening file — window cleaned up", e); + final String message = e.getMessage() != null ? e.getMessage() : "An unexpected error occurred while opening the file"; + Platform.runLater(() -> + { + final Alert alert = new Alert(Alert.AlertType.ERROR, message, ButtonType.OK); + alert.setTitle("Cannot open file"); + alert.showAndWait(); + }); + } } - /** - * Called when a window is closed. - * Exits the application when the last window closes and the MCP server is not running. - * When MCP is enabled and running the service stays alive in the background. - */ + // Exits when last window closes, unless MCP server is running. private void onWindowClosed(final AppWindow window) { + windowRegistry.unregisterWindow(window); windows.remove(window); logger.info("Window closed. {} window(s) remaining.", windows.size()); if (windows.isEmpty() && !mcpController.isMcpServerRunning()) @@ -141,37 +197,116 @@ private void onWindowClosed(final AppWindow window) } } - /** Returns the shared file session manager. */ public FileSessionManager getFileSessionManager() { return fileSessionManager; } - /** Returns the shared settings controller. */ public SettingsController getSettingsController() { return settingsController; } - /** Returns the shared MCP controller. */ public McpController getMcpController() { return mcpController; } - /** Returns the recent files manager. */ public RecentFilesManager getRecentFilesManager() { return recentFilesManager; } - /** Returns the number of currently open windows. */ + public WindowRegistry getWindowRegistry() + { + return windowRegistry; + } + + public FileOpenCoordinator getFileOpenCoordinator() + { + return fileOpenCoordinator; + } + + /** + * Attaches a loaded file session and wires a new {@link ControllerImpl} to the given window/stage. + * Handles: FSM session attach, optional settings application, controller construction, + * window registration. Package-private — called by {@link AppWindow} factory methods and + * by {@link #openFileInNewWindowDirect}. + * + * @param window the AppWindow that will host the editor + * @param stage the JavaFX stage for the window + * @param jsonFile the JSON file to open + * @param schemaFile the schema file; must not be {@code null} — an {@link AttachResult#ofError} is returned if null + * @param settingsFile optional settings file, may be {@code null} + * @return the FSM {@link AttachResult}; on failure {@link AttachResult#success()} is {@code false} + */ + AttachResult attachLoadedSession(final AppWindow window, final Stage stage, final File jsonFile, + final File schemaFile, final File settingsFile) + { + if (schemaFile == null) + { + return AttachResult.ofError("Schema file is required and must not be null"); + } + + final AttachResult result = fileSessionManager.attachSession( + jsonFile.getAbsolutePath(), + schemaFile.getAbsolutePath(), + true); + if (!result.success()) + { + logger.warn("attachSession failed for {}: {}", jsonFile, result.error()); + return result; + } + + try + { + final EditorSession session = fileSessionManager.getSession(result.sessionId()); + if (session == null) + { + fileSessionManager.detachSession(result.sessionId()); + return AttachResult.ofError("Session unavailable after attach"); + } + final ReadableModel sessionModel = session.model(); + if (!(sessionModel instanceof WritableModel writableModel)) + { + throw new IllegalStateException("Session model does not implement WritableModel: " + sessionModel.getClass()); + } + + if (settingsFile != null && !settingsFile.getPath().isEmpty() && settingsFile.exists()) + { + final Settings settings = new JsonFileReaderAndWriterImpl().getJsonFromFile(settingsFile, Settings.class, true); + if (settings != null) + { + writableModel.setSettings(settings); + } + } + + final ControllerImpl controller = new ControllerImpl(writableModel, sessionModel, stage, this, jsonFile, schemaFile, + result.sessionId()); + controller.setAppWindow(window); + controller.registerInWindowRegistry(CanonicalPaths.canonicalize(jsonFile)); + window.attachLoadedController(controller); + return result; + } + catch (final RuntimeException e) + { + try + { + fileSessionManager.detachSession(result.sessionId()); + } + catch (final Exception detachEx) + { + logger.warn("detachSession failed during cleanup after exception; session {} may leak", result.sessionId(), detachEx); + } + throw e; + } + } + public int getWindowCount() { return windows.size(); } - /** Returns true if the application is shutting down. */ public boolean isShuttingDown() { return shuttingDown.get(); @@ -189,6 +324,7 @@ public void shutdown() } logger.info("Shutting down AppService"); systemTrayManager.hide(); + fileSessionManager.closeAllHeadlessSessions(); mcpController.stopMcpServer(); } } diff --git a/src/main/java/com/daniel/jsoneditor/controller/AppWindow.java b/src/main/java/com/daniel/jsoneditor/controller/AppWindow.java index 91bd04fb..c4061b70 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/AppWindow.java +++ b/src/main/java/com/daniel/jsoneditor/controller/AppWindow.java @@ -1,9 +1,13 @@ package com.daniel.jsoneditor.controller; import com.daniel.jsoneditor.controller.impl.ControllerImpl; -import com.daniel.jsoneditor.model.impl.ModelImpl; -import com.daniel.jsoneditor.model.statemachine.impl.EventSenderImpl; +import com.daniel.jsoneditor.model.sessions.AttachResult; +import javafx.application.Platform; import javafx.stage.Stage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; /** * Encapsulates a single app window with its own Model, Controller, View, and Stage. @@ -11,21 +15,67 @@ */ public class AppWindow { - private final Controller controller; + private static final Logger logger = LoggerFactory.getLogger(AppWindow.class); private final Stage stage; - /** Creates a new editor window wired to the given AppService. */ - public AppWindow(final AppService appService) + private final AppService appService; + + /** Mutable: null during the bootstrap picker phase, non-null once a file is loaded. */ + private Controller controller; + + /** Package-private constructor used by factory methods — no controller yet. */ + AppWindow(final AppService appService, final Stage stage) + { + this.appService = appService; + this.stage = stage; + this.controller = null; + } + + /** + * Creates a blank AppWindow with a new Stage, no controller. Package-private so that + * {@link AppService} can also create blank windows for direct-load flows. + * + * @param appService the shared application service + * @return a new blank AppWindow backed by a fresh Stage + */ + static AppWindow createBlank(final AppService appService) + { + final Stage stage = new Stage(); + return new AppWindow(appService, stage); + } + + /** + * Bootstrap factory: creates an AppWindow that starts in Phase 1 (picker only, no model). + * A {@link BootstrapController} handles the picker scene and transitions the window to Phase 2 + * (real editor) by calling {@link #attachLoadedController(ControllerImpl)} after the user + * confirms a file selection. + * + * @param appService the shared application service + * @return the new AppWindow (already showing the picker) + */ + public static AppWindow bootstrap(final AppService appService) + { + final AppWindow window = createBlank(appService); + final BootstrapController bootstrap = new BootstrapController(window.getStage(), appService, window); + bootstrap.showPicker(); + return window; + } + + /** + * Replaces the (possibly null) bootstrap-phase controller with the real one after file load. + * Called by {@link BootstrapController} once the model is ready. + * + * @param loadedController the fully initialised controller backed by the loaded model + */ + public void attachLoadedController(final ControllerImpl loadedController) { - this.stage = new Stage(); - stage.setTitle("JSON Editor"); - final ModelImpl model = new ModelImpl(new EventSenderImpl()); - this.controller = new ControllerImpl(model, model, stage, appService); + this.controller = loadedController; } /** * Sets up close behavior: shuts down this window's controller. + * Guards against a null controller (bootstrap window closed before a file was picked). * * @param onClose callback to run after this window closes (e.g. app exit check) */ @@ -33,7 +83,10 @@ public void setOnClose(final Runnable onClose) { stage.setOnHiding(event -> { - controller.shutdown(); + if (controller != null) + { + controller.shutdown(); + } if (onClose != null) { onClose.run(); @@ -41,15 +94,35 @@ public void setOnClose(final Runnable onClose) }); } - /** Returns this window's controller. */ + /** + * Returns the controller for this window's editor phase. + * + * @return the {@link Controller} once a file has been loaded, or {@code null} during the + * bootstrap (file-picker) phase before any file is selected. Callers must null-check. + */ public Controller getController() { return controller; } - /** Returns this window's stage. */ public Stage getStage() { return stage; } + + /** + * Brings this window to the front, restoring it if iconified. + * Must be called on the JavaFX Application Thread. + */ + public void focus() + { + if (!Platform.isFxApplicationThread()) + { + Platform.runLater(this::focus); + return; + } + stage.setIconified(false); + stage.toFront(); + stage.requestFocus(); + } } diff --git a/src/main/java/com/daniel/jsoneditor/controller/BootstrapController.java b/src/main/java/com/daniel/jsoneditor/controller/BootstrapController.java new file mode 100644 index 00000000..7f87de20 --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/controller/BootstrapController.java @@ -0,0 +1,86 @@ +package com.daniel.jsoneditor.controller; + +import java.io.File; +import com.daniel.jsoneditor.model.impl.ModelFactory; +import com.daniel.jsoneditor.model.impl.ModelImpl; +import com.daniel.jsoneditor.model.sessions.AttachResult; +import com.daniel.jsoneditor.view.impl.jfx.impl.scenes.impl.JSONSelectionScene; +import javafx.stage.Stage; +import javafx.application.Platform; +import javafx.scene.control.Alert; +import javafx.scene.control.ButtonType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Owns Phase 1 of the two-phase bootstrap: shows the file picker, no model yet. + * On file pick, attaches a session via {@link FileSessionManager}, then transitions + * the owning {@link AppWindow} to a real {@link ControllerImpl} via + * {@link AppWindow#attachLoadedController(ControllerImpl)}. + *

Must be called on the JavaFX Application Thread.

+ */ +public final class BootstrapController +{ + private static final Logger logger = LoggerFactory.getLogger(BootstrapController.class); + + private final Stage stage; + private final AppService appService; + private final AppWindow appWindow; + + public BootstrapController(final Stage stage, final AppService appService, final AppWindow appWindow) + { + this.stage = stage; + this.appService = appService; + this.appWindow = appWindow; + } + + /** + * Shows the file picker scene. + * Must be called on the JavaFX Application Thread. + *

+ * A disposable empty {@link ModelImpl} is passed as a placeholder — {@link JSONSelectionScene} + * requires a non-null {@link com.daniel.jsoneditor.model.ReadableModel} for its parent + * constructor but never reads from it during the picker phase. The UIHandler argument is + * {@code null} for the same reason: {@code JSONSelectionScene.getScene()} never invokes it. + * When the user confirms a selection, {@link #onFilesPicked(File, File, File)} is called. + */ + public void showPicker() + { + final ModelImpl placeholder = ModelFactory.createEmpty(); + final JSONSelectionScene pickerScene = new JSONSelectionScene( + null, // null: picker-only scene has no toast capability + appService.getSettingsController(), + placeholder, + this::onFilesPicked); + stage.setScene(pickerScene.getScene(stage)); + stage.setWidth(700); + stage.setHeight(300); + stage.show(); + } + + void onFilesPicked(final File jsonFile, final File schemaFile, final File settingsFile) + { + final AttachResult result; + try + { + result = appService.attachLoadedSession(appWindow, stage, jsonFile, schemaFile, settingsFile); + } + catch (final IllegalStateException e) + { + logger.error("attachLoadedSession threw IllegalStateException: {}", e.getMessage(), e); + return; + } + if (!result.success()) + { + logger.warn("attachSession failed: {}", result.error()); + final String message = result.error() != null ? result.error() : "Failed to open file"; + Platform.runLater(() -> + { + final Alert alert = new Alert(Alert.AlertType.ERROR, message, ButtonType.OK); + alert.setTitle("Cannot open file"); + alert.showAndWait(); + }); + // Picker stays visible — the user can correct the file selection + } + } +} diff --git a/src/main/java/com/daniel/jsoneditor/controller/Controller.java b/src/main/java/com/daniel/jsoneditor/controller/Controller.java index 530439ea..c3d3b60f 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/Controller.java +++ b/src/main/java/com/daniel/jsoneditor/controller/Controller.java @@ -14,67 +14,81 @@ public interface Controller { SettingsController getSettingsController(); - + McpController getMcpController(); - + /** * Gets the command manager for accessing command history * @return the command manager instance */ CommandManager getCommandManager(); - + /** * Undo the last action performed by the user. * If no action can be undone, this method does nothing. */ void undo(); - + /** * Redo the last undone action performed by the user. * If no action can be redone, this method does nothing. */ void redo(); - + void launchFinished(); - - void jsonAndSchemaSelected(File json, File schema, File settings); - - void moveItemToIndex(JsonNodeWithPath newParent, JsonNodeWithPath item, int index); - - String resolveVariablesInJson(String json); - - void importAtNode(String path, String content); - - void exportNode(String path); - - void exportNodeWithDependencies(String path); - - void removeNodes(List paths); - - void addNewNodeToArray(String path); - - void createNewReferenceableObjectNodeWithKey(String pathOfReferenceableObject, String key); - - void sortArray(String path); - - void reorderArray(String path, List newIndices); - - void duplicateArrayNode(String path); - - void duplicateReferenceableObjectForLinking(String referencePath, String pathToDuplicate); - + + + + /** Loads the given JSON and schema files; {@code settings} may be {@code null} for no per-file settings. */ + void jsonAndSchemaSelected(final File json, final File schema, final File settings); + + /** Moves {@code item} to position {@code index} (0-based) within {@code newParent}. */ + void moveItemToIndex(final JsonNodeWithPath newParent, final JsonNodeWithPath item, final int index); + + /** Resolves {@code ${VAR}} placeholders in {@code json} by prompting the user for values; returns the result. */ + String resolveVariablesInJson(final String json); + + /** Merges {@code content} (a JSON string) into the node at {@code path}. */ + void importAtNode(final String path, final String content); + + /** Exports only the node at {@code path} to a file (no dependencies). */ + void exportNode(final String path); + + /** Exports the node at {@code path} together with all nodes it depends on. */ + void exportNodeWithDependencies(final String path); + + /** Removes all nodes at the given {@code paths} as a single undoable batch operation. */ + void removeNodes(final List paths); + + void addNewNodeToArray(final String path); + + /** Creates a new referenceable object under {@code pathOfReferenceableObject} using {@code key} as its identifier. */ + void createNewReferenceableObjectNodeWithKey(final String pathOfReferenceableObject, final String key); + + void sortArray(final String path); + + /** Reorders the array at {@code path} according to {@code newIndices}, which must be a permutation of [0, size). */ + void reorderArray(final String path, final List newIndices); + + void duplicateArrayNode(final String path); + + /** Duplicates the referenceable object at {@code pathToDuplicate} and links the copy to {@code referencePath}. */ + void duplicateReferenceableObjectForLinking(final String referencePath, final String pathToDuplicate); + void saveToFile(); - + void refreshFromDisk(); - - String searchForNode(String path, String value); - + + /** Searches at/under {@code path} for a node matching {@code value}; returns the matching path or {@code null}. */ + String searchForNode(final String path, final String value); + void openNewJson(); - + void generateJson(); - - void setValueAtPath(String path, Object value); + + /** Sets the value at {@code path}; {@code value} may be a {@link String}, {@link Number}, {@link Boolean}, or {@code null}. */ + void setValueAtPath(final String path, final Object value); /** * Sets a complete JSON node at the given path. @@ -83,16 +97,15 @@ public interface Controller * @param path The path to the node * @param node The JsonNode to set */ - void overrideNodeAtPath(String path, JsonNode node); - - /** - * copy the node at the path to the clipboard - */ - void copyToClipboard(String path); - - void pasteFromClipboardReplacingChild(String pathToInsert); + void overrideNodeAtPath(final String path, final JsonNode node); - void pasteFromClipboardIntoParent(String parentPath); + /** Copies the JSON node at {@code path} to the system clipboard. */ + void copyToClipboard(final String path); + /** Pastes the clipboard JSON, replacing the existing child node at {@code pathToInsert}. */ + void pasteFromClipboardReplacingChild(final String pathToInsert); + + /** Pastes the clipboard JSON as a new element appended to the array at {@code parentPath}. */ + void pasteFromClipboardIntoParent(final String parentPath); /** * Calculates differences between the JSON currently in the editor and the JSON saved on disk. diff --git a/src/main/java/com/daniel/jsoneditor/controller/FileOpenCoordinator.java b/src/main/java/com/daniel/jsoneditor/controller/FileOpenCoordinator.java new file mode 100644 index 00000000..751febfd --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/controller/FileOpenCoordinator.java @@ -0,0 +1,36 @@ +package com.daniel.jsoneditor.controller; + +import java.io.File; +import com.daniel.jsoneditor.util.CanonicalPaths; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Single entry point for opening a JSON+schema file in a GUI window. + * Routes to an existing window if the file is already open, otherwise delegates to + * {@link AppService#openFileInNewWindowDirect(File, File)} to create a new window. + */ +public final class FileOpenCoordinator +{ + private static final Logger logger = LoggerFactory.getLogger(FileOpenCoordinator.class); + + private final WindowRegistry registry; + + private final AppService appService; + + public FileOpenCoordinator(final WindowRegistry registry, final AppService appService) + { + this.registry = registry; + this.appService = appService; + } + + public void open(final File jsonFile, final File schemaFile) + { + final String canonicalPath = CanonicalPaths.canonicalize(jsonFile); + + registry.findByPath(canonicalPath).ifPresentOrElse( + AppWindow::focus, + () -> appService.openFileInNewWindowDirect(jsonFile, schemaFile)); + } +} diff --git a/src/main/java/com/daniel/jsoneditor/controller/WindowRegistry.java b/src/main/java/com/daniel/jsoneditor/controller/WindowRegistry.java new file mode 100644 index 00000000..d400f826 --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/controller/WindowRegistry.java @@ -0,0 +1,44 @@ +package com.daniel.jsoneditor.controller; + +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Tracks which {@link AppWindow} is currently displaying which file (by canonical path). + * Used by {@link FileOpenCoordinator} to prevent duplicate windows for the same file. + */ +public final class WindowRegistry +{ + private static final Logger logger = LoggerFactory.getLogger(WindowRegistry.class); + + private final Map windowsByCanonicalPath = new ConcurrentHashMap<>(); + + // synchronized: remove-old + insert-new must be atomic + public synchronized void register(final String canonicalPath, final AppWindow window) + { + windowsByCanonicalPath.values().removeIf(w -> w == window); + windowsByCanonicalPath.put(canonicalPath, window); + } + + public synchronized Optional findByPath(final String canonicalPath) + { + return Optional.ofNullable(windowsByCanonicalPath.get(canonicalPath)); + } + + public synchronized void unregisterWindow(final AppWindow window) + { + final boolean removed = windowsByCanonicalPath.values().removeIf(w -> w == window); + if (removed) + { + logger.debug("Unregistered window from registry: {}", window); + } + else + { + logger.debug("unregisterWindow called but window was not found in registry: {}", window); + } + } +} diff --git a/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java b/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java index f23266a8..89967c60 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java +++ b/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java @@ -1,15 +1,17 @@ package com.daniel.jsoneditor.controller.impl; import java.io.File; -import java.io.IOException; +import com.daniel.jsoneditor.util.CanonicalPaths; import java.util.ArrayList; import java.util.List; +import java.util.Optional; import java.util.Map; import java.util.Set; import com.daniel.jsoneditor.controller.AppService; import com.daniel.jsoneditor.controller.AppWindow; import com.daniel.jsoneditor.controller.Controller; +import com.daniel.jsoneditor.controller.WindowRegistry; import com.daniel.jsoneditor.controller.impl.commands.CommandManager; import com.daniel.jsoneditor.controller.impl.commands.CommandManagerImpl; import com.daniel.jsoneditor.controller.impl.json.JsonFileReaderAndWriter; @@ -31,6 +33,8 @@ import com.daniel.jsoneditor.model.observe.Observer; import com.daniel.jsoneditor.model.observe.Subject; import com.daniel.jsoneditor.model.sessions.FileSessionManager; +import com.daniel.jsoneditor.model.sessions.EditorSession; +import com.daniel.jsoneditor.model.sessions.AttachResult; import com.daniel.jsoneditor.model.settings.Settings; import com.fasterxml.jackson.databind.node.ObjectNode; import com.daniel.jsoneditor.model.statemachine.impl.Event; @@ -57,9 +61,9 @@ public class ControllerImpl implements Controller, Observer { private static final Logger logger = LoggerFactory.getLogger(ControllerImpl.class); - private final WritableModel model; + private WritableModel model; - private final ReadableModel readableModel; + private ReadableModel readableModel; private final View view; @@ -67,9 +71,9 @@ public class ControllerImpl implements Controller, Observer private final SettingsController settingsController; - private final CommandManager commandManager; + private CommandManager commandManager; - private final CommandFactory commandFactory; + private CommandFactory commandFactory; private final McpController mcpController; @@ -79,9 +83,11 @@ public class ControllerImpl implements Controller, Observer private String guiSessionId; + private AppWindow appWindow; + private boolean updateCheckDone; - public ControllerImpl(final WritableModel model, final ReadableModel readableModel, final Stage stage, final AppService appService) + ControllerImpl(final WritableModel model, final ReadableModel readableModel, final Stage stage, final AppService appService) { this.appService = appService; this.settingsController = appService.getSettingsController(); @@ -100,14 +106,79 @@ public ControllerImpl(final WritableModel model, final ReadableModel readableMod } /** - * Updates the window title with given unsaved changes count. - * This method is called by the CommandManager callback. + * Constructs a controller with a model that is already populated from disk. + * Skips the file-picker phase and goes straight to the editor scene. + * Used by the bootstrap flow when {@link com.daniel.jsoneditor.model.sessions.FileSessionManager#attachSession} + * returns an already-loaded model (either freshly loaded or shared from another session). + * + *

After this constructor returns, the caller must invoke {@link #setAppWindow(AppWindow)} then + * {@link #registerInWindowRegistry(String)} to complete window dedup registration.

+ * + * @param writableModel a fully loaded model implementing {@link WritableModel} + * @param readableModel the same model viewed as {@link ReadableModel} + * @param stage the JavaFX stage to render into + * @param appService the shared application service + * @param jsonFile the JSON file backing the model (used for GUI session registration) + * @param schemaFile the schema file + * @param sessionId the session ID returned by + * {@link com.daniel.jsoneditor.model.sessions.FileSessionManager#attachSession}; + * stored directly to avoid creating a duplicate GUI session */ + public ControllerImpl(final WritableModel writableModel, final ReadableModel readableModel, final Stage stage, + final AppService appService, final File jsonFile, final File schemaFile, final String sessionId) + { + this(writableModel, readableModel, stage, appService); + // The base constructor registered this as an observer and triggered update(). + // Since the model's latest event is MAIN_EDITOR (set by jsonAndSchemaSuccessfullyValidated), + // ViewImpl.update() sees MAIN_EDITOR and calls showMainEditor() automatically — no explicit + // event firing needed. The editor scene is already showing after the delegating call above. + // + // Use the session ID from attachSession directly — do NOT call refreshGuiSession here. + // refreshGuiSession would create a second GUI session (causing a refcount/session leak). + this.guiSessionId = sessionId; + } + private void updateWindowTitle(final int unsavedChangesCount) { view.updateWindowTitle(unsavedChangesCount); } + /** Must be called before {@link #registerInWindowRegistry()}. */ + public void setAppWindow(final AppWindow window) + { + this.appWindow = window; + } + + // Replaces GUI session registration; idempotent — safe when guiSessionId is null. + private void refreshGuiSession(final File jsonFile, final File schemaFile) + { + if (guiSessionId != null) + { + fileSessionManager.unregisterGuiSession(guiSessionId); + } + guiSessionId = fileSessionManager.registerGuiSession(readableModel, jsonFile, schemaFile); + } + + /** + * Registers this controller's window in the {@link com.daniel.jsoneditor.controller.AppService} + * window registry so that subsequent "open same file" requests focus this window instead of + * opening a duplicate. Must be called AFTER {@link #setAppWindow(AppWindow)}. + * + * @param canonicalPath the canonical path of the JSON file + * (use {@link com.daniel.jsoneditor.util.CanonicalPaths#canonicalize(java.io.File)}) + */ + public void registerInWindowRegistry(final String canonicalPath) + { + if (appWindow != null) + { + appService.getWindowRegistry().register(canonicalPath, appWindow); + } + else + { + logger.warn("registerInWindowRegistry called before setAppWindow — window not registered for {}", canonicalPath); + } + } + @Override public SettingsController getSettingsController() { @@ -185,39 +256,95 @@ private static String buildUpdateAvailableMessage(final String latestVersion) } @Override - public void jsonAndSchemaSelected(File jsonFile, File schemaFile, File settingsFile) + public void jsonAndSchemaSelected(final File jsonFile, final File schemaFile, final File settingsFile) { if (jsonFile != null && schemaFile != null) { - // grab Json from files and validate - JsonFileReaderAndWriter reader = new JsonFileReaderAndWriterImpl(); - JsonNode json = reader.getJsonFromFile(jsonFile); - JsonSchema schema = reader.getSchemaFromFileResolvingRefs(schemaFile); - handleJsonValidation(json, schema, () -> { - // settings file is optional - if (settingsFile != null) - { - Settings settingsFromFile = reader.getJsonFromFile(settingsFile, Settings.class, true); - if (settingsFromFile != null) - { - model.setSettings(settingsFromFile); - } - } - model.jsonAndSchemaSuccessfullyValidated(jsonFile, schemaFile, json, schema); - appService.getRecentFilesManager().addRecentFile(jsonFile, schemaFile); - if (guiSessionId != null) + final String canonicalPath = CanonicalPaths.canonicalize(jsonFile); + + // Dedup: if another window already shows this file, focus it and close this empty window + final WindowRegistry registry = appService.getWindowRegistry(); + final Optional existing = registry.findByPath(canonicalPath); + if (existing.isPresent()) + { + existing.get().focus(); + if (appWindow != null) { - fileSessionManager.unregisterGuiSession(guiSessionId); + appWindow.getStage().close(); } - guiSessionId = fileSessionManager.registerGuiSession(readableModel, jsonFile, schemaFile); - }); + return; + } + loadJsonAndSchema(jsonFile, schemaFile, settingsFile, canonicalPath); } else { view.selectJsonAndSchema(); } + } + + private void loadJsonAndSchema(final File jsonFile, final File schemaFile, final File settingsFile, + final String canonicalPath) + { + final JsonFileReaderAndWriter reader = new JsonFileReaderAndWriterImpl(); + final JsonNode json = reader.getJsonFromFile(jsonFile); + final JsonSchema schema = reader.getSchemaFromFileResolvingRefs(schemaFile); + final WindowRegistry registry = appService.getWindowRegistry(); + handleJsonValidation(json, schema, () -> { + // Detach from the old SharedFile so any concurrent MCP session retains its own + // isolated model instance and is not corrupted by the new file's data. + if (guiSessionId != null) + { + fileSessionManager.detachSession(guiSessionId); + } + guiSessionId = null; + // Attach a fresh (or shared) session for the new file. + final AttachResult attachResult = fileSessionManager.attachSession( + jsonFile.getAbsolutePath(), schemaFile.getAbsolutePath(), true); + if (!attachResult.success()) + { + logger.error("Cannot open new session for {}: {}", jsonFile.getAbsolutePath(), attachResult.error()); + view.cantValidateJson(); + // guiSessionId stays null; shutdown() handles that case safely + return; + } + + // Swap model references — the old model now belongs exclusively to any remaining MCP sessions. + guiSessionId = attachResult.sessionId(); + final EditorSession newSession = fileSessionManager.getSession(guiSessionId); + final ReadableModel sessionModel = newSession.model(); + if (!(sessionModel instanceof WritableModel writableModel)) + { + logger.error("New session model does not implement WritableModel: {}", sessionModel.getClass()); + view.cantValidateJson(); + return; + } + model = writableModel; + readableModel = sessionModel; + commandManager = new CommandManagerImpl(model); + commandManager.setUnsavedChangesCallback(this::updateWindowTitle); + commandFactory = readableModel.getCommandFactory(); + + if (settingsFile != null) + { + final Settings settingsFromFile = reader.getJsonFromFile(settingsFile, Settings.class, true); + if (settingsFromFile != null) + { + model.setSettings(settingsFromFile); + } + } + + // Re-wire the view to observe the new model, then fire MAIN_EDITOR to refresh the editor scene. + view.reloadForNewModel(readableModel); + model.jsonAndSchemaSuccessfullyValidated(jsonFile, schemaFile, json, schema); + appService.getRecentFilesManager().addRecentFile(jsonFile, schemaFile); + if (appWindow != null) + { + registry.unregisterWindow(appWindow); + registry.register(canonicalPath, appWindow); + } + }); } @Override @@ -292,7 +419,6 @@ public void importAtNode(String path, String content) @Override public void exportNode(String path) { - // exporting a node does not require writing to the model, hence we only need the controller and the readable model JsonNodeWithPath nodeWithPath = readableModel.getNodeForPath(path); if (nodeWithPath != null) { @@ -418,7 +544,7 @@ public void saveToFile() final JsonFileReaderAndWriter jsonWriter = new JsonFileReaderAndWriterImpl(); jsonWriter.writeJsonToFile(readableModel.getRootJson(), readableModel.getCurrentJSONFile()); - commandManager.markAsSaved(); // Mark current state as saved + commandManager.markAsSaved(); model.sendEvent(new Event(EventEnum.SAVING_SUCCESSFUL)); } @@ -428,7 +554,7 @@ public void refreshFromDisk() JsonFileReaderAndWriter reader = new JsonFileReaderAndWriterImpl(); JsonNode json = reader.getJsonFromFile(readableModel.getCurrentJSONFile()); handleJsonValidation(json, readableModel.getRootSchema(), () -> { - commandManager.clearHistory(); // Clear undo/redo stacks before reset + commandManager.clearHistory(); model.resetRootNode(json); }); } @@ -473,8 +599,7 @@ public void setValueAtPath(String path, Object value) final String parentPath = PathHelper.getParentPath(path); final String propertyName = PathHelper.getLastPathSegment(path); - // Validate by building a candidate parent object with the change applied, then checking it against the parent schema. - // This way we check for both correct format and correct structure (required properties etc) + // Validate candidate parent against schema before applying the change. final JsonNodeWithPath parentNodeWithPath = readableModel.getNodeForPath(parentPath); if (parentNodeWithPath == null || !parentNodeWithPath.getNode().isObject()) { @@ -667,7 +792,11 @@ public void shutdown() logger.info("Shutting down editor window"); if (guiSessionId != null) { - fileSessionManager.unregisterGuiSession(guiSessionId); + fileSessionManager.detachSession(guiSessionId); + } + else + { + logger.debug("No GUI session to detach — bootstrap window closed without file selection"); } } } diff --git a/src/main/java/com/daniel/jsoneditor/model/impl/ModelFactory.java b/src/main/java/com/daniel/jsoneditor/model/impl/ModelFactory.java new file mode 100644 index 00000000..6b3915ba --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/model/impl/ModelFactory.java @@ -0,0 +1,21 @@ +package com.daniel.jsoneditor.model.impl; + +import com.daniel.jsoneditor.model.statemachine.impl.EventSenderImpl; + +/** Single construction point for {@link ModelImpl} instances. */ +public final class ModelFactory +{ + private ModelFactory() + { + // utility class + } + + /** + * Creates a fresh, empty {@link ModelImpl} ready to be populated via + * {@code jsonAndSchemaSuccessfullyValidated(...)}. + */ + public static ModelImpl createEmpty() + { + return new ModelImpl(new EventSenderImpl()); + } +} diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/OpenFileTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/OpenFileTool.java index 83bb2516..7e35e728 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/OpenFileTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/OpenFileTool.java @@ -1,7 +1,7 @@ package com.daniel.jsoneditor.model.mcp; +import com.daniel.jsoneditor.model.sessions.AttachResult; import com.daniel.jsoneditor.model.sessions.FileSessionManager; -import com.daniel.jsoneditor.model.sessions.OpenFileResult; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -62,14 +62,14 @@ public String execute(final JsonNode arguments, final JsonNode id) throws JsonPr final String jsonPath = arguments.path("json_path").asText(""); final String schemaPath = arguments.path("schema_path").asText(""); - final OpenFileResult openResult = sessionManager.openFile(jsonPath, schemaPath); - if (!openResult.success()) + final AttachResult attachResult = sessionManager.attachSession(jsonPath, schemaPath, false); + if (!attachResult.success()) { - return JsonEditorMcpServer.createErrorResponseStatic(id, JSONRPC_INVALID_PARAMS, openResult.error()); + return JsonEditorMcpServer.createErrorResponseStatic(id, JSONRPC_INVALID_PARAMS, attachResult.error()); } final ObjectNode result = OBJECT_MAPPER.createObjectNode(); - result.put("file_id", openResult.sessionId()); + result.put("file_id", attachResult.sessionId()); result.put("json_path", jsonPath); result.put("schema_path", schemaPath); diff --git a/src/main/java/com/daniel/jsoneditor/model/observe/Subject.java b/src/main/java/com/daniel/jsoneditor/model/observe/Subject.java index 7185c762..2173f00a 100644 --- a/src/main/java/com/daniel/jsoneditor/model/observe/Subject.java +++ b/src/main/java/com/daniel/jsoneditor/model/observe/Subject.java @@ -3,6 +3,8 @@ public interface Subject { void registerObserver(Observer newObserver); + + void removeObserver(Observer observer); void notifyObservers(); } diff --git a/src/main/java/com/daniel/jsoneditor/model/sessions/AttachResult.java b/src/main/java/com/daniel/jsoneditor/model/sessions/AttachResult.java new file mode 100644 index 00000000..4135ea6b --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/model/sessions/AttachResult.java @@ -0,0 +1,34 @@ +package com.daniel.jsoneditor.model.sessions; + +/** + * Result of a {@link FileSessionManager#attachSession} call. + * On success, {@link #sessionId()} is non-null and {@link #error()} is null. + * On failure, {@link #sessionId()} is null and {@link #error()} contains a human-readable reason. + */ +public record AttachResult(String sessionId, String error) +{ + /** Compact constructor: enforces exactly one of sessionId / error is non-null. */ + public AttachResult + { + if ((sessionId == null) == (error == null)) + { + throw new IllegalArgumentException( + "Exactly one of sessionId or error must be non-null; got sessionId=" + sessionId + ", error=" + error); + } + } + + public boolean success() + { + return sessionId != null; + } + + public static AttachResult ofSuccess(final String id) + { + return new AttachResult(id, null); + } + + public static AttachResult ofError(final String msg) + { + return new AttachResult(null, msg); + } +} diff --git a/src/main/java/com/daniel/jsoneditor/model/sessions/FileSessionManager.java b/src/main/java/com/daniel/jsoneditor/model/sessions/FileSessionManager.java index 263c1886..e75f5d14 100644 --- a/src/main/java/com/daniel/jsoneditor/model/sessions/FileSessionManager.java +++ b/src/main/java/com/daniel/jsoneditor/model/sessions/FileSessionManager.java @@ -2,20 +2,23 @@ import com.daniel.jsoneditor.controller.impl.json.impl.JsonFileReaderAndWriterImpl; import com.daniel.jsoneditor.model.ReadableModel; +import com.daniel.jsoneditor.model.impl.ModelFactory; import com.daniel.jsoneditor.model.impl.ModelImpl; import com.daniel.jsoneditor.model.json.schema.SchemaHelper; -import com.daniel.jsoneditor.model.statemachine.impl.EventSenderImpl; import com.fasterxml.jackson.databind.JsonNode; import com.networknt.schema.JsonSchema; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.File; +import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; /** * Manages multiple open file sessions. Used by both GUI and MCP server. @@ -25,74 +28,48 @@ public class FileSessionManager { private static final Logger logger = LoggerFactory.getLogger(FileSessionManager.class); + private static final String GUI_SESSION_PREFIX = "gui-"; + private final Map sessions = new ConcurrentHashMap<>(); /** - * Opens a JSON file with its schema and creates a new headless session. - * - * @param jsonPath absolute path to the JSON file - * @param schemaPath absolute path to the schema file - * @return result with sessionId on success, or error message on failure + * Tracks a file that may be shared across multiple sessions. + * {@code canonicalSchemaPath} is stored so schema-mismatch checks never need I/O inside a lock. */ - public OpenFileResult openFile(final String jsonPath, final String schemaPath) - { - final File jsonFile = new File(jsonPath); - final File schemaFile = new File(schemaPath); - - if (!jsonFile.exists()) - { - logger.error("JSON file does not exist: {}", jsonPath); - return new OpenFileResult(null, "JSON file does not exist: " + jsonPath); - } - if (!schemaFile.exists()) - { - logger.error("Schema file does not exist: {}", schemaPath); - return new OpenFileResult(null, "Schema file does not exist: " + schemaPath); - } + private record SharedFile( + String canonicalPath, + String canonicalSchemaPath, + ModelImpl model, + File jsonFile, + File schemaFile, + AtomicInteger refCount) {} - final JsonFileReaderAndWriterImpl reader = new JsonFileReaderAndWriterImpl(); - final JsonNode json; - final JsonSchema schema; - try - { - json = reader.getJsonFromFile(jsonFile); - schema = reader.getSchemaFromFileResolvingRefs(schemaFile); - } - catch (Exception e) + /** + * Typed result of a single attach attempt — replaces the raw {@code String[] errorHolder} side-channel. + */ + private record AttachAttempt(SharedFile shared, String error) + { + static AttachAttempt ofShared(final SharedFile shared) { - logger.error("Failed to parse JSON or schema files: {} / {}", jsonPath, schemaPath, e); - return new OpenFileResult(null, "Failed to parse files: " + e.getMessage()); + return new AttachAttempt(shared, null); } - if (json == null || schema == null) + static AttachAttempt ofError(final String error) { - logger.error("Failed to load JSON or schema from files: {} / {}", jsonPath, schemaPath); - return new OpenFileResult(null, "Failed to parse JSON or schema files: " + jsonPath + " / " + schemaPath); + return new AttachAttempt(null, error); } - final List validationErrors = SchemaHelper.validateJsonWithSchema(json, schema); - if (!validationErrors.isEmpty()) + boolean isSuccess() { - final String errorDetails = String.join(", ", validationErrors); - logger.error("JSON does not validate against schema: {} / {}", jsonPath, schemaPath); - return new OpenFileResult(null, "JSON does not validate against schema: " + errorDetails); + return error == null; } + } - final ModelImpl model = new ModelImpl(new EventSenderImpl()); - model.jsonAndSchemaSuccessfullyValidated(jsonFile, schemaFile, json, schema); - - EditorSession session; - String sessionId; - do - { - sessionId = generateUniqueId(""); - session = new EditorSession(sessionId, model, jsonFile, schemaFile, false); - } - while (sessions.putIfAbsent(sessionId, session) != null); + /** Map from canonical JSON file path to the shared file entry. */ + private final Map filesByPath = new ConcurrentHashMap<>(); - logger.info("Opened file session {} for {}", sessionId, jsonPath); - return new OpenFileResult(sessionId, null); - } + /** Map from session ID to canonical JSON file path (for reverse lookup). */ + private final Map sessionToCanonicalPath = new ConcurrentHashMap<>(); /** * Registers an existing GUI model as a session. Protected from MCP close. @@ -108,7 +85,7 @@ public String registerGuiSession(final ReadableModel model, final File jsonFile, String sessionId; do { - sessionId = generateUniqueId("gui-"); + sessionId = generateUniqueId(GUI_SESSION_PREFIX); session = new EditorSession(sessionId, model, jsonFile, schemaFile, true); } while (sessions.putIfAbsent(sessionId, session) != null); @@ -118,24 +95,28 @@ public String registerGuiSession(final ReadableModel model, final File jsonFile, /** * Unregisters a GUI session (called when GUI closes a file). + * Delegates to {@link #detachSession(String)} to ensure the shared-file ref count is + * decremented and the session is removed from {@code sessionToCanonicalPath}. * * @param sessionId the session to unregister */ public void unregisterGuiSession(final String sessionId) { - sessions.computeIfPresent(sessionId, (final String key, final EditorSession session) -> + final EditorSession session = sessions.get(sessionId); + if (session != null && session.guiOwned()) { - if (session.guiOwned()) - { - logger.info("Unregistered GUI session {}", sessionId); - return null; // removes the entry - } - return session; - }); + logger.info("Unregistered GUI session {}", sessionId); + detachSession(sessionId); + } + else if (session != null) + { + logger.warn("unregisterGuiSession called with non-GUI session id {}", sessionId); + } } /** * Closes a headless session. Refuses to close GUI-owned sessions. + * Also decrements the shared-file ref count via {@link #decrementRefCount(String)}. * * @param sessionId the session to close * @return {@link CloseFileResult#CLOSED} if closed, {@link CloseFileResult#NOT_FOUND} if not found, @@ -156,6 +137,10 @@ public CloseFileResult closeFile(final String sessionId) result[0] = CloseFileResult.CLOSED; return null; // removes the entry }); + if (result[0] == CloseFileResult.CLOSED) + { + decrementRefCount(sessionId); + } return result[0]; } @@ -178,6 +163,232 @@ public List listSessions() return new ArrayList<>(sessions.values()); } + + + /** + * Attaches a new session to the given file path. Deduplication-aware: if the path is already + * open, the existing {@link ModelImpl} is reused. Concurrent attaches to the same new path are + * atomic — only one model is created. + * + * @param jsonPath absolute path to the JSON file + * @param schemaPath absolute path to the schema file + * @param guiOwned true if this session is owned by a GUI window + * @return {@link AttachResult} with sessionId on success, or error on failure + */ + public AttachResult attachSession(final String jsonPath, final String schemaPath, final boolean guiOwned) + { + final File jsonFile = new File(jsonPath); + final File schemaFile = new File(schemaPath); + + if (!jsonFile.exists()) + { + return AttachResult.ofError("JSON file does not exist: " + jsonPath); + } + if (!schemaFile.exists()) + { + return AttachResult.ofError("Schema file does not exist: " + schemaPath); + } + + // Pre-canonicalize both paths before any map operations — no I/O inside locks + final String canonicalJson; + final String canonicalSchema; + try + { + canonicalJson = jsonFile.getCanonicalPath(); + canonicalSchema = schemaFile.getCanonicalPath(); + } + catch (final IOException e) + { + return AttachResult.ofError("Cannot resolve canonical path: " + e.getMessage()); + } + + final AttachAttempt attempt = getOrCreateSharedFile( + jsonFile, schemaFile, canonicalJson, canonicalSchema, jsonPath, schemaPath); + if (!attempt.isSuccess()) + { + return AttachResult.ofError(attempt.error()); + } + final SharedFile sharedFile = attempt.shared(); + + final String prefix = guiOwned ? GUI_SESSION_PREFIX : ""; + EditorSession session; + String sessionId; + // Insert canonical mapping BEFORE session is visible in `sessions` to close the + // TOCTOU gap. On the rare event of a sessionId collision, clean up the orphan mapping and retry. + do + { + sessionId = generateUniqueId(prefix); + session = new EditorSession(sessionId, sharedFile.model(), sharedFile.jsonFile(), sharedFile.schemaFile(), guiOwned); + sessionToCanonicalPath.put(sessionId, canonicalJson); + if (sessions.putIfAbsent(sessionId, session) == null) + { + break; + } + sessionToCanonicalPath.remove(sessionId); // clean up orphan mapping on the rare ID collision + } + while (true); + + logger.info("Attached session {} to path {} (refCount={})", sessionId, canonicalJson, sharedFile.refCount().get()); + return AttachResult.ofSuccess(sessionId); + } + + /** + * Retrieves the existing {@link SharedFile} for the given canonical JSON path (incrementing its + * ref-count), or builds a brand-new one. All blocking I/O happens BEFORE any {@code compute()} + * call so no {@link ConcurrentHashMap} bucket lock is held during disk access. + * + *

Dedup correctness: concurrent slow-path races are resolved inside the final {@code compute()} + * call — the thread that loses the race discards its freshly-built model and increments the + * winner's ref-count instead.

+ */ + private AttachAttempt getOrCreateSharedFile( + final File jsonFile, + final File schemaFile, + final String canonicalJson, + final String canonicalSchema, + final String jsonPath, + final String schemaPath) + { + // ── Fast path: peek without holding a lock ──────────────────────────────── + final SharedFile peeked = filesByPath.get(canonicalJson); + if (peeked != null) + { + afterFastPathPeek(canonicalJson); + // Increment refCount inside compute() so it is atomic with concurrent decrements + final AttachAttempt[] fastResult = {null}; + filesByPath.compute(canonicalJson, (final String key, final SharedFile cur) -> + { + if (cur == null) + { + return null; // evicted between get() and compute(); caller falls through to slow path + } + // Re-verify schema: another thread may have evicted and reinstalled with a different schema + if (!cur.canonicalSchemaPath().equals(canonicalSchema)) + { + fastResult[0] = AttachAttempt.ofError("Schema mismatch: path " + canonicalJson + + " is already open with schema " + cur.canonicalSchemaPath() + + " but requested " + canonicalSchema); + return cur; // leave the entry unchanged; do not increment + } + cur.refCount().incrementAndGet(); + fastResult[0] = AttachAttempt.ofShared(cur); + return cur; + }); + if (fastResult[0] != null) + { + return fastResult[0]; + } + // Entry was evicted between the peek and the compute; fall through to slow path + } + + // ── Slow path: build model entirely outside any lock ───────────────────── + final JsonFileReaderAndWriterImpl reader = new JsonFileReaderAndWriterImpl(); + final JsonNode json; + final JsonSchema schema; + try + { + json = reader.getJsonFromFile(jsonFile); + schema = reader.getSchemaFromFileResolvingRefs(schemaFile); + } + catch (final Exception e) + { + return AttachAttempt.ofError("Failed to parse files: " + e.getMessage()); + } + if (json == null || schema == null) + { + return AttachAttempt.ofError("Failed to parse JSON or schema files: " + jsonPath + " / " + schemaPath); + } + final List validationErrors = SchemaHelper.validateJsonWithSchema(json, schema); + if (!validationErrors.isEmpty()) + { + return AttachAttempt.ofError("JSON does not validate against schema: " + String.join(", ", validationErrors)); + } + final ModelImpl newModel = ModelFactory.createEmpty(); + newModel.jsonAndSchemaSuccessfullyValidated(jsonFile, schemaFile, json, schema); + final SharedFile newShared = new SharedFile( + canonicalJson, canonicalSchema, newModel, jsonFile, schemaFile, new AtomicInteger(1)); + + // Atomically install: if another thread won the race, use theirs and increment its refCount + final AttachAttempt[] resultHolder = {null}; + filesByPath.compute(canonicalJson, (final String key, final SharedFile cur) -> + { + if (cur != null) + { + // Another thread installed first — verify schema compatibility then increment + if (!cur.canonicalSchemaPath().equals(canonicalSchema)) + { + resultHolder[0] = AttachAttempt.ofError("Schema mismatch: path " + canonicalJson + + " is already open with schema " + cur.canonicalSchemaPath() + + " but requested " + canonicalSchema); + return cur; + } + cur.refCount().incrementAndGet(); + resultHolder[0] = AttachAttempt.ofShared(cur); + return cur; + } + resultHolder[0] = AttachAttempt.ofShared(newShared); + return newShared; + }); + return resultHolder[0]; + } + + /** + * Detaches a session created via {@link #attachSession}. Decrements the shared-file ref count; + * when the last session for a path is detached the shared model is removed from the registry. + * + * @param sessionId the session ID returned by {@link #attachSession} + */ + public void detachSession(final String sessionId) + { + sessions.remove(sessionId); + decrementRefCount(sessionId); + logger.info("Detached session {}", sessionId); + } + + /** + * Closes all headless (non-GUI-owned) sessions. + * Called during application shutdown to drain in-flight MCP sessions before the MCP server stops. + * Collect IDs before iterating to avoid {@link java.util.ConcurrentModificationException}. + */ + public void closeAllHeadlessSessions() + { + final List headlessIds = sessions.entrySet().stream() + .filter(e -> !e.getValue().guiOwned()) + .map(Map.Entry::getKey) + .toList(); + for (final String id : headlessIds) + { + detachSession(id); + } + } + + /** + * Removes this session from {@code sessionToCanonicalPath} and decrements the ref count in + * {@code filesByPath}. When the count reaches zero the shared model is evicted from the map. + * Called by both {@link #closeFile(String)} and {@link #detachSession(String)}. + */ + private void decrementRefCount(final String sessionId) + { + final String canonical = sessionToCanonicalPath.remove(sessionId); + if (canonical != null) + { + filesByPath.computeIfPresent(canonical, (final String key, final SharedFile sharedFile) -> + { + final int remaining = sharedFile.refCount().decrementAndGet(); + if (remaining <= 0) + { + logger.info("Removed shared file for path {} (last session detached)", key); + return null; // removes the entry from the map + } + return sharedFile; + }); + } + } + + protected void afterFastPathPeek(final String canonicalJson) + { + } + private String generateUniqueId(final String prefix) { return prefix + UUID.randomUUID().toString().substring(0, 8); diff --git a/src/main/java/com/daniel/jsoneditor/model/sessions/OpenFileResult.java b/src/main/java/com/daniel/jsoneditor/model/sessions/OpenFileResult.java deleted file mode 100644 index 919a77b3..00000000 --- a/src/main/java/com/daniel/jsoneditor/model/sessions/OpenFileResult.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.daniel.jsoneditor.model.sessions; - - -/** - * Result of a {@link FileSessionManager#openFile} call. - * On success, {@link #sessionId()} is non-null and {@link #error()} is null. - * On failure, {@link #sessionId()} is null and {@link #error()} contains a human-readable reason. - */ -public record OpenFileResult(String sessionId, String error) -{ - /** @return true when the file was opened successfully */ - public boolean success() - { - return sessionId != null; - } -} diff --git a/src/main/java/com/daniel/jsoneditor/model/statemachine/impl/EventSenderImpl.java b/src/main/java/com/daniel/jsoneditor/model/statemachine/impl/EventSenderImpl.java index afb42aee..703a97dc 100644 --- a/src/main/java/com/daniel/jsoneditor/model/statemachine/impl/EventSenderImpl.java +++ b/src/main/java/com/daniel/jsoneditor/model/statemachine/impl/EventSenderImpl.java @@ -26,6 +26,12 @@ public void registerObserver(Observer newObserver) newObserver.update(); } + @Override + public void removeObserver(final Observer observer) + { + this.observers.remove(observer); + } + @Override public void notifyObservers() { diff --git a/src/main/java/com/daniel/jsoneditor/util/CanonicalPaths.java b/src/main/java/com/daniel/jsoneditor/util/CanonicalPaths.java new file mode 100644 index 00000000..6760c40f --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/util/CanonicalPaths.java @@ -0,0 +1,41 @@ +package com.daniel.jsoneditor.util; + +import java.io.File; +import java.io.IOException; +import java.util.Objects; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Utility for safe path canonicalization — falls back to the absolute path when + * {@link File#getCanonicalPath()} fails, logging a warning instead of throwing. + */ +public final class CanonicalPaths +{ + private static final Logger logger = LoggerFactory.getLogger(CanonicalPaths.class); + + private CanonicalPaths() {} + + /** + * Returns the canonical path for {@code file}, falling back to the absolute path + * when canonicalization fails (e.g. file does not exist yet, IO error). + * Logs a warning on fallback so callers do not need to. + * + * @param file the file to canonicalize, must not be {@code null} + * @return canonical path, or absolute path as fallback + */ + public static String canonicalize(final File file) + { + Objects.requireNonNull(file, "file must not be null"); + try + { + return file.getCanonicalPath(); + } + catch (final IOException e) + { + logger.warn("Cannot canonicalize {}, falling back to absolute path", file, e); + return file.getAbsolutePath(); + } + } +} diff --git a/src/main/java/com/daniel/jsoneditor/view/View.java b/src/main/java/com/daniel/jsoneditor/view/View.java index 7802a881..3d6b455e 100644 --- a/src/main/java/com/daniel/jsoneditor/view/View.java +++ b/src/main/java/com/daniel/jsoneditor/view/View.java @@ -1,6 +1,7 @@ package com.daniel.jsoneditor.view; import com.daniel.jsoneditor.model.observe.Observer; +import com.daniel.jsoneditor.model.ReadableModel; import com.daniel.jsoneditor.view.impl.jfx.toast.Toasts; import javafx.scene.paint.Color; @@ -32,4 +33,13 @@ public interface View extends Observer * @param unsavedChangesCount number of unsaved changes */ void updateWindowTitle(int unsavedChangesCount); + + /** + * Swaps the model this view observes and re-wires all internal components to use the new model. + * Called when the controller loads a different file in the same window, replacing the old model + * with a fresh session-managed instance so that former MCP sessions on the old file are not affected. + * + * @param newModel the new model to observe + */ + void reloadForNewModel(ReadableModel newModel); } diff --git a/src/main/java/com/daniel/jsoneditor/view/impl/ViewImpl.java b/src/main/java/com/daniel/jsoneditor/view/impl/ViewImpl.java index 6d2f0ed2..f9b7b54b 100644 --- a/src/main/java/com/daniel/jsoneditor/view/impl/ViewImpl.java +++ b/src/main/java/com/daniel/jsoneditor/view/impl/ViewImpl.java @@ -23,7 +23,7 @@ public class ViewImpl implements View { private final List subjects; - private final ReadableModel model; + private ReadableModel model; private final Controller controller; @@ -97,6 +97,20 @@ public void observe(Subject subjectToObserve) subjectToObserve.registerObserver(this); subjects.add(subjectToObserve); } + + @Override + public void reloadForNewModel(final ReadableModel newModel) + { + for (final Subject subject : subjects) + { + subject.removeObserver(this); + } + subjects.clear(); + this.model = newModel; + uiHandler.swapModel(newModel); + newModel.getForObservation().registerObserver(this); + subjects.add(newModel.getForObservation()); + } @Override public void cantValidateJson() diff --git a/src/main/java/com/daniel/jsoneditor/view/impl/jfx/UIHandler.java b/src/main/java/com/daniel/jsoneditor/view/impl/jfx/UIHandler.java index 5ebf3675..9ecac0c1 100644 --- a/src/main/java/com/daniel/jsoneditor/view/impl/jfx/UIHandler.java +++ b/src/main/java/com/daniel/jsoneditor/view/impl/jfx/UIHandler.java @@ -1,6 +1,7 @@ package com.daniel.jsoneditor.view.impl.jfx; import com.daniel.jsoneditor.model.statemachine.impl.Event; +import com.daniel.jsoneditor.model.ReadableModel; import com.daniel.jsoneditor.view.impl.jfx.toast.ToastLike; import javafx.scene.paint.Color; @@ -47,4 +48,12 @@ public interface UIHandler */ void handleGitBlameLoaded(); + /** + * Updates the internal model reference to {@code newModel}. Called during a file swap + * so that subsequent scene creations (e.g., {@link #showMainEditor()}) use the new model. + * + * @param newModel the replacement model + */ + void swapModel(ReadableModel newModel); + } diff --git a/src/main/java/com/daniel/jsoneditor/view/impl/jfx/impl/UIHandlerImpl.java b/src/main/java/com/daniel/jsoneditor/view/impl/jfx/impl/UIHandlerImpl.java index dc930956..19818619 100644 --- a/src/main/java/com/daniel/jsoneditor/view/impl/jfx/impl/UIHandlerImpl.java +++ b/src/main/java/com/daniel/jsoneditor/view/impl/jfx/impl/UIHandlerImpl.java @@ -30,7 +30,7 @@ public class UIHandlerImpl implements UIHandler private final Stage stage; - private final ReadableModel model; + private ReadableModel model; private final ToastManager toastManager = new ToastManager(); @@ -47,11 +47,19 @@ public UIHandlerImpl(Controller controller, Stage stage, ReadableModel model) }); } + + @Override + public void swapModel(final ReadableModel newModel) + { + this.model = newModel; + } @Override public void showSelectJsonAndSchema() { - stage.setScene(new JSONSelectionScene(this, controller, model).getScene(stage)); + final JSONSelectionScene scene = new JSONSelectionScene(this, controller.getSettingsController(), model, + controller::jsonAndSchemaSelected); + stage.setScene(scene.getScene(stage)); stage.setWidth(700); stage.setHeight(300); stage.show(); @@ -61,7 +69,7 @@ public void showSelectJsonAndSchema() public void showMainEditor() { EditorDimensions dimensions = controller.getSettingsController().getEditorDimensions(); - stage.setMaximized(dimensions.isMaximized()); //start maximized if the editor was maximized last + stage.setMaximized(dimensions.isMaximized()); stage.setWidth(dimensions.getWidth()); stage.setHeight(dimensions.getHeight()); Rectangle2D screenBounds = Screen.getPrimary().getVisualBounds(); @@ -144,7 +152,6 @@ public void handleCommandApplied(Event event) { if (editorScene != null && event.getChanges() != null) { - // Process each model change granularly for (final ModelChange change : event.getChanges()) { handleModelChange(change, event); @@ -219,17 +226,13 @@ private void handleReplace(String path) private void handleMove(ModelChange change) { final String path = change.getPath(); - // Update navbar to reflect new order editorScene.getNavbar().handlePathMoved(path); - // Update any open editors showing the parent array editorScene.getEditorWindowManager().handlePathMoved(change); } private void handleSort(String path) { - // Update navbar to reflect new sort order editorScene.getNavbar().handlePathSorted(path); - // Update any open editors showing the sorted array editorScene.getEditorWindowManager().handlePathSorted(path); } diff --git a/src/main/java/com/daniel/jsoneditor/view/impl/jfx/impl/scenes/impl/JSONSelectionScene.java b/src/main/java/com/daniel/jsoneditor/view/impl/jfx/impl/scenes/impl/JSONSelectionScene.java index b78f832b..9560b46e 100644 --- a/src/main/java/com/daniel/jsoneditor/view/impl/jfx/impl/scenes/impl/JSONSelectionScene.java +++ b/src/main/java/com/daniel/jsoneditor/view/impl/jfx/impl/scenes/impl/JSONSelectionScene.java @@ -3,7 +3,6 @@ import java.io.File; import java.util.Optional; -import com.daniel.jsoneditor.controller.Controller; import com.daniel.jsoneditor.controller.settings.SettingsController; import com.daniel.jsoneditor.model.ReadableModel; import com.daniel.jsoneditor.view.impl.jfx.UIHandler; @@ -11,8 +10,6 @@ import javafx.geometry.Insets; import javafx.scene.Scene; import javafx.scene.control.*; -import javafx.scene.layout.GridPane; -import javafx.scene.layout.HBox; import javafx.scene.layout.VBox; import javafx.stage.FileChooser; import javafx.stage.Stage; @@ -20,29 +17,35 @@ public class JSONSelectionScene extends SceneHandlerImpl { + private final SettingsController settingsController; + + private final JsonSchemaSelectionListener listener; + private String selectedJsonPath; - + private String selectedSchemaPath; - + private String selectedSettingsPath; - + private File lastDirectory; - + private boolean remember; - - public JSONSelectionScene(UIHandler handler, Controller controller, ReadableModel model) + + public JSONSelectionScene(final UIHandler handler, final SettingsController settingsController, final ReadableModel model, + final JsonSchemaSelectionListener listener) { - super(handler, controller, model); + super(handler, null, model); + this.settingsController = settingsController; + this.listener = listener; } - + @Override public Scene getScene(Stage stage) { - SettingsController settingsController = controller.getSettingsController(); - boolean rememberFiles = settingsController.rememberPaths(); - String rememberedJsonPath = settingsController.getLastJsonPath(); - String rememberedSchemaPath = settingsController.getLastSchemaPath(); - String rememberedSettingsPath = settingsController.getLastSettingsPath(); + final boolean rememberFiles = settingsController.rememberPaths(); + final String rememberedJsonPath = settingsController.getLastJsonPath(); + final String rememberedSchemaPath = settingsController.getLastSchemaPath(); + final String rememberedSettingsPath = settingsController.getLastSettingsPath(); if (rememberFiles && rememberedJsonPath != null) { selectedJsonPath = rememberedJsonPath; @@ -55,20 +58,19 @@ public Scene getScene(Stage stage) { selectedSettingsPath = rememberedSettingsPath; } - boolean rememberedRememberSettings = settingsController.rememberPaths(); - - FileSelectionBox jsonBox = new FileSelectionBox("JSON to edit:", selectedJsonPath, stage, + final boolean rememberedRememberSettings = settingsController.rememberPaths(); + + final FileSelectionBox jsonBox = new FileSelectionBox("JSON to edit:", selectedJsonPath, stage, new FileChooser.ExtensionFilter("JSON Files", "*.json")); - FileSelectionBox schemaBox = new FileSelectionBox("Schema:", selectedSchemaPath, stage, + final FileSelectionBox schemaBox = new FileSelectionBox("Schema:", selectedSchemaPath, stage, new FileChooser.ExtensionFilter("JSON Files", "*.json")); - FileSelectionBox settingsBox = new FileSelectionBox("Settings:", selectedSettingsPath, stage, + final FileSelectionBox settingsBox = new FileSelectionBox("Settings:", selectedSettingsPath, stage, new FileChooser.ExtensionFilter("JSON Files", "*.json")); - - - CheckBox rememberCheckBox = new CheckBox("Remember"); + + final CheckBox rememberCheckBox = new CheckBox("Remember"); rememberCheckBox.setSelected(rememberedRememberSettings); - - Button okButton = new Button("OK"); + + final Button okButton = new Button("OK"); okButton.setOnAction(e -> { remember = rememberCheckBox.isSelected(); @@ -83,56 +85,55 @@ public Scene getScene(Stage stage) { continueToEditor(); } - + }); - - VBox root = new VBox(10, jsonBox, schemaBox, settingsBox, rememberCheckBox, okButton); + + final VBox root = new VBox(10, jsonBox, schemaBox, settingsBox, rememberCheckBox, okButton); root.setPadding(new Insets(10)); - - Scene scene = new Scene(root, 700, 300); + + final Scene scene = new Scene(root, 700, 300); scene.getStylesheets().add(getClass().getResource("/css/style_darkmode.css").toExternalForm()); return scene; } - + private void continueToEditor() { - controller.jsonAndSchemaSelected(new File(selectedJsonPath), new File(selectedSchemaPath), new File(selectedSettingsPath)); - controller.getSettingsController().setFileProperties(remember, selectedJsonPath, selectedSchemaPath, selectedSettingsPath); + final File schemaFile = selectedSchemaPath != null ? new File(selectedSchemaPath) : null; + final File settingsFile = selectedSettingsPath != null ? new File(selectedSettingsPath) : null; + listener.onFilesSelected(new File(selectedJsonPath), schemaFile, settingsFile); + settingsController.setFileProperties(remember, selectedJsonPath, selectedSchemaPath, selectedSettingsPath); } - - private void askToGenerateJson(Stage stage) { - // Create an alert dialog to ask for the JSON path - Alert alert = new Alert(Alert.AlertType.CONFIRMATION); + + private void askToGenerateJson(Stage stage) + { + final Alert alert = new Alert(Alert.AlertType.CONFIRMATION); alert.setTitle("JSON Path"); alert.setHeaderText("No JSON path entered"); alert.setContentText("Do you want to generate one?"); - ButtonType yesButton = new ButtonType("Yes", ButtonBar.ButtonData.YES); - ButtonType noButton = new ButtonType("No", ButtonBar.ButtonData.NO); + final ButtonType yesButton = new ButtonType("Yes", ButtonBar.ButtonData.YES); + final ButtonType noButton = new ButtonType("No", ButtonBar.ButtonData.NO); alert.getButtonTypes().setAll(yesButton, noButton); - - // Apply the common CSS to the alert dialog - DialogPane dialogPane = alert.getDialogPane(); + + final DialogPane dialogPane = alert.getDialogPane(); dialogPane.getStylesheets().add(getClass().getResource("/css/style_darkmode.css").toExternalForm()); - - Optional result = alert.showAndWait(); - - if (result.isPresent() && result.get() == yesButton) { - // Create the file chooser with a default JSON path - FileChooser fileChooser = new FileChooser(); + + final Optional result = alert.showAndWait(); + + if (result.isPresent() && result.get() == yesButton) + { + final FileChooser fileChooser = new FileChooser(); fileChooser.setTitle("Save As"); fileChooser.setInitialDirectory(lastDirectory); fileChooser.setInitialFileName("newfile.json"); fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("JSON Files", "*.json")); - - // Show the file chooser dialog - File selectedDirectory = fileChooser.showSaveDialog(stage); - - if (selectedDirectory != null) { - // Get the selected file name and directory - String fileName = fileChooser.getInitialFileName(); - File selectedFile = new File(selectedDirectory, fileName); - // generate a JSON and save it in the selected file - // TODO + + final File selectedDirectory = fileChooser.showSaveDialog(stage); + + if (selectedDirectory != null) + { + final String fileName = fileChooser.getInitialFileName(); + final File selectedFile = new File(selectedDirectory, fileName); + // TODO: implement save-generated-JSON-from-schema flow } } } diff --git a/src/main/java/com/daniel/jsoneditor/view/impl/jfx/impl/scenes/impl/JsonSchemaSelectionListener.java b/src/main/java/com/daniel/jsoneditor/view/impl/jfx/impl/scenes/impl/JsonSchemaSelectionListener.java new file mode 100644 index 00000000..271ac1cc --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/view/impl/jfx/impl/scenes/impl/JsonSchemaSelectionListener.java @@ -0,0 +1,20 @@ +package com.daniel.jsoneditor.view.impl.jfx.impl.scenes.impl; + +import java.io.File; + +/** + * Callback invoked by {@link JSONSelectionScene} when the user confirms a JSON + schema + * file selection. Decouples the picker scene from the controller. + */ +@FunctionalInterface +public interface JsonSchemaSelectionListener +{ + /** + * Called when the user clicks OK in the file-selection scene. + * + * @param jsonFile the JSON file to edit (may be a new empty file) + * @param schemaFile the JSON Schema file + * @param settingsFile the optional settings JSON file + */ + void onFilesSelected(File jsonFile, File schemaFile, File settingsFile); +} diff --git a/src/main/java/com/daniel/jsoneditor/view/impl/jfx/impl/scenes/impl/SceneHandlerImpl.java b/src/main/java/com/daniel/jsoneditor/view/impl/jfx/impl/scenes/impl/SceneHandlerImpl.java index 21f67de5..c4de7191 100644 --- a/src/main/java/com/daniel/jsoneditor/view/impl/jfx/impl/scenes/impl/SceneHandlerImpl.java +++ b/src/main/java/com/daniel/jsoneditor/view/impl/jfx/impl/scenes/impl/SceneHandlerImpl.java @@ -4,9 +4,11 @@ import com.daniel.jsoneditor.model.ReadableModel; import com.daniel.jsoneditor.view.impl.jfx.UIHandler; import com.daniel.jsoneditor.view.impl.jfx.impl.scenes.SceneHandler; +import org.jetbrains.annotations.Nullable; public abstract class SceneHandlerImpl implements SceneHandler { + @Nullable private final UIHandler handler; protected final Controller controller; @@ -21,6 +23,7 @@ public SceneHandlerImpl(UIHandler handler, Controller controller, ReadableModel } @Override + @Nullable public final UIHandler getHandlerForToasting() { return handler; diff --git a/src/test/java/com/daniel/jsoneditor/controller/AppServiceTest.java b/src/test/java/com/daniel/jsoneditor/controller/AppServiceTest.java new file mode 100644 index 00000000..4fded45a --- /dev/null +++ b/src/test/java/com/daniel/jsoneditor/controller/AppServiceTest.java @@ -0,0 +1,269 @@ +package com.daniel.jsoneditor.controller; + +import com.daniel.jsoneditor.model.sessions.AttachResult; +import javafx.stage.Stage; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.testfx.api.FxRobot; +import org.testfx.framework.junit5.ApplicationExtension; +import org.testfx.framework.junit5.Start; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.lang.reflect.Field; + +import com.daniel.jsoneditor.model.ReadableModel; +import com.daniel.jsoneditor.model.WritableModel; +import com.daniel.jsoneditor.model.sessions.EditorSession; +import com.daniel.jsoneditor.model.sessions.FileSessionManager; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.Mockito.*; + +/** + * Unit tests for {@link AppService#attachLoadedSession}. + */ +@ExtendWith(ApplicationExtension.class) +class AppServiceTest +{ + private static final String SIMPLE_JSON = "{\"name\":\"test\",\"value\":42}"; + + private static final String SIMPLE_SCHEMA = + "{\"$schema\":\"http://json-schema.org/draft-07/schema#\"," + + "\"type\":\"object\"," + + "\"properties\":{" + + "\"name\":{\"type\":\"string\"}," + + "\"value\":{\"type\":\"number\"}" + + "}}"; + + private AppService appService; + private Stage fxStage; + + @Start + void start(final Stage stage) + { + this.fxStage = stage; + } + + @BeforeEach + void setUp() + { + appService = new AppService(0); + } + + @AfterEach + void tearDown() + { + if (appService != null) + { + appService.shutdown(); + } + } + + /** + * Success path: valid JSON + schema files  AttachResult is successful, the session is + * registered in FileSessionManager, and {@link AppWindow#attachLoadedController} is called + * on the provided window. Must run on FX Application Thread because ControllerImpl sets a scene. + */ + @Test + void attachLoadedSession_success_returnsSuccess_andCallsWindowMethods( + @TempDir final Path tempDir, + final FxRobot robot) throws Exception + { + final Path jsonPath = tempDir.resolve("test.json"); + final Path schemaPath = tempDir.resolve("schema.json"); + Files.writeString(jsonPath, SIMPLE_JSON); + Files.writeString(schemaPath, SIMPLE_SCHEMA); + + final AppWindow window = mock(AppWindow.class); + final File jsonFile = jsonPath.toFile(); + final File schemaFile = schemaPath.toFile(); + + // ControllerImpl construction sets a JavaFX scene  must run on the FX Application Thread + final AttachResult[] holder = {null}; + robot.interact(() -> holder[0] = appService.attachLoadedSession(window, fxStage, jsonFile, schemaFile, null)); + final AttachResult result = holder[0]; + + assertTrue(result.success(), "attachLoadedSession must succeed for valid files"); + assertNotNull(result.sessionId(), "sessionId must be non-null on success"); + verify(window).attachLoadedController(any()); + assertNotNull(appService.getFileSessionManager().getSession(result.sessionId()), + "session must be registered in FileSessionManager after successful attach"); + } + + /** + * Failure path: null schema  error returned immediately, no controller constructed, window untouched. + */ + @Test + void attachLoadedSession_nullSchema_returnsError() + { + final AppWindow window = mock(AppWindow.class); + final File jsonFile = new File("/any/file.json"); + + final AttachResult result = appService.attachLoadedSession(window, fxStage, jsonFile, null, null); + + assertFalse(result.success(), "null schema must return an error result"); + assertNotNull(result.error(), "error message must be non-null"); + assertTrue(result.error().toLowerCase().contains("schema"), + "error must mention schema, got: " + result.error()); + verifyNoInteractions(window); + } + + /** + * Failure path: FileSessionManager rejects a second attach when a different schema is already + * registered for the same canonical JSON path. The FSM error is returned verbatim; the second + * window must not be mutated. + */ + @Test + void attachLoadedSession_schemaMismatch_returnsFsmError( + @TempDir final Path tempDir, + final FxRobot robot) throws Exception + { + final Path jsonPath = tempDir.resolve("test.json"); + final Path schemaAPath = tempDir.resolve("schemaA.json"); + final Path schemaBPath = tempDir.resolve("schemaB.json"); + Files.writeString(jsonPath, SIMPLE_JSON); + Files.writeString(schemaAPath, SIMPLE_SCHEMA); + Files.writeString(schemaBPath, + "{\"$schema\":\"http://json-schema.org/draft-07/schema#\"," + + "\"type\":\"object\"," + + "\"properties\":{\"name\":{\"type\":\"string\"}}}"); + + // First attach with schemaA  success path requires FX thread for ControllerImpl construction + final AppWindow windowA = mock(AppWindow.class); + final AttachResult[] firstHolder = {null}; + robot.interact(() -> firstHolder[0] = + appService.attachLoadedSession(windowA, fxStage, jsonPath.toFile(), schemaAPath.toFile(), null)); + assertTrue(firstHolder[0].success(), "precondition: first attach with schemaA must succeed"); + + // Second attach with schemaBPath for the same JSON  FSM rejects before ControllerImpl construction + final AppWindow windowB = mock(AppWindow.class); + final AttachResult resultB = appService.attachLoadedSession( + windowB, fxStage, jsonPath.toFile(), schemaBPath.toFile(), null); + + assertFalse(resultB.success(), "schema mismatch must return an error result"); + assertNotNull(resultB.error(), "error must be non-null on schema mismatch"); + assertTrue(resultB.error().toLowerCase().contains("schema"), + "FSM error must mention schema mismatch, got: " + resultB.error()); + verifyNoInteractions(windowB); + } + + @Test + void attachLoadedSession_postAttachFailures_alwaysDetachSession() throws Exception + { + { + final FileSessionManager fsm = mock(FileSessionManager.class); + when(fsm.attachSession(any(), any(), anyBoolean())) + .thenReturn(AttachResult.ofSuccess("ghost-session")); + when(fsm.getSession("ghost-session")) + .thenReturn(null); + + final Field field = AppService.class.getDeclaredField("fileSessionManager"); + field.setAccessible(true); + field.set(appService, fsm); + + final AppWindow window = mock(AppWindow.class); + final AttachResult result = appService.attachLoadedSession( + window, fxStage, new File("/fake/test.json"), new File("/fake/schema.json"), null); + + assertFalse(result.success()); + assertNotNull(result.error()); + verify(fsm).detachSession("ghost-session"); + verifyNoInteractions(window); + } + + { + final FileSessionManager fsm = mock(FileSessionManager.class); + when(fsm.attachSession(any(), any(), anyBoolean())) + .thenReturn(AttachResult.ofSuccess("leaked-session")); + when(fsm.getSession("leaked-session")) + .thenReturn(new EditorSession( + "leaked-session", + mock(ReadableModel.class), + new File("/fake.json"), + new File("/fake.schema"), + true)); + + final Field field = AppService.class.getDeclaredField("fileSessionManager"); + field.setAccessible(true); + field.set(appService, fsm); + + final AppWindow window = mock(AppWindow.class); + + assertThrows(IllegalStateException.class, () -> appService.attachLoadedSession( + window, null, new File("/fake/test.json"), new File("/fake/schema.json"), null)); + + verify(fsm).detachSession("leaked-session"); + } + } + + /** + * A generic RuntimeException (not ISE) thrown during post-attach wiring must still trigger + * detachSession so the session does not leak. + */ + @Test + void attachLoadedSession_genericRuntimeException_detachesSession(final FxRobot robot) throws Exception + { + final FileSessionManager fsm = mock(FileSessionManager.class); + final WritableModel writableModel = mock(WritableModel.class); + final EditorSession session = new EditorSession( + "npe-session", writableModel, new File("/fake.json"), new File("/fake.schema"), true); + when(fsm.attachSession(any(), any(), anyBoolean())).thenReturn(AttachResult.ofSuccess("npe-session")); + when(fsm.getSession("npe-session")).thenReturn(session); + + final Field field = AppService.class.getDeclaredField("fileSessionManager"); + field.setAccessible(true); + field.set(appService, fsm); + + final AppWindow window = mock(AppWindow.class); + doThrow(new NullPointerException("simulated post-attach failure")).when(window).attachLoadedController(any()); + + final RuntimeException[] caught = {null}; + robot.interact(() -> + { + try + { + appService.attachLoadedSession(window, fxStage, new File("/fake/test.json"), new File("/fake/schema.json"), null); + } + catch (final RuntimeException e) + { + caught[0] = e; + } + }); + + assertNotNull(caught[0], "A RuntimeException must have been thrown"); + verify(fsm).detachSession("npe-session"); + } + + /** + * When attachLoadedSession throws a RuntimeException, openFileInNewWindowDirect must remove + * the window from the list and not propagate the exception. + */ + @Test + void openFileInNewWindowDirect_exceptionDuringAttach_windowRemovedAndNoThrow(final FxRobot robot) throws Exception + { + // A ReadableModel mock (not WritableModel) causes ISE inside attachLoadedSession, + // which is rethrown; openFileInNewWindowDirect must catch it and remove the window. + final FileSessionManager fsm = mock(FileSessionManager.class); + when(fsm.attachSession(any(), any(), anyBoolean())).thenReturn(AttachResult.ofSuccess("zombie-session")); + when(fsm.getSession("zombie-session")).thenReturn( + new EditorSession("zombie-session", mock(ReadableModel.class), + new File("/fake.json"), new File("/fake.schema"), true)); + + final Field field = AppService.class.getDeclaredField("fileSessionManager"); + field.setAccessible(true); + field.set(appService, fsm); + + // Must not throw + robot.interact(() -> appService.openFileInNewWindowDirect( + new File("/fake/test.json"), new File("/fake/schema.json"))); + + assertEquals(0, appService.getWindowCount(), "Window must be removed from the list after exception in attachLoadedSession"); + } +} diff --git a/src/test/java/com/daniel/jsoneditor/controller/BootstrapControllerTest.java b/src/test/java/com/daniel/jsoneditor/controller/BootstrapControllerTest.java new file mode 100644 index 00000000..5d561fbc --- /dev/null +++ b/src/test/java/com/daniel/jsoneditor/controller/BootstrapControllerTest.java @@ -0,0 +1,117 @@ +package com.daniel.jsoneditor.controller; + +import com.daniel.jsoneditor.controller.impl.ControllerImpl; +import com.daniel.jsoneditor.model.sessions.AttachResult; +import javafx.application.Platform; +import javafx.stage.Stage; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import java.io.File; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * Unit tests for {@link BootstrapController} — the Phase 1 bootstrap controller + * that manages the file-picker scene before a session is attached. + */ +class BootstrapControllerTest +{ + /** + * Exercises both the success and failure branches of {@link BootstrapController#onFilesPicked}. + * Success: attachLoadedSession called with correct args; no alert scheduled; stage not closed. + * Failure: onFilesPicked does not throw; stage stays visible; error alert scheduled via Platform.runLater. + * ISE thrown: onFilesPicked does not propagate; stage stays open. + * Fresh controller instances are used per branch to avoid state accumulation. + */ + @Test + void onFilesPicked_handlesAllOutcomes() + { + final File jsonFile = new File("/data/file.json"); + final File schemaFile = new File("/data/schema.json"); + final File settingsFile = new File("/data/settings.json"); + + // === Success path: attachLoadedSession called with correct args; no alert, stage not closed === + try (final MockedStatic platformMock = mockStatic(Platform.class)) + { + final Stage stage = mock(Stage.class); + final AppService appService = mock(AppService.class); + final AppWindow appWindow = mock(AppWindow.class); + when(appService.attachLoadedSession(appWindow, stage, jsonFile, schemaFile, settingsFile)) + .thenReturn(AttachResult.ofSuccess("gui-abc123")); + + final BootstrapController bootstrap = new BootstrapController(stage, appService, appWindow); + bootstrap.onFilesPicked(jsonFile, schemaFile, settingsFile); + + verify(appService).attachLoadedSession(appWindow, stage, jsonFile, schemaFile, settingsFile); + // On success: no error alert scheduled via Platform.runLater, picker stage not closed + platformMock.verify(() -> Platform.runLater(any(Runnable.class)), never()); + verify(stage, never()).close(); + verify(stage, never()).hide(); + } + + // === Failure path: stage stays open (picker visible), error alert scheduled via Platform.runLater === + try (final MockedStatic platformMock = mockStatic(Platform.class)) + { + final Stage stage = mock(Stage.class); + final AppService appService = mock(AppService.class); + final AppWindow appWindow = mock(AppWindow.class); + when(appService.attachLoadedSession(any(), any(), any(), any(), any())) + .thenReturn(AttachResult.ofError("JSON file does not exist: /bad.json")); + + final BootstrapController bootstrap = new BootstrapController(stage, appService, appWindow); + assertDoesNotThrow( + () -> bootstrap.onFilesPicked(new File("/bad.json"), new File("/schema.json"), null), + "onFilesPicked must not throw on attach failure"); + + // Picker stays: stage is NOT closed or hidden + verify(stage, never()).close(); + verify(stage, never()).hide(); + + // Error alert was scheduled via Platform.runLater — not executed synchronously (no FX toolkit needed) + platformMock.verify(() -> Platform.runLater(any(Runnable.class))); + } + + // === Branch 3: attachLoadedSession throws ISE → no propagation, stage stays open === + try (final MockedStatic platformMock = mockStatic(Platform.class)) + { + final Stage stage = mock(Stage.class); + final AppService appService = mock(AppService.class); + final AppWindow appWindow = mock(AppWindow.class); + when(appService.attachLoadedSession(any(), any(), any(), any(), any())) + .thenThrow(new IllegalStateException("session detached: duplicate attach")); + + final BootstrapController bootstrap = new BootstrapController(stage, appService, appWindow); + assertDoesNotThrow( + () -> bootstrap.onFilesPicked(new File("/data/file.json"), new File("/data/schema.json"), null), + "onFilesPicked must not propagate IllegalStateException from attachLoadedSession"); + + verify(stage, never()).close(); + verify(stage, never()).hide(); + } + } + + /** + * Two-phase init: AppWindow starts with a null controller (picker phase), + * and transitions to a real controller after attachLoadedController is called. + */ + @Test + void appWindow_twoPhase_init_controllerTransition() + { + final AppService appService = mock(AppService.class); + final Stage stage = mock(Stage.class); + final AppWindow window = new AppWindow(appService, stage); + + assertNull(window.getController(), "controller must be null in Phase 1 (picker phase)"); + + final ControllerImpl loadedController = mock(ControllerImpl.class); + window.attachLoadedController(loadedController); + + assertSame(loadedController, window.getController(), + "controller must be the real one after attachLoadedController (Phase 2)"); + } + + +} diff --git a/src/test/java/com/daniel/jsoneditor/controller/FileOpenCoordinatorTest.java b/src/test/java/com/daniel/jsoneditor/controller/FileOpenCoordinatorTest.java new file mode 100644 index 00000000..d60b9e6e --- /dev/null +++ b/src/test/java/com/daniel/jsoneditor/controller/FileOpenCoordinatorTest.java @@ -0,0 +1,69 @@ +package com.daniel.jsoneditor.controller; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +class FileOpenCoordinatorTest +{ + @TempDir + Path tempDir; + + /** + * When a window is already showing the requested file, open() must focus that window + * and must NOT call AppService — the deduplication guard. + */ + @Test + void open_pathAlreadyInRegistry_focusesExistingWindowWithoutOpeningNew() throws Exception + { + final File jsonFile = tempDir.resolve("test.json").toFile(); + final File schemaFile = tempDir.resolve("schema.json").toFile(); + Files.writeString(jsonFile.toPath(), "{\"a\":1}"); + Files.writeString(schemaFile.toPath(), "{}"); + + // Register a mock window at the canonical path that FileOpenCoordinator will compute + final String canonicalPath = jsonFile.getCanonicalPath(); + final WindowRegistry registry = new WindowRegistry(); + final AppWindow mockWindow = mock(AppWindow.class); + registry.register(canonicalPath, mockWindow); + + final AppService mockAppService = mock(AppService.class); + final FileOpenCoordinator coordinator = new FileOpenCoordinator(registry, mockAppService); + + coordinator.open(jsonFile, schemaFile); + + // Existing window must be focused; AppService must not be touched + verify(mockWindow, times(1)).focus(); + verifyNoInteractions(mockAppService); + } + + /** + * When no window is currently showing the requested file, open() must delegate + * to AppService.openFileInNewWindowDirect to create a new window. + */ + @Test + void open_pathNotInRegistry_opensNewWindowViaAppService() throws Exception + { + final File jsonFile = tempDir.resolve("fresh.json").toFile(); + final File schemaFile = tempDir.resolve("schema.json").toFile(); + Files.writeString(jsonFile.toPath(), "{\"a\":1}"); + Files.writeString(schemaFile.toPath(), "{}"); + + final WindowRegistry registry = new WindowRegistry(); // empty — no window registered + final AppService mockAppService = mock(AppService.class); + final FileOpenCoordinator coordinator = new FileOpenCoordinator(registry, mockAppService); + + coordinator.open(jsonFile, schemaFile); + + // A new window must be requested via AppService + verify(mockAppService, times(1)).openFileInNewWindowDirect(jsonFile, schemaFile); + } +} diff --git a/src/test/java/com/daniel/jsoneditor/controller/WindowRegistryTest.java b/src/test/java/com/daniel/jsoneditor/controller/WindowRegistryTest.java new file mode 100644 index 00000000..d2164ec1 --- /dev/null +++ b/src/test/java/com/daniel/jsoneditor/controller/WindowRegistryTest.java @@ -0,0 +1,44 @@ +package com.daniel.jsoneditor.controller; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +class WindowRegistryTest +{ + @Test + void windowRegistry_fullLifecycle() + { + final WindowRegistry registry = new WindowRegistry(); + final AppWindow windowA = mock(AppWindow.class); + final AppWindow windowB = mock(AppWindow.class); + + // Register and look up a single entry + assertFalse(registry.findByPath("/foo/bar.json").isPresent(), "lookup before register must return empty"); + registry.register("/foo/bar.json", windowA); + assertTrue(registry.findByPath("/foo/bar.json").isPresent(), "lookup after register must succeed"); + assertSame(windowA, registry.findByPath("/foo/bar.json").get(), "findByPath must return the registered window"); + + // Simulate same window re-registering at new paths (stale entry eviction) + registry.register("/files/a.json", windowA); + registry.register("/files/b.json", windowA); + assertFalse(registry.findByPath("/files/a.json").isPresent(), + "Old path must be evicted when the same window re-registers at a new path"); + assertTrue(registry.findByPath("/files/b.json").isPresent(), "New path must be registered"); + + // Register multiple paths for windowA and one for windowB + registry.register("/path/a.json", windowA); + registry.register("/path/b.json", windowA); + registry.register("/path/c.json", windowB); + + // Unregistering windowA must remove all its paths but leave windowB's paths intact + registry.unregisterWindow(windowA); + assertFalse(registry.findByPath("/path/a.json").isPresent(), "/path/a.json must be gone after unregisterWindow(windowA)"); + assertFalse(registry.findByPath("/path/b.json").isPresent(), "/path/b.json must be gone after unregisterWindow(windowA)"); + assertFalse(registry.findByPath("/foo/bar.json").isPresent(), "/foo/bar.json must be gone after unregisterWindow(windowA)"); + assertTrue(registry.findByPath("/path/c.json").isPresent(), "/path/c.json (windowB) must survive unregisterWindow(windowA)"); + } +} diff --git a/src/test/java/com/daniel/jsoneditor/controller/commands/CommandManagerTest.java b/src/test/java/com/daniel/jsoneditor/controller/commands/CommandManagerTest.java index 8935808a..2f50392f 100644 --- a/src/test/java/com/daniel/jsoneditor/controller/commands/CommandManagerTest.java +++ b/src/test/java/com/daniel/jsoneditor/controller/commands/CommandManagerTest.java @@ -2,6 +2,7 @@ import com.daniel.jsoneditor.controller.impl.commands.CommandManager; import com.daniel.jsoneditor.controller.impl.commands.CommandManagerImpl; +import com.daniel.jsoneditor.model.impl.ModelFactory; import com.daniel.jsoneditor.model.impl.ModelImpl; import com.daniel.jsoneditor.model.commands.CommandFactory; import com.daniel.jsoneditor.model.changes.ChangeType; @@ -44,7 +45,7 @@ private ModelImpl createModel() throws Exception properties.set("arr", arrSchema); schemaRoot.set("properties", properties); JsonSchema schema = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012).getSchema(schemaRoot); - ModelImpl model = new ModelImpl(new EventSenderImpl()); + ModelImpl model = ModelFactory.createEmpty(); model.jsonAndSchemaSuccessfullyValidated(new File("dummy.json"), new File("dummy_schema.json"), root, schema); return model; } diff --git a/src/test/java/com/daniel/jsoneditor/model/ModelImplTest.java b/src/test/java/com/daniel/jsoneditor/model/ModelImplTest.java index c766f884..d517287f 100644 --- a/src/test/java/com/daniel/jsoneditor/model/ModelImplTest.java +++ b/src/test/java/com/daniel/jsoneditor/model/ModelImplTest.java @@ -6,7 +6,7 @@ import com.daniel.jsoneditor.model.changes.ModelChange; import com.daniel.jsoneditor.model.commands.impl.*; import com.daniel.jsoneditor.model.impl.ModelImpl; -import com.daniel.jsoneditor.model.statemachine.impl.EventSenderImpl; +import com.daniel.jsoneditor.model.impl.ModelFactory; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; @@ -75,7 +75,7 @@ private ModelImpl createModel() schemaRoot.set("properties", properties); final JsonSchema schema = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012).getSchema(schemaRoot); - final ModelImpl model = new ModelImpl(new EventSenderImpl()); + final ModelImpl model = ModelFactory.createEmpty(); model.jsonAndSchemaSuccessfullyValidated(new File("dummy.json"), new File("dummy_schema.json"), root, schema); return model; } diff --git a/src/test/java/com/daniel/jsoneditor/model/ModelValidationTest.java b/src/test/java/com/daniel/jsoneditor/model/ModelValidationTest.java index 3b0f6223..33d7735b 100644 --- a/src/test/java/com/daniel/jsoneditor/model/ModelValidationTest.java +++ b/src/test/java/com/daniel/jsoneditor/model/ModelValidationTest.java @@ -1,7 +1,7 @@ package com.daniel.jsoneditor.model; import com.daniel.jsoneditor.model.impl.ModelImpl; -import com.daniel.jsoneditor.model.statemachine.impl.EventSenderImpl; +import com.daniel.jsoneditor.model.impl.ModelFactory; import com.daniel.jsoneditor.model.validation.ModelValidationException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -383,7 +383,7 @@ private ModelImpl createModelWithArraySchema() processes.add(process); data.set("processes", processes); - final ModelImpl m = new ModelImpl(new EventSenderImpl()); + final ModelImpl m = ModelFactory.createEmpty(); m.jsonAndSchemaSuccessfullyValidated( new File("dummy.json"), new File("dummy_schema.json"), data, schema); return m; @@ -437,7 +437,7 @@ private ModelImpl createModelWithStrictSchema() person.put("age", 42); data.set("person", person); - final ModelImpl m = new ModelImpl(new EventSenderImpl()); + final ModelImpl m = ModelFactory.createEmpty(); m.jsonAndSchemaSuccessfullyValidated(new File("dummy.json"), new File("dummy_schema.json"), data, schema); return m; } diff --git a/src/test/java/com/daniel/jsoneditor/model/SchemaAndValidationTest.java b/src/test/java/com/daniel/jsoneditor/model/SchemaAndValidationTest.java index 4ee380f9..c449f23e 100644 --- a/src/test/java/com/daniel/jsoneditor/model/SchemaAndValidationTest.java +++ b/src/test/java/com/daniel/jsoneditor/model/SchemaAndValidationTest.java @@ -3,8 +3,8 @@ import java.io.File; import com.daniel.jsoneditor.model.impl.ModelImpl; +import com.daniel.jsoneditor.model.impl.ModelFactory; import com.daniel.jsoneditor.model.json.schema.SchemaHelper; -import com.daniel.jsoneditor.model.statemachine.impl.EventSenderImpl; import com.daniel.jsoneditor.view.impl.jfx.toast.Toasts; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -29,7 +29,7 @@ public class SchemaAndValidationTest private ModelImpl createModel(ObjectNode schemaRoot, ObjectNode dataRoot) { final JsonSchema schema = SCHEMA_FACTORY.getSchema(schemaRoot); - final ModelImpl model = new ModelImpl(new EventSenderImpl()); + final ModelImpl model = ModelFactory.createEmpty(); model.jsonAndSchemaSuccessfullyValidated(new File("dummy.json"), new File("dummy_schema.json"), dataRoot, schema); return model; } diff --git a/src/test/java/com/daniel/jsoneditor/model/commands/ModelCommandsTest.java b/src/test/java/com/daniel/jsoneditor/model/commands/ModelCommandsTest.java index 7786a4fa..b09f89f3 100644 --- a/src/test/java/com/daniel/jsoneditor/model/commands/ModelCommandsTest.java +++ b/src/test/java/com/daniel/jsoneditor/model/commands/ModelCommandsTest.java @@ -7,7 +7,7 @@ import com.daniel.jsoneditor.model.commands.impl.AddNodeToArrayCommand; import com.daniel.jsoneditor.model.commands.impl.SetValueAtNodeCommand; import com.daniel.jsoneditor.model.impl.ModelImpl; -import com.daniel.jsoneditor.model.statemachine.impl.EventSenderImpl; +import com.daniel.jsoneditor.model.impl.ModelFactory; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; @@ -146,7 +146,7 @@ private ModelImpl createModel() throws Exception { // allow arbitrary properties (like 'a', 'notArray') schemaRoot.set("properties", properties); JsonSchema schema = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012).getSchema(schemaRoot); - ModelImpl m = new ModelImpl(new EventSenderImpl()); + ModelImpl m = ModelFactory.createEmpty(); m.jsonAndSchemaSuccessfullyValidated(new File("dummy.json"), new File("dummy_schema.json"), root, schema); return m; } diff --git a/src/test/java/com/daniel/jsoneditor/model/sessions/FileSessionManagerTest.java b/src/test/java/com/daniel/jsoneditor/model/sessions/FileSessionManagerTest.java index 60a081cc..68f66e50 100644 --- a/src/test/java/com/daniel/jsoneditor/model/sessions/FileSessionManagerTest.java +++ b/src/test/java/com/daniel/jsoneditor/model/sessions/FileSessionManagerTest.java @@ -13,8 +13,15 @@ import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.Future; import static org.junit.jupiter.api.Assertions.*; +import com.daniel.jsoneditor.model.ReadableModel; +import com.daniel.jsoneditor.model.WritableModel; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; public class FileSessionManagerTest @@ -46,91 +53,178 @@ void setUp() throws Exception Files.writeString(schemaFile, SIMPLE_SCHEMA); } + /** + * Comprehensive lifecycle test covering: basic open, dedup/shared-model, cross-session mutation visibility, + * refcount decrement on detach, model eviction on last-session close, fresh model on re-attach, + * detach idempotency, GUI-session unregister cleanup, and GUI window-close cleanup. + */ @Test - void testOpenFileCreatesSession() + void attachAndDetach_fullLifecycle() { - final OpenFileResult openResult = sessionManager.openFile(jsonFile.toString(), schemaFile.toString()); - assertTrue(openResult.success(), "openFile must succeed"); - assertNull(openResult.error(), "error must be null on success"); - final String id = openResult.sessionId(); - assertFalse(id.isEmpty(), "Session ID must not be empty"); - - final EditorSession session = sessionManager.getSession(id); - assertNotNull(session, "getSession must return the opened session"); - assertEquals(id, session.id(), "Session ID must match"); - assertFalse(session.guiOwned(), "Headless session must not be GUI-owned"); - assertEquals(jsonFile.toFile(), session.jsonFile(), "JSON file must match"); - assertEquals(schemaFile.toFile(), session.schemaFile(), "Schema file must match"); - assertNotNull(session.model(), "Session model must not be null"); + // === Section: first attach creates a valid headless session === + final AttachResult result1 = sessionManager.attachSession(jsonFile.toString(), schemaFile.toString(), false); + assertTrue(result1.success(), "first attach must succeed"); + final String id1 = result1.sessionId(); + assertFalse(id1.isEmpty(), "session ID must not be empty"); + final EditorSession session1 = sessionManager.getSession(id1); + assertNotNull(session1, "getSession must return the opened session"); + assertFalse(session1.guiOwned(), "headless session must not be GUI-owned"); + final ReadableModel model1 = session1.model(); + assertNotNull(model1, "session model must not be null"); + + // === Section: second attach to same path deduplicates — shares one ModelImpl === + final AttachResult result2 = sessionManager.attachSession(jsonFile.toString(), schemaFile.toString(), false); + assertTrue(result2.success(), "second attach to same path must succeed"); + final String id2 = result2.sessionId(); + assertNotEquals(id1, id2, "each attach must produce a unique session ID"); + assertSame(model1, sessionManager.getSession(id2).model(), + "both sessions on the same path must share one ModelImpl"); + + // === Section: mutation via session1 is immediately visible via session2 (shared state) === + final WritableModel writable = (WritableModel) sessionManager.getSession(id1).model(); + final ObjectNode mutatedRoot = new ObjectMapper().createObjectNode(); + mutatedRoot.put("mutated", true); + writable.resetRootNode(mutatedRoot); + assertSame(mutatedRoot, sessionManager.getSession(id2).model().getRootJson(), + "write via session1 must be visible via session2 — GUI sees MCP edits at the model layer"); + + // === Section: detach session1 — refCount drops but model survives (session2 still holds it) === + sessionManager.detachSession(id1); + assertNull(sessionManager.getSession(id1), "detached session must not be accessible"); + assertNotNull(sessionManager.getSession(id2), "second session must survive first detach"); + assertSame(model1, sessionManager.getSession(id2).model(), + "surviving session must still hold the original shared model after first detach"); + + // === Section: close last session evicts the shared model and empties the sessions map === + final CloseFileResult closeResult = sessionManager.closeFile(id2); + assertEquals(CloseFileResult.CLOSED, closeResult, "closeFile on last headless session must return CLOSED"); + assertNull(sessionManager.getSession(id2), "closed session must not be accessible"); + assertTrue(sessionManager.listSessions().isEmpty(), "sessions must be empty after all sessions closed"); + + // === Section: re-attach after full eviction produces a fresh ModelImpl (not the evicted one) === + final AttachResult result3 = sessionManager.attachSession(jsonFile.toString(), schemaFile.toString(), false); + assertTrue(result3.success(), "re-attach after eviction must succeed"); + assertNotSame(model1, sessionManager.getSession(result3.sessionId()).model(), + "re-attach after eviction must create a fresh ModelImpl — SharedFile entry was evicted"); + sessionManager.closeFile(result3.sessionId()); + + // === Section: detach idempotency — second detach must be a no-op (guards re-entrant shutdown) === + final AttachResult idempotentResult = + sessionManager.attachSession(jsonFile.toString(), schemaFile.toString(), false); + assertTrue(idempotentResult.success(), "precondition: attach for idempotency check must succeed"); + final String idempotentId = idempotentResult.sessionId(); + sessionManager.detachSession(idempotentId); + assertNull(sessionManager.getSession(idempotentId), "session must be gone after first detach"); + assertDoesNotThrow(() -> sessionManager.detachSession(idempotentId), + "second detach must not throw — idempotency required for re-entrant AppService.shutdown() paths"); + assertTrue(sessionManager.listSessions().isEmpty(), "sessions must be empty after double-detach"); + + // === Section: GUI session — unregisterGuiSession decrements refCount and evicts SharedFile === + final AttachResult guiResult1 = sessionManager.attachSession(jsonFile.toString(), schemaFile.toString(), true); + assertTrue(guiResult1.success(), "GUI attach must succeed"); + final String guiId1 = guiResult1.sessionId(); + assertTrue(sessionManager.getSession(guiId1).guiOwned(), "GUI session must be marked guiOwned"); + final ReadableModel originalGuiModel = sessionManager.getSession(guiId1).model(); + sessionManager.unregisterGuiSession(guiId1); + assertNull(sessionManager.getSession(guiId1), "session must be gone after unregisterGuiSession"); + final AttachResult guiReattach1 = + sessionManager.attachSession(jsonFile.toString(), schemaFile.toString(), true); + assertTrue(guiReattach1.success(), "re-attach after unregister must succeed"); + assertNotSame(originalGuiModel, sessionManager.getSession(guiReattach1.sessionId()).model(), + "re-attach after unregisterGuiSession must create a fresh ModelImpl — proves SharedFile was evicted"); + sessionManager.detachSession(guiReattach1.sessionId()); + assertTrue(sessionManager.listSessions().isEmpty(), "sessions must be clean after GUI unregister section"); + + // === Section: GUI session — window-close path (detachSession) evicts SharedFile === + final AttachResult guiResult2 = sessionManager.attachSession(jsonFile.toString(), schemaFile.toString(), true); + assertTrue(guiResult2.success(), "GUI window-close attach must succeed"); + final String guiId2 = guiResult2.sessionId(); + assertTrue(guiId2.startsWith("gui-"), "GUI session ID must start with 'gui-'"); + assertEquals(1, sessionManager.listSessions().size(), "exactly 1 session must exist while window is open"); + final ReadableModel modelBeforeClose = sessionManager.getSession(guiId2).model(); + sessionManager.detachSession(guiId2); + assertTrue(sessionManager.listSessions().isEmpty(), "sessions must be empty after window close — no session leak"); + assertNull(sessionManager.getSession(guiId2), "closed GUI session must not be accessible"); + final AttachResult guiReattach2 = + sessionManager.attachSession(jsonFile.toString(), schemaFile.toString(), true); + assertTrue(guiReattach2.success(), "re-attach after window close must succeed"); + assertNotSame(modelBeforeClose, sessionManager.getSession(guiReattach2.sessionId()).model(), + "re-attach after window close must produce a fresh model — proves filesByPath entry was evicted"); + sessionManager.detachSession(guiReattach2.sessionId()); + assertTrue(sessionManager.listSessions().isEmpty(), "must be fully clean at end of lifecycle test"); } @Test - void testOpenInvalidFileReturnsNull() + void attachSession_rejectsInvalidInput() + throws Exception { + // Case A — non-existent files final String nonExistentJson = tempDir.resolve("no-such.json").toString(); final String nonExistentSchema = tempDir.resolve("no-such-schema.json").toString(); - final OpenFileResult r1 = sessionManager.openFile(nonExistentJson, nonExistentSchema); - assertFalse(r1.success(), "openFile with non-existent JSON and schema must fail"); + final AttachResult r1 = sessionManager.attachSession(nonExistentJson, nonExistentSchema, false); + assertFalse(r1.success(), "attachSession with non-existent JSON and schema must fail"); assertNotNull(r1.error(), "error message must be present"); assertTrue(r1.error().contains("does not exist"), "error must mention missing file"); - final OpenFileResult r2 = sessionManager.openFile(jsonFile.toString(), nonExistentSchema); - assertFalse(r2.success(), "openFile with existing JSON but missing schema must fail"); + final AttachResult r2 = sessionManager.attachSession(jsonFile.toString(), nonExistentSchema, false); + assertFalse(r2.success(), "attachSession with existing JSON but missing schema must fail"); assertNotNull(r2.error(), "error message must be present"); assertTrue(r2.error().contains("does not exist"), "error must mention missing schema"); - } - @Test - void testCloseFileRemovesSession() - { - final OpenFileResult openResult = sessionManager.openFile(jsonFile.toString(), schemaFile.toString()); - assertTrue(openResult.success(), "Precondition: session must open successfully"); - final String id = openResult.sessionId(); + // Case B — invalid JSON against schema + final Path invalidJson = tempDir.resolve("invalid.json"); + Files.writeString(invalidJson, "{\"name\":\"test\",\"value\":\"not-a-number\"}"); + final AttachResult invalidResult = sessionManager.attachSession(invalidJson.toString(), schemaFile.toString(), false); + assertFalse(invalidResult.success(), "attachSession must fail when JSON does not validate against schema"); + assertNotNull(invalidResult.error(), "error message must be present"); + assertFalse(invalidResult.error().isBlank(), "error message must not be blank"); + assertTrue(invalidResult.error().toLowerCase().contains("schema") || invalidResult.error().toLowerCase().contains("validat"), + "error must mention schema or validation, got: " + invalidResult.error()); + + // Case C — schema mismatch on second attach + final Path schema2 = tempDir.resolve("schema2.json"); + Files.writeString(schema2, + "{\"$schema\":\"http://json-schema.org/draft-07/schema#\"," + + "\"type\":\"object\"," + + "\"properties\":{" + + "\"name\":{\"type\":\"string\"}}}"); - final CloseFileResult closeResult = sessionManager.closeFile(id); - assertEquals(CloseFileResult.CLOSED, closeResult, "closeFile must return CLOSED for a valid headless session"); + final AttachResult result1 = sessionManager.attachSession(jsonFile.toString(), schemaFile.toString(), false); + assertTrue(result1.success(), "first attach must succeed"); - assertNull(sessionManager.getSession(id), "getSession must return null after close"); - assertTrue(sessionManager.listSessions().isEmpty(), "listSessions must be empty after close"); + final AttachResult result2 = sessionManager.attachSession(jsonFile.toString(), schema2.toString(), false); + assertFalse(result2.success(), "attach with mismatched schema must fail"); + assertNotNull(result2.error(), "error must be present on schema mismatch"); + assertTrue(result2.error().toLowerCase().contains("schema"), + "error must mention 'schema', got: " + result2.error()); + + assertNotNull(sessionManager.getSession(result1.sessionId()), + "original session must remain accessible after rejected attach"); } @Test - void testCannotCloseGuiSession() + void guiSession_closeProhibitedAndUnregisterable() + throws Exception { - // Open a headless session to get a valid ReadableModel instance - final OpenFileResult headlessResult = sessionManager.openFile(jsonFile.toString(), schemaFile.toString()); + final AttachResult headlessResult = sessionManager.attachSession(jsonFile.toString(), schemaFile.toString(), false); assertTrue(headlessResult.success(), "Precondition: headless session must open"); final String headlessId = headlessResult.sessionId(); final EditorSession headlessSession = sessionManager.getSession(headlessId); - // Register as GUI session (guiOwned=true) final String guiId = sessionManager.registerGuiSession( headlessSession.model(), jsonFile.toFile(), schemaFile.toFile()); assertNotNull(guiId, "registerGuiSession must return a session ID"); assertTrue(sessionManager.getSession(guiId).guiOwned(), "GUI session must be marked guiOwned"); - // Attempting to close the GUI session via closeFile must fail + // Branch A (close prohibited) final CloseFileResult closeResult = sessionManager.closeFile(guiId); assertEquals(CloseFileResult.GUI_OWNED, closeResult, "closeFile must return GUI_OWNED for a GUI-owned session"); assertNotNull(sessionManager.getSession(guiId), "GUI session must still exist after failed close"); - } - - @Test - void testUnregisterGuiSession() - { - final OpenFileResult headlessResult = sessionManager.openFile(jsonFile.toString(), schemaFile.toString()); - assertTrue(headlessResult.success(), "Precondition: headless session must open"); - final String headlessId = headlessResult.sessionId(); - final EditorSession headlessSession = sessionManager.getSession(headlessId); - - final String guiId = sessionManager.registerGuiSession( - headlessSession.model(), jsonFile.toFile(), schemaFile.toFile()); - assertNotNull(sessionManager.getSession(guiId), "GUI session must exist before unregister"); + // Branch B (unregister) sessionManager.unregisterGuiSession(guiId); - assertNull(sessionManager.getSession(guiId), - "GUI session must be gone after unregisterGuiSession"); + assertNull(sessionManager.getSession(guiId), "GUI session must be gone after unregisterGuiSession"); } @Test @@ -146,8 +240,8 @@ void testListSessionsReturnsAll() throws Exception + "\"type\":\"object\"," + "\"properties\":{\"a\":{\"type\":\"integer\"}}}"); - final OpenFileResult result1 = sessionManager.openFile(jsonFile.toString(), schemaFile.toString()); - final OpenFileResult result2 = sessionManager.openFile(jsonFile2.toString(), schemaFile2.toString()); + final AttachResult result1 = sessionManager.attachSession(jsonFile.toString(), schemaFile.toString(), false); + final AttachResult result2 = sessionManager.attachSession(jsonFile2.toString(), schemaFile2.toString(), false); assertTrue(result1.success(), "First session must open"); assertTrue(result2.success(), "Second session must open"); final String id1 = result1.sessionId(); @@ -186,7 +280,7 @@ void testConcurrentAccess() throws Exception schemaFiles.add(sf); } - // Each thread opens a file then immediately closes it + // Each thread attaches a file then immediately closes it for (int i = 0; i < threadCount; i++) { final int index = i; @@ -195,8 +289,8 @@ void testConcurrentAccess() throws Exception try { startLatch.await(); - final OpenFileResult openResult = sessionManager.openFile( - jsonFiles.get(index).toString(), schemaFiles.get(index).toString()); + final AttachResult openResult = sessionManager.attachSession( + jsonFiles.get(index).toString(), schemaFiles.get(index).toString(), false); if (!openResult.success()) { errorCount.incrementAndGet(); @@ -231,30 +325,159 @@ void testConcurrentAccess() throws Exception } @Test - void testOpenFileWithInvalidJson() throws Exception + void attachSession_concurrentSamePathSharesOneModel() throws Exception { - // JSON has a string where the schema expects a number - validation must reject it - final Path invalidJson = tempDir.resolve("invalid.json"); - Files.writeString(invalidJson, "{\"name\":\"test\",\"value\":\"not-a-number\"}"); + final int threadCount = 10; + final ExecutorService executor = Executors.newFixedThreadPool(threadCount); + final CountDownLatch startLatch = new CountDownLatch(1); + final List> futures = new ArrayList<>(); + + for (int i = 0; i < threadCount; i++) + { + futures.add(executor.submit(() -> + { + startLatch.await(); + return sessionManager.attachSession(jsonFile.toString(), schemaFile.toString(), false); + })); + } + + startLatch.countDown(); - final OpenFileResult result = sessionManager.openFile(invalidJson.toString(), schemaFile.toString()); + final List results = new ArrayList<>(); + for (final Future f : futures) + { + results.add(f.get(5, TimeUnit.SECONDS)); + } + executor.shutdown(); + + // All attaches must succeed with unique session IDs + final Set sessionIds = new HashSet<>(); + for (final AttachResult r : results) + { + assertTrue(r.success(), "all concurrent attaches must succeed: " + r.error()); + sessionIds.add(r.sessionId()); + } + assertEquals(threadCount, sessionIds.size(), "each concurrent attach must produce a unique sessionId"); - assertFalse(result.success(), "openFile must fail when JSON does not validate against schema"); - assertNotNull(result.error(), "error message must be present"); - assertFalse(result.error().isBlank(), "error message must not be blank"); - assertTrue(result.error().toLowerCase().contains("schema") || result.error().toLowerCase().contains("validat"), - "error must mention schema or validation, got: " + result.error()); + // All sessions must share one ModelImpl instance + final ReadableModel referenceModel = sessionManager.getSession(results.get(0).sessionId()).model(); + for (final AttachResult r : results) + { + assertSame(referenceModel, sessionManager.getSession(r.sessionId()).model(), + "all concurrent attaches to same path must share one ModelImpl"); + } } @Test - void testCloseSessionThenAccess() + void attachSession_falseSchemaError_whenEvictedAfterPeek() throws Exception { - final OpenFileResult openResult = sessionManager.openFile(jsonFile.toString(), schemaFile.toString()); - assertTrue(openResult.success(), "Precondition: session must open"); - final String id = openResult.sessionId(); + final Path schemaB = tempDir.resolve("schema-b.json"); + Files.writeString(schemaB, + "{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"},\"value\":{\"type\":\"number\"},\"extra\":{\"type\":\"string\"}}}"); - sessionManager.closeFile(id); + final CountDownLatch peekDone = new CountDownLatch(1); + final CountDownLatch evictionDone = new CountDownLatch(1); + + final FileSessionManager timedManager = new FileSessionManager() + { + @Override + protected void afterFastPathPeek(final String canonicalJson) + { + peekDone.countDown(); + try + { + evictionDone.await(5, TimeUnit.SECONDS); + } + catch (final InterruptedException e) + { + Thread.currentThread().interrupt(); + } + } + }; - assertNull(sessionManager.getSession(id), "getSession must return null after session is closed"); + final AttachResult rA = timedManager.attachSession(jsonFile.toString(), schemaFile.toString(), false); + assertTrue(rA.success(), "first attach must succeed"); + final String idA = rA.sessionId(); + + final ExecutorService executor = Executors.newSingleThreadExecutor(); + final Future futureC = executor.submit(() -> + { + return timedManager.attachSession(jsonFile.toString(), schemaB.toString(), false); + }); + + assertTrue(peekDone.await(5, TimeUnit.SECONDS), "timed peek must complete within timeout"); + + // Evict the shared file by detaching the original session + timedManager.detachSession(idA); + + // Allow the attaching thread to continue after eviction + evictionDone.countDown(); + + final AttachResult resultC = futureC.get(5, TimeUnit.SECONDS); + executor.shutdown(); + + assertTrue(resultC.success(), "attach with schema-b must succeed after eviction — stale peek must not produce false schema-mismatch error; got: " + resultC.error()); + if (resultC.success()) + { + timedManager.closeFile(resultC.sessionId()); + } } + + /** + * Verifies that when the GUI switches from file A to file B (detach A, attach B), + * any MCP session still open on file A keeps its original model data intact. + * This guards against the shared-model mutation bug where loading a new file + * would silently corrupt concurrent MCP sessions on the old file. + */ + @Test + void fileChange_detachAttach_preservesOldModelIntegrity() throws Exception + { + // Setup file 2 with different content but compatible schema + final Path jsonFile2 = tempDir.resolve("file2.json"); + Files.writeString(jsonFile2, "{\"name\":\"file-two\",\"value\":2}"); + // Reuse the existing SIMPLE_SCHEMA — both files validate against it + + // GUI opens file 1 + final AttachResult guiResult = sessionManager.attachSession( + jsonFile.toString(), schemaFile.toString(), true); + assertTrue(guiResult.success(), "GUI attach to file 1 must succeed"); + final String guiId = guiResult.sessionId(); + + // MCP opens the same file 1 — shares one model with the GUI session + final AttachResult mcpResult = sessionManager.attachSession( + jsonFile.toString(), schemaFile.toString(), false); + assertTrue(mcpResult.success(), "MCP attach to file 1 must succeed"); + final String mcpId = mcpResult.sessionId(); + final ReadableModel sharedModel = sessionManager.getSession(mcpId).model(); + assertSame(sharedModel, sessionManager.getSession(guiId).model(), + "GUI and MCP sessions on file 1 must share one ModelImpl"); + + // GUI switches to file 2: detach from file 1, attach to file 2 + sessionManager.detachSession(guiId); + final AttachResult newGuiResult = sessionManager.attachSession( + jsonFile2.toString(), schemaFile.toString(), true); + assertTrue(newGuiResult.success(), "GUI attach to file 2 must succeed"); + final String newGuiId = newGuiResult.sessionId(); + + // MCP session on file 1 must still be accessible and hold the original model + assertNotNull(sessionManager.getSession(mcpId), + "MCP session on file 1 must still be accessible after GUI file switch"); + assertSame(sharedModel, sessionManager.getSession(mcpId).model(), + "MCP session must still reference the original shared model"); + assertEquals("test", sessionManager.getSession(mcpId).model().getRootJson().get("name").asText(), + "File 1 model must still contain its original data — not file 2 data"); + + // New GUI session must use a separate model with file 2's data + final ReadableModel newModel = sessionManager.getSession(newGuiId).model(); + assertNotSame(sharedModel, newModel, + "File 2 must use a different ModelImpl — models are file-specific"); + assertEquals("file-two", newModel.getRootJson().get("name").asText(), + "File 2 model must contain file-two data"); + + // Cleanup + sessionManager.detachSession(newGuiId); + sessionManager.closeFile(mcpId); + assertTrue(sessionManager.listSessions().isEmpty(), "All sessions must be cleaned up"); + } + } diff --git a/src/test/java/com/daniel/jsoneditor/view/ModelChangeUIIntegrationTest.java b/src/test/java/com/daniel/jsoneditor/view/ModelChangeUIIntegrationTest.java index 75251287..1c5c7455 100644 --- a/src/test/java/com/daniel/jsoneditor/view/ModelChangeUIIntegrationTest.java +++ b/src/test/java/com/daniel/jsoneditor/view/ModelChangeUIIntegrationTest.java @@ -7,8 +7,8 @@ import com.daniel.jsoneditor.model.changes.ModelChange; import com.daniel.jsoneditor.model.commands.impl.*; import com.daniel.jsoneditor.model.impl.ModelImpl; +import com.daniel.jsoneditor.model.impl.ModelFactory; import com.daniel.jsoneditor.model.json.JsonNodeWithPath; -import com.daniel.jsoneditor.model.statemachine.impl.EventSenderImpl; import com.daniel.jsoneditor.view.impl.jfx.impl.scenes.impl.editor.components.editorwindow.EditorWindowManager; import com.daniel.jsoneditor.view.impl.jfx.impl.scenes.impl.editor.components.editorwindow.JsonEditorEditorWindow; import com.daniel.jsoneditor.view.impl.jfx.impl.scenes.impl.editor.components.editorwindow.components.tableview.impl.EditorTableViewImpl; @@ -295,7 +295,7 @@ void shouldFilterAndDisplayCorrectItems() private ModelImpl createTestModel() { - final ModelImpl testModel = new ModelImpl(new EventSenderImpl()); + final ModelImpl testModel = ModelFactory.createEmpty(); final ObjectNode root = MAPPER.createObjectNode(); @@ -392,4 +392,3 @@ private ObjectNode createBooleanSchema() return schema; } } -