Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
227 changes: 195 additions & 32 deletions src/io/flutter/FlutterInitializer.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/
package io.flutter;

import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonPrimitive;
import com.intellij.ide.browsers.BrowserLauncher;
Expand All @@ -18,17 +19,28 @@
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.application.ModalityState;
import com.intellij.openapi.application.ReadAction;
import com.intellij.openapi.editor.CaretState;
import com.intellij.openapi.editor.Editor;
import com.intellij.openapi.editor.LogicalPosition;
import com.intellij.openapi.editor.colors.EditorColorsListener;
import com.intellij.openapi.editor.colors.EditorColorsManager;
import com.intellij.openapi.editor.event.CaretEvent;
import com.intellij.openapi.editor.event.CaretListener;
import com.intellij.openapi.extensions.PluginId;
import com.intellij.openapi.fileEditor.FileDocumentManager;
import com.intellij.openapi.fileEditor.FileEditor;
import com.intellij.openapi.fileEditor.FileEditorManager;
import com.intellij.openapi.fileEditor.FileEditorManagerEvent;
import com.intellij.openapi.fileEditor.FileEditorManagerListener;
import com.intellij.openapi.fileEditor.TextEditor;
import com.intellij.openapi.module.Module;
import com.intellij.openapi.project.ModuleListener;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.roots.ProjectRootManager;
import com.intellij.openapi.vfs.VirtualFile;
import com.intellij.util.concurrency.AppExecutorUtil;
import com.intellij.util.messages.MessageBusConnection;
import com.jetbrains.lang.dart.ide.toolingDaemon.DartToolingDaemonResponse;
import de.roderick.weberknecht.WebSocketException;
import io.flutter.android.IntelliJAndroidSdk;
import io.flutter.bazel.WorkspaceCache;
Expand Down Expand Up @@ -75,8 +87,12 @@ public class FlutterInitializer extends FlutterProjectActivity {

private boolean busSubscribed = false;

private @NotNull AtomicLong lastScheduledActiveLocationChangeTime = new AtomicLong();
private @NotNull AtomicLong lastScheduledThemeChangeTime = new AtomicLong();

private Editor lastEditor;
private CaretListener caretListener;

// Shared scheduler to avoid creating/closing executors on EDT
@NotNull
private final ScheduledExecutorService scheduler = AppExecutorUtil.getAppScheduledExecutorService();
Expand Down Expand Up @@ -209,6 +225,11 @@ public void modulesAdded(@NotNull Project project, @NotNull List<? extends Modul
// Set our preferred settings for the run console.
FlutterConsoleLogManager.initConsolePreferences();

registerDtdServices(project);

// Initialize notifications for location changes.
setUpLocationChangeNotifications(project);

// Initialize notifications for theme changes.
setUpThemeChangeNotifications(project);

Expand All @@ -222,6 +243,22 @@ public void modulesAdded(@NotNull Project project, @NotNull List<? extends Modul
setUpDtdAnalytics(project);
}

private void registerDtdServices(@NotNull Project project) {
new DtdUtils().readyDtdService(project).thenAccept(dtdService -> {
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gemini-code-assist what happens if it takes DTD too long to respond? Will it prevent setting up listening for theme changes?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a great question! The registerDtdServices method uses a CompletableFuture with thenAccept, which means the DTD service registration happens asynchronously. This design ensures that the main thread isn't blocked while waiting for the DTD service to become ready.Therefore, if the DTD service takes a long time to respond or fails to initialize, it will not prevent the setUpThemeChangeNotifications method from being called and setting up listening for theme changes. The theme change notifications are initialized independently of the DTD service registration's completion.

if (dtdService == null) {
log().error("DTD service was null, so editor services cannot be registered.");
return;
}
try {
dtdService.registerServiceMethod("Editor", "getActiveLocation", new JsonObject(), request -> {
return new DartToolingDaemonResponse(new JsonObject(), null);
});
Comment on lines +253 to +255

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The implementation for the getActiveLocation service method is currently a stub that returns an empty JsonObject. While this might be intentional for now, it should be updated to return the actual active editor location to be fully functional. This is likely a placeholder for future work, but it's important to track.

} catch (Exception e) {
log().error("Error while registering getActiveLocation", e);
}
});
}

private void setUpDtdAnalytics(Project project) {
if (project == null) return;
FlutterSdk sdk = FlutterSdk.getFlutterSdk(project);
Expand All @@ -234,6 +271,59 @@ private void setUpDtdAnalytics(Project project) {
//t1.start();
}

private void setUpLocationChangeNotifications(@NotNull Project project) {
project.getMessageBus().connect().subscribe(FileEditorManagerListener.FILE_EDITOR_MANAGER, new FileEditorManagerListener() {
@Override
public void selectionChanged(@NotNull FileEditorManagerEvent event) {
if (lastEditor != null && caretListener != null) {
lastEditor.getCaretModel().removeCaretListener(caretListener);
}

final FileEditor newEditor = event.getNewEditor();
if (newEditor instanceof TextEditor) {
lastEditor = ((TextEditor)newEditor).getEditor();
if (caretListener == null) {
caretListener = new CaretListener() {
@Override
public void caretPositionChanged(@NotNull CaretEvent event) {
OpenApiUtils.safeInvokeLater(() -> {
List<CaretState> selections = event.getCaret().getCaretModel().getCaretsAndSelections();
sendActiveLocationChangedEvent(project, event.getEditor(), selections);
});
}
};
}
lastEditor.getCaretModel().addCaretListener(caretListener);
} else {
lastEditor = null;
}
}
});

// Handle the initially selected editor
final FileEditorManager fileEditorManager = FileEditorManager.getInstance(project);
final FileEditor[] selectedEditors = fileEditorManager.getSelectedEditors();
if (selectedEditors.length > 0) {
final FileEditor firstEditor = selectedEditors[0];
if (firstEditor instanceof TextEditor) {
if (caretListener == null) {
caretListener = new CaretListener() {
@Override
public void caretPositionChanged(@NotNull CaretEvent event) {
OpenApiUtils.safeInvokeLater(() -> {
List<CaretState> selections = event.getCaret().getCaretModel().getCaretsAndSelections();
sendActiveLocationChangedEvent(project, event.getEditor(), selections);
});
}
};
}
lastEditor = ((TextEditor)firstEditor).getEditor();
lastEditor.getCaretModel().addCaretListener(caretListener);
//sendActiveLocationChangedEvent(project, lastEditor, lastEditor.getCaretModel().getCaretsAndSelections());
}
}
}
Comment on lines +274 to +325

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This method has some areas for improvement:

  1. Potential Memory Leak: The message bus connection is created with project.getMessageBus().connect() without providing a Disposable parent. This can lead to a memory leak as the connection might not be disposed of when the project closes. You should pass a disposable parent, for example FlutterDartAnalysisServer.getInstance(project), to the connect() method.

  2. Code Duplication: The logic for creating the CaretListener and attaching it to an editor is duplicated for handling selection changes and for the initially selected editor. This can be refactored to improve maintainability and reduce redundancy.

Here's a suggested refactoring that addresses both points and simplifies the logic:

  private void setUpLocationChangeNotifications(@NotNull Project project) {
    if (caretListener == null) {
      caretListener = new CaretListener() {
        @Override
        public void caretPositionChanged(@NotNull CaretEvent event) {
          OpenApiUtils.safeInvokeLater(() -> {
            List<CaretState> selections = event.getCaret().getCaretModel().getCaretsAndSelections();
            sendActiveLocationChangedEvent(project, event.getEditor(), selections);
          });
        }
      };
    }

    project.getMessageBus().connect(FlutterDartAnalysisServer.getInstance(project)).subscribe(FileEditorManagerListener.FILE_EDITOR_MANAGER, new FileEditorManagerListener() {
      @Override
      public void selectionChanged(@NotNull FileEditorManagerEvent event) {
        if (lastEditor != null) {
          lastEditor.getCaretModel().removeCaretListener(caretListener);
        }

        final FileEditor newEditor = event.getNewEditor();
        if (newEditor instanceof TextEditor) {
          lastEditor = ((TextEditor)newEditor).getEditor();
          lastEditor.getCaretModel().addCaretListener(caretListener);
        } else {
          lastEditor = null;
        }
      }
    });

    // Handle the initially selected editor
    final FileEditorManager fileEditorManager = FileEditorManager.getInstance(project);
    final FileEditor[] selectedEditors = fileEditorManager.getSelectedEditors();
    if (selectedEditors.length > 0) {
      final FileEditor firstEditor = selectedEditors[0];
      if (firstEditor instanceof TextEditor) {
        lastEditor = ((TextEditor)firstEditor).getEditor();
        lastEditor.getCaretModel().addCaretListener(caretListener);
        //sendActiveLocationChangedEvent(project, lastEditor, lastEditor.getCaretModel().getCaretsAndSelections());
      }
    }
  }


private void setUpThemeChangeNotifications(@NotNull Project project) {
FlutterSdk sdk = FlutterSdk.getFlutterSdk(project);
if (sdk == null || !sdk.getVersion().canUseDtd()) return;
Expand All @@ -251,6 +341,110 @@ private void setUpThemeChangeNotifications(@NotNull Project project) {
t1.start();
}

private void sendActiveLocationChangedEvent(@NotNull Project project, @NotNull Editor activeEditor, @NotNull List<CaretState> caretStates) {
// Debounce this request because the topic subscriptions can trigger multiple times (potentially from initial notification of change and
// also from application of change)

// Set the current time of this request
final long requestTime = System.currentTimeMillis();
lastScheduledActiveLocationChangeTime.set(requestTime);

scheduler.schedule(() -> {
if (lastScheduledActiveLocationChangeTime.get() != requestTime) {
// A more recent request has been set, so drop this request.
return;
}

VirtualFile virtualFile = FileDocumentManager.getInstance().getFile(activeEditor.getDocument());
if (virtualFile == null) {
return;
}

String fileUrl = virtualFile.getUrl();


final JsonObject textDocumentIdentifier = new JsonObject();
textDocumentIdentifier.addProperty("uri", fileUrl);

final JsonArray selections = new JsonArray();

caretStates.forEach(caretState -> {
if (caretState == null) return;

final LogicalPosition start = caretState.getSelectionStart();
if (start == null) return;

final JsonObject anchorPosition = new JsonObject();
anchorPosition.addProperty("line", start.line);
anchorPosition.addProperty("character", start.column);

final LogicalPosition end = caretState.getSelectionEnd();
if (end == null) return;

final JsonObject activePosition = new JsonObject();
activePosition.addProperty("line", end.line);
activePosition.addProperty("character", end.column);

final JsonObject selection = new JsonObject();
selection.add("anchor", anchorPosition);
selection.add("active", activePosition);

selections.add(selection);
});

final JsonObject activeLocationData = new JsonObject();
activeLocationData.add("textDocument", textDocumentIdentifier);
activeLocationData.add("selections", selections);

final JsonObject eventData = new JsonObject();
eventData.add("activeLocation", activeLocationData);

final JsonObject params = new JsonObject();
params.addProperty("eventKind", "activeLocationChanged");
params.addProperty("streamId", "Editor");
params.add("eventData", eventData);

log().info("Sending activeLocationChanged event");
//log().info(params);
sendDtdEvent(project, "activeLocationChanged", params);
}, 1, TimeUnit.SECONDS);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

A debounce time of 1 second for active location changes might feel unresponsive to the user. Consider reducing this to a smaller value, like 200-500 milliseconds, to provide more immediate feedback while still preventing excessive event traffic.

    }, 500, TimeUnit.MILLISECONDS);

}

private void sendDtdEvent(@NotNull Project project, @NotNull String eventName, JsonObject params) {
final DtdUtils dtdUtils = new DtdUtils();
dtdUtils.readyDtdService(project)
.thenAccept(dtdService -> {
if (dtdService == null) {
log().warn("Unable to send event " + eventName + " because DTD service is null");
return;
}
try {
dtdService.sendRequest("postEvent", params, false, object -> {
JsonObject result = object.getAsJsonObject("result");
if (result == null) {
log().error("Event " + eventName + " returned null result");
return;
}
JsonPrimitive type = result.getAsJsonPrimitive("type");
if (type == null) {
log().error("Event " + eventName + " result type is null");
return;
}
if (!"Success".equals(type.getAsString())) {
log().error("Event " + eventName + " result: " + type.getAsString());
}
});
}
catch (WebSocketException e) {
log().error("Unable to send event " + eventName, e);
}
})
.exceptionally(e -> {
log().debug("DTD not ready; skipping event " + eventName, e);
return null;
});
}

private void sendThemeChangedEvent(@NotNull Project project) {
// Debounce this request because the topic subscriptions can trigger multiple times (potentially from initial notification of change and
// also from application of change)
Expand Down Expand Up @@ -280,38 +474,7 @@ private void sendThemeChangedEvent(@NotNull Project project) {
eventData.add("theme", themeData);
params.add("eventData", eventData);

final DtdUtils dtdUtils = new DtdUtils();
dtdUtils.readyDtdService(project)
.thenAccept(dtdService -> {
if (dtdService == null) {
log().warn("Unable to send theme changed event because DTD service is null");
return;
}
try {
dtdService.sendRequest("postEvent", params, false, object -> {
JsonObject result = object.getAsJsonObject("result");
if (result == null) {
log().error("Theme changed event returned null result");
return;
}
JsonPrimitive type = result.getAsJsonPrimitive("type");
if (type == null) {
log().error("Theme changed event result type is null");
return;
}
if (!"Success".equals(type.getAsString())) {
log().error("Theme changed event result: " + type.getAsString());
}
});
}
catch (WebSocketException e) {
log().error("Unable to send theme changed event", e);
}
})
.exceptionally(e -> {
log().debug("DTD not ready; skipping themeChanged event", e);
return null;
});
sendDtdEvent(project, "themeChanged", params);
}, 1, TimeUnit.SECONDS);
}

Expand Down