Skip to content
Open
14 changes: 13 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -43,19 +43,31 @@ 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'
}

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 {
Expand Down
178 changes: 157 additions & 21 deletions src/main/java/com/daniel/jsoneditor/controller/AppService.java
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -37,6 +48,10 @@ public class AppService
private final List<AppWindow> 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()
{
Expand All @@ -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
{
Expand All @@ -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())
Expand All @@ -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
*/
Expand All @@ -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())
Expand All @@ -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();
Expand All @@ -189,6 +324,7 @@ public void shutdown()
}
logger.info("Shutting down AppService");
systemTrayManager.hide();
fileSessionManager.closeAllHeadlessSessions();
mcpController.stopMcpServer();
}
}
Loading