From 65dcc576c29076698d42fd40bc8e2b2ecf0be4bb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Magnus=20W=C3=A5llberg?=
Date: Sun, 28 Dec 2025 15:11:31 +0100
Subject: [PATCH 01/14] Fix HEAD diff logic if root directory is not a git repo
Fixes comod/git-scope-pro#75
---
.../compare/ChangesService.java | 91 ++++++++++------
.../java/listener/MyBulkFileListener.java | 2 +-
src/main/java/service/ViewService.java | 101 ++++++++++++------
3 files changed, 129 insertions(+), 65 deletions(-)
diff --git a/src/main/java/implementation/compare/ChangesService.java b/src/main/java/implementation/compare/ChangesService.java
index 7793d3f..8cf47bb 100644
--- a/src/main/java/implementation/compare/ChangesService.java
+++ b/src/main/java/implementation/compare/ChangesService.java
@@ -80,10 +80,10 @@ public void run(@NotNull ProgressIndicator indicator) {
repositories.forEach(repo -> {
String branchToCompare = getBranchToCompare(targetBranchByRepo, repo);
-
+
// Use only repo path as cache key
String cacheKey = repo.getRoot().getPath();
-
+
Collection changesPerRepo = null;
if (!checkFs && changesCache.containsKey(cacheKey)) {
@@ -92,7 +92,7 @@ public void run(@NotNull ProgressIndicator indicator) {
} else {
// Fetch fresh changes
changesPerRepo = doCollectChanges(currentProject, repo, branchToCompare);
-
+
// Cache the results (but don't cache error states)
if (!(changesPerRepo instanceof ErrorStateList)) {
changesCache.put(cacheKey, new ArrayList<>(changesPerRepo)); // Store a copy to avoid modification issues
@@ -159,25 +159,50 @@ public void clearCache(GitRepository repo) {
changesCache.remove(cacheKey);
}
- private Boolean isLocalChangeOnly(String localChangePath, Collection changes) {
+ /**
+ * Filters local changes to include only those within the specified repository path.
+ * Also optionally excludes changes that are already present in an existing collection.
+ *
+ * @param localChanges All local changes from the project
+ * @param repoPath Repository root path to filter by
+ * @param existingChanges Optional collection of existing changes to exclude duplicates (null to include all)
+ * @return Filtered collection of changes
+ */
+ private Collection filterLocalChanges(Collection localChanges, String repoPath, Collection existingChanges) {
+ Collection filtered = new ArrayList<>();
+
+ for (Change change : localChanges) {
+ VirtualFile changeFile = change.getVirtualFile();
+ if (changeFile == null) {
+ continue;
+ }
- if (changes == null || changes.isEmpty()) {
- return false;
- }
+ String changePath = changeFile.getPath();
- for (Change change : changes) {
- VirtualFile vFile = change.getVirtualFile();
- if (vFile == null) {
- return false;
+ // Check if change belongs to this repository
+ if (!changePath.startsWith(repoPath)) {
+ continue;
}
- String changePath = change.getVirtualFile().getPath();
- if (localChangePath.equals(changePath)) {
- // we have already this file in our changes-list
- return false;
+ // If existingChanges provided, skip duplicates
+ if (existingChanges != null && !existingChanges.isEmpty()) {
+ boolean isDuplicate = false;
+ for (Change existing : existingChanges) {
+ VirtualFile existingFile = existing.getVirtualFile();
+ if (existingFile != null && changePath.equals(existingFile.getPath())) {
+ isDuplicate = true;
+ break;
+ }
+ }
+ if (isDuplicate) {
+ continue;
+ }
}
+
+ filtered.add(change);
}
- return true;
+
+ return filtered;
}
@NotNull
@@ -194,6 +219,19 @@ public Collection getChangesByHistory(Project project, GitRepository rep
return new ArrayList<>(changeMap.values());
}
+ /**
+ * Collects local changes for HEAD (uncommitted changes) filtered by repository.
+ *
+ * @param localChanges All local changes from the project
+ * @param repo Repository to filter changes for
+ * @return Collection of local changes within this repository
+ */
+ private Collection collectHeadChanges(Collection localChanges, GitRepository repo) {
+ String repoPath = repo.getRoot().getPath();
+ // For HEAD, we only want local changes within this repository (no existing changes to exclude)
+ return filterLocalChanges(localChanges, repoPath, null);
+ }
+
public Collection doCollectChanges(Project project, GitRepository repo, String scopeRef) {
VirtualFile file = repo.getRoot();
Collection _changes = new ArrayList<>();
@@ -202,10 +240,9 @@ public Collection doCollectChanges(Project project, GitRepository repo,
ChangeListManager changeListManager = ChangeListManager.getInstance(project);
Collection localChanges = changeListManager.getAllChanges();
- // Special handling for HEAD - just return local changes
+ // Special handling for HEAD - return local changes filtered by this repository
if (scopeRef.equals(GitService.BRANCH_HEAD)) {
- _changes.addAll(localChanges);
- return _changes;
+ return collectHeadChanges(localChanges, repo);
}
// Diff Changes
@@ -241,18 +278,10 @@ public Collection doCollectChanges(Project project, GitRepository repo,
}
}
- for (Change localChange : localChanges) {
- VirtualFile localChangeVirtualFile = localChange.getVirtualFile();
- if (localChangeVirtualFile == null) {
- continue;
- }
- String localChangePath = localChangeVirtualFile.getPath();
-
- // Add Local Change if not part of Diff Changes anyway
- if (isLocalChangeOnly(localChangePath, _changes)) {
- _changes.add(localChange);
- }
- }
+ // Add local changes that aren't already in the diff (filtered by repository and excluding duplicates)
+ String repoPath = repo.getRoot().getPath();
+ Collection additionalLocalChanges = filterLocalChanges(localChanges, repoPath, _changes);
+ _changes.addAll(additionalLocalChanges);
} catch (VcsException ignored) {
}
diff --git a/src/main/java/listener/MyBulkFileListener.java b/src/main/java/listener/MyBulkFileListener.java
index 20d80e8..e377e6e 100644
--- a/src/main/java/listener/MyBulkFileListener.java
+++ b/src/main/java/listener/MyBulkFileListener.java
@@ -22,7 +22,7 @@ public void after(@NotNull List extends VFileEvent> events) {
ViewService viewService = project.getService(ViewService.class);
if (viewService != null) {
// TODO: collectChanges: bulk file event (disabled)
- // viewService.collectChanges(false);
+ viewService.collectChanges(false);
}
}
}
diff --git a/src/main/java/service/ViewService.java b/src/main/java/service/ViewService.java
index d16da8c..4750700 100644
--- a/src/main/java/service/ViewService.java
+++ b/src/main/java/service/ViewService.java
@@ -26,7 +26,6 @@
import java.util.Collection;
import java.util.List;
import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
@@ -38,7 +37,6 @@ public class ViewService implements Disposable {
public static final String PLUS_TAB_LABEL = "+";
public static final int DEBOUNCE_MS = 50;
- public static final int HEAD_TAB_INIT_TIMEOUT = 5; // sec
public List collection = new ArrayList<>();
public Integer currentTabIndex = 0;
private final Project project;
@@ -242,29 +240,7 @@ public void initTabsSequentially() {
private void initHeadTab() {
this.myHeadModel = new MyModel(true);
toolWindowService.addTab(myHeadModel, GitService.BRANCH_HEAD, false);
-
- // Wait for repositories to be loaded before proceeding
- CountDownLatch latch = new CountDownLatch(1);
-
- gitService.getRepositoriesAsync(repositories -> {
- repositories.forEach(repo -> {
- myHeadModel.addTargetBranch(repo, null);
- });
-
- subscribeToObservable(myHeadModel);
-
- // TODO: collectChanges: head tab initialized
- incrementUpdate();
- collectChanges(myHeadModel, true);
- latch.countDown();
- });
-
- try {
- // Wait for the head tab initialization to complete with a timeout
- latch.await(HEAD_TAB_INIT_TIMEOUT, TimeUnit.SECONDS);
- } catch (InterruptedException e) {
- // Handle interruption if necessary
- }
+ subscribeToObservable(myHeadModel);
}
public void addRevisionTab(String revision) {
@@ -440,24 +416,61 @@ public CompletableFuture collectChanges(boolean checkFs) {
return collectChanges(getCurrent(), checkFs);
}
+ /**
+ * Ensures HEAD tab model has a targetBranchMap initialized with all repositories.
+ * This is a lazy initialization that runs when HEAD tab is accessed, after repositories are loaded.
+ * Uses async callback to avoid slow operations on EDT.
+ */
+ private void ensureHeadTabInitializedAsync(MyModel model, Runnable onComplete) {
+ if (model.isHeadTab() && model.getTargetBranchMap() == null) {
+ // Use async method to avoid slow operations on EDT
+ gitService.getRepositoriesAsync(repositories -> {
+ repositories.forEach(repo -> {
+ model.addTargetBranch(repo, null);
+ });
+ if (onComplete != null) {
+ onComplete.run();
+ }
+ });
+ } else {
+ // Already initialized or not HEAD tab
+ if (onComplete != null) {
+ onComplete.run();
+ }
+ }
+ }
+
public CompletableFuture collectChanges(MyModel model, boolean checkFs) {
CompletableFuture done = new CompletableFuture<>();
if (model == null) {
done.complete(null);
return done;
}
- TargetBranchMap targetBranchMap = model.getTargetBranchMap();
- if (targetBranchMap == null) {
- done.complete(null);
- return done;
- }
+ // Ensure HEAD tab is initialized before proceeding
+ ensureHeadTabInitializedAsync(model, () -> {
+ TargetBranchMap targetBranchMap = model.getTargetBranchMap();
+ if (targetBranchMap == null) {
+ done.complete(null);
+ return;
+ }
+
+ collectChangesInternal(model, targetBranchMap, checkFs, done);
+ });
+
+ return done;
+ }
+
+ private void collectChangesInternal(MyModel model, TargetBranchMap targetBranchMap, boolean checkFs, CompletableFuture done) {
+
+ // Make targetBranchMap effectively final for lambda
+ final TargetBranchMap finalTargetBranchMap = targetBranchMap;
final long gen = applyGeneration.get();
LOG.debug("collectChanges() scheduled with generation = " + gen);
// serialize collection behind a single-threaded executor
changesExecutor.execute(() -> {
- changesService.collectChangesWithCallback(targetBranchMap, changes -> {
+ changesService.collectChangesWithCallback(finalTargetBranchMap, changes -> {
ApplicationManager.getApplication().invokeLater(() -> {
try {
long currentGen = applyGeneration.get();
@@ -473,8 +486,6 @@ public CompletableFuture collectChanges(MyModel model, boolean checkFs) {
});
}, checkFs);
});
-
- return done;
}
// helper to enqueue UI work strictly after the currently queued collections
@@ -495,6 +506,11 @@ public MyModel addModel() {
}
public MyModel getCurrent() {
+ // Check if toolWindowService is initialized
+ if (toolWindowService == null) {
+ return myHeadModel; // Safe fallback when not yet initialized
+ }
+
// Get the currently selected tab's model directly from ContentManager
ContentManager contentManager = toolWindowService.getToolWindow().getContentManager();
Content selectedContent = contentManager.getSelectedContent();
@@ -531,6 +547,25 @@ public void setCollection(List collection) {
this.collection = collection;
}
+ /**
+ * Gets the changes from the currently active scope/tab.
+ * Used by FileStatusProvider to color files based on active scope.
+ *
+ * @return Collection of changes in current scope, or null if no active scope or not initialized
+ */
+ public Collection getCurrentScopeChanges() {
+ // Early return if ViewService is not fully initialized yet
+ if (toolWindowService == null) {
+ return null;
+ }
+
+ MyModel current = getCurrent();
+ if (current == null) {
+ return null;
+ }
+ return current.getChanges();
+ }
+
public void removeTab(int tabIndex) {
int modelIndex = getModelIndex(tabIndex);
// Check if the index is valid before removing
From 64d29ae92b68728b1a9ffa9ec6c76c09eb05e10b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Magnus=20W=C3=A5llberg?=
Date: Sun, 28 Dec 2025 16:33:00 +0100
Subject: [PATCH 02/14] Improve file-system error handling.
---
.../compare/ChangesService.java | 68 +++++++++++--------
src/main/java/utils/CustomRollback.java | 33 ++++++++-
src/main/java/utils/GitUtil.java | 38 ++++++++---
3 files changed, 101 insertions(+), 38 deletions(-)
diff --git a/src/main/java/implementation/compare/ChangesService.java b/src/main/java/implementation/compare/ChangesService.java
index 8cf47bb..a21c322 100644
--- a/src/main/java/implementation/compare/ChangesService.java
+++ b/src/main/java/implementation/compare/ChangesService.java
@@ -1,6 +1,7 @@
package implementation.compare;
import com.intellij.openapi.application.ApplicationManager;
+import com.intellij.openapi.diagnostic.Logger;
import com.intellij.openapi.progress.ProgressIndicator;
import com.intellij.openapi.progress.Task;
import com.intellij.openapi.project.Project;
@@ -28,6 +29,7 @@
import java.util.function.Consumer;
public class ChangesService extends GitCompareWithRefAction {
+ private static final Logger LOG = Defs.getLogger(ChangesService.class);
public interface ErrorStateMarker {}
public static class ErrorStateList extends AbstractList implements ErrorStateMarker {
@Override public Change get(int index) { throw new IndexOutOfBoundsException(); }
@@ -79,41 +81,48 @@ public void run(@NotNull ProgressIndicator indicator) {
Collection repositories = currentGitService.getRepositories();
repositories.forEach(repo -> {
- String branchToCompare = getBranchToCompare(targetBranchByRepo, repo);
+ try {
+ String branchToCompare = getBranchToCompare(targetBranchByRepo, repo);
- // Use only repo path as cache key
- String cacheKey = repo.getRoot().getPath();
+ // Use only repo path as cache key
+ String cacheKey = repo.getRoot().getPath();
- Collection changesPerRepo = null;
+ Collection changesPerRepo = null;
- if (!checkFs && changesCache.containsKey(cacheKey)) {
- // Use cached changes if checkFs is false and cache exists
- changesPerRepo = changesCache.get(cacheKey);
- } else {
- // Fetch fresh changes
- changesPerRepo = doCollectChanges(currentProject, repo, branchToCompare);
+ if (!checkFs && changesCache.containsKey(cacheKey)) {
+ // Use cached changes if checkFs is false and cache exists
+ changesPerRepo = changesCache.get(cacheKey);
+ } else {
+ // Fetch fresh changes
+ changesPerRepo = doCollectChanges(currentProject, repo, branchToCompare);
- // Cache the results (but don't cache error states)
- if (!(changesPerRepo instanceof ErrorStateList)) {
- changesCache.put(cacheKey, new ArrayList<>(changesPerRepo)); // Store a copy to avoid modification issues
+ // Cache the results (but don't cache error states)
+ if (!(changesPerRepo instanceof ErrorStateList)) {
+ changesCache.put(cacheKey, new ArrayList<>(changesPerRepo)); // Store a copy to avoid modification issues
+ }
}
- }
- if (changesPerRepo instanceof ErrorStateList) {
- errorRepos.add(repo.getRoot().getPath());
- return; // Skip this repo but continue with others
- }
+ if (changesPerRepo instanceof ErrorStateList) {
+ errorRepos.add(repo.getRoot().getPath());
+ return; // Skip this repo but continue with others
+ }
- // Handle null case
- if (changesPerRepo == null) {
- changesPerRepo = new ArrayList<>();
- }
+ // Handle null case
+ if (changesPerRepo == null) {
+ changesPerRepo = new ArrayList<>();
+ }
- // Simple "merge" logic
- for (Change change : changesPerRepo) {
- if (!_changes.contains(change)) {
- _changes.add(change);
+ // Simple "merge" logic
+ for (Change change : changesPerRepo) {
+ if (!_changes.contains(change)) {
+ _changes.add(change);
+ }
}
+ } catch (Exception e) {
+ // Catch any unexpected errors from individual repo processing
+ // This ensures one bad repo doesn't crash the entire operation
+ LOG.warn("Unexpected error processing repository " + repo.getRoot().getPath(), e);
+ errorRepos.add(repo.getRoot().getPath());
}
});
@@ -283,7 +292,12 @@ public Collection doCollectChanges(Project project, GitRepository repo,
Collection additionalLocalChanges = filterLocalChanges(localChanges, repoPath, _changes);
_changes.addAll(additionalLocalChanges);
- } catch (VcsException ignored) {
+ } catch (VcsException e) {
+ // Log VCS errors (e.g., locked files, git command failures) but don't fail entirely
+ LOG.warn("Error collecting changes for repository " + repo.getRoot().getPath() + ": " + e.getMessage());
+ } catch (Exception e) {
+ // Catch any other unexpected errors (e.g., file system issues)
+ LOG.warn("Unexpected error collecting changes for repository " + repo.getRoot().getPath(), e);
}
return _changes;
}
diff --git a/src/main/java/utils/CustomRollback.java b/src/main/java/utils/CustomRollback.java
index dd4d95a..1673276 100644
--- a/src/main/java/utils/CustomRollback.java
+++ b/src/main/java/utils/CustomRollback.java
@@ -1,6 +1,7 @@
package utils;
import com.intellij.openapi.application.ApplicationManager;
+import com.intellij.openapi.diagnostic.Logger;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.ui.DialogWrapper;
import com.intellij.openapi.ui.Messages;
@@ -25,6 +26,7 @@
import org.jetbrains.annotations.NotNull;
import com.intellij.history.LocalHistory;
import com.intellij.openapi.util.io.FileUtil;
+import system.Defs;
import javax.swing.*;
import java.awt.*;
@@ -33,6 +35,7 @@
import java.util.function.Consumer;
public class CustomRollback {
+ private static final Logger LOG = Defs.getLogger(CustomRollback.class);
public void rollbackChanges(@NotNull Project project, @NotNull Change[] changes, String revisionString) {
AsyncChangesTreeImpl.Changes changesTree = new AsyncChangesTreeImpl.Changes(
@@ -271,6 +274,7 @@ public List revertToBeforeRev(@NotNull Project project, @NotNull List revertToBeforeRev(@NotNull Project project, @NotNull List revertToBeforeRev(@NotNull Project project, @NotNull List revertToBeforeRev(@NotNull Project project, @NotNull List revertToBeforeRev(@NotNull Project project, @NotNull List changes =
- GitChangeUtils.getDiffWithWorkingDir(project, repository.getRoot(), revisionNumber.toString(), Collections.singletonList(filePath), false);
- if (changes.isEmpty() && GitHistoryUtils.getCurrentRevision(project, filePath, revisionNumber.toString()) == null) {
- throw new VcsException("Could not get diff for base file:" + file + " and revision: " + revisionNumber);
- }
+ try {
+ Collection changes =
+ GitChangeUtils.getDiffWithWorkingDir(project, repository.getRoot(), revisionNumber.toString(), Collections.singletonList(filePath), false);
+
+ if (changes.isEmpty() && GitHistoryUtils.getCurrentRevision(project, filePath, revisionNumber.toString()) == null) {
+ throw new VcsException("Could not get diff for base file:" + file + " and revision: " + revisionNumber);
+ }
- ContentRevision contentRevision = GitContentRevision.createRevision(filePath, revisionNumber, project);
- return changes.isEmpty() && !filePath.isDirectory() ? createChangesWithCurrentContentForFile(filePath, contentRevision) : changes;
+ ContentRevision contentRevision = GitContentRevision.createRevision(filePath, revisionNumber, project);
+ return changes.isEmpty() && !filePath.isDirectory() ? createChangesWithCurrentContentForFile(filePath, contentRevision) : changes;
+ } catch (VcsException e) {
+ // Check if this is a file locking or access issue (common on Windows)
+ String message = e.getMessage();
+ if (message.contains("lock") || message.contains("unable to open") || message.contains("permission denied") || message.contains("access is denied")) {
+ LOG.warn("File access error (possibly locked file) in repository " + repository.getRoot().getPath() + ": " + message);
+ // Return empty collection to gracefully ignore this file
+ return Collections.emptyList();
+ }
+ // Re-throw other VcsExceptions
+ throw e;
+ }
}
}
From 612178be2656ac452e313d8807e2fc74290b733e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Magnus=20W=C3=A5llberg?=
Date: Sun, 28 Dec 2025 16:18:21 +0100
Subject: [PATCH 03/14] Make file colors in project explorer and tabs based on
active git scope.
Fixes comod/git-scope-pro#74
---
.../compare/ChangesService.java | 46 ++++++--
.../GitScopeFileStatusProvider.java | 100 ++++++++++++++++++
src/main/java/model/MyModel.java | 11 +-
src/main/java/service/ViewService.java | 71 ++++++++++++-
src/main/resources/META-INF/plugin.xml | 3 +
5 files changed, 220 insertions(+), 11 deletions(-)
create mode 100644 src/main/java/implementation/fileStatus/GitScopeFileStatusProvider.java
diff --git a/src/main/java/implementation/compare/ChangesService.java b/src/main/java/implementation/compare/ChangesService.java
index a21c322..fc917f2 100644
--- a/src/main/java/implementation/compare/ChangesService.java
+++ b/src/main/java/implementation/compare/ChangesService.java
@@ -30,14 +30,30 @@
public class ChangesService extends GitCompareWithRefAction {
private static final Logger LOG = Defs.getLogger(ChangesService.class);
+
public interface ErrorStateMarker {}
+
public static class ErrorStateList extends AbstractList implements ErrorStateMarker {
@Override public Change get(int index) { throw new IndexOutOfBoundsException(); }
@Override public int size() { return 0; }
@Override public String toString() { return "ERROR_STATE_SENTINEL"; }
@Override public boolean equals(Object o) { return o instanceof ErrorStateList; }
}
+
public static final Collection ERROR_STATE = new ErrorStateList();
+
+ /**
+ * Container for both merged changes and local changes towards HEAD.
+ */
+ public static class ChangesResult {
+ public final Collection mergedChanges; // Scope changes + local changes
+ public final Collection localChanges; // Local changes towards HEAD only
+
+ public ChangesResult(Collection mergedChanges, Collection localChanges) {
+ this.mergedChanges = mergedChanges;
+ this.localChanges = localChanges;
+ }
+ }
private final Project project;
private final GitService git;
private Task.Backgroundable task;
@@ -64,22 +80,27 @@ private static String getBranchToCompare(TargetBranchMap targetBranchByRepo, Git
// Cache for storing changes per repository
private final Map> changesCache = new ConcurrentHashMap<>();
- public void collectChangesWithCallback(TargetBranchMap targetBranchByRepo, Consumer> callBack, boolean checkFs) {
+ public void collectChangesWithCallback(TargetBranchMap targetBranchByRepo, Consumer callBack, boolean checkFs) {
// Capture the current project reference to ensure consistency
final Project currentProject = this.project;
final GitService currentGitService = this.git;
task = new Task.Backgroundable(currentProject, "Collecting " + Defs.APPLICATION_NAME, true) {
- private Collection changes;
+ private ChangesResult result;
@Override
public void run(@NotNull ProgressIndicator indicator) {
Collection _changes = new ArrayList<>();
+ Collection _localChanges = new ArrayList<>();
List errorRepos = new ArrayList<>();
Collection repositories = currentGitService.getRepositories();
+ // Get all local changes from ChangeListManager once
+ ChangeListManager changeListManager = ChangeListManager.getInstance(currentProject);
+ Collection allLocalChanges = changeListManager.getAllChanges();
+
repositories.forEach(repo -> {
try {
String branchToCompare = getBranchToCompare(targetBranchByRepo, repo);
@@ -112,12 +133,21 @@ public void run(@NotNull ProgressIndicator indicator) {
changesPerRepo = new ArrayList<>();
}
- // Simple "merge" logic
+ // Merge changes into the collection
for (Change change : changesPerRepo) {
if (!_changes.contains(change)) {
_changes.add(change);
}
}
+
+ // Also collect local changes for this repository
+ String repoPath = repo.getRoot().getPath();
+ Collection repoLocalChanges = filterLocalChanges(allLocalChanges, repoPath, null);
+ for (Change change : repoLocalChanges) {
+ if (!_localChanges.contains(change)) {
+ _localChanges.add(change);
+ }
+ }
} catch (Exception e) {
// Catch any unexpected errors from individual repo processing
// This ensures one bad repo doesn't crash the entire operation
@@ -128,19 +158,19 @@ public void run(@NotNull ProgressIndicator indicator) {
// Only return ERROR_STATE if ALL repositories failed
if (!errorRepos.isEmpty() && _changes.isEmpty()) {
- changes = ERROR_STATE;
+ result = new ChangesResult(ERROR_STATE, new ArrayList<>());
} else {
- changes = _changes;
+ result = new ChangesResult(_changes, _localChanges);
}
}
@Override
public void onSuccess() {
- // Ensure `changes` is accessed only on the UI thread to update the UI component
+ // Ensure result is accessed only on the UI thread to update the UI component
ApplicationManager.getApplication().invokeLater(() -> {
// Double-check the project is still valid
if (!currentProject.isDisposed() && callBack != null) {
- callBack.accept(this.changes);
+ callBack.accept(this.result);
}
});
}
@@ -149,7 +179,7 @@ public void onSuccess() {
public void onThrowable(@NotNull Throwable error) {
ApplicationManager.getApplication().invokeLater(() -> {
if (!currentProject.isDisposed() && callBack != null) {
- callBack.accept(ERROR_STATE);
+ callBack.accept(new ChangesResult(ERROR_STATE, new ArrayList<>()));
}
});
}
diff --git a/src/main/java/implementation/fileStatus/GitScopeFileStatusProvider.java b/src/main/java/implementation/fileStatus/GitScopeFileStatusProvider.java
new file mode 100644
index 0000000..1d3dd6d
--- /dev/null
+++ b/src/main/java/implementation/fileStatus/GitScopeFileStatusProvider.java
@@ -0,0 +1,100 @@
+package implementation.fileStatus;
+
+import com.intellij.openapi.project.Project;
+import com.intellij.openapi.vcs.FileStatus;
+import com.intellij.openapi.vcs.changes.Change;
+import com.intellij.openapi.vcs.impl.FileStatusProvider;
+import com.intellij.openapi.vfs.VirtualFile;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import service.ViewService;
+
+import java.util.Collection;
+
+/**
+ * Provides custom file status colors based on the active Git Scope tab
+ * instead of the default diff against HEAD.
+ *
+ * Strategy:
+ * 1. If file is in local changes towards HEAD → return null (let IntelliJ handle with gutter bars)
+ * 2. If file is in Git Scope but NOT in local changes → return our custom status
+ * 3. If file is not in scope → return null (let IntelliJ handle)
+ *
+ * This ensures that actively modified files get IntelliJ's default treatment (including gutter bars),
+ * while historical Git Scope files get our custom colors.
+ */
+public class GitScopeFileStatusProvider implements FileStatusProvider {
+
+ @Override
+ public @Nullable FileStatus getFileStatus(@NotNull VirtualFile virtualFile) {
+ // Get the project from the context
+ Project project = getProjectFromFile(virtualFile);
+ if (project == null || project.isDisposed()) {
+ return null;
+ }
+
+ // Get the ViewService to access current scope's changes
+ ViewService viewService = project.getService(ViewService.class);
+ if (viewService == null) {
+ return null;
+ }
+
+ String filePath = virtualFile.getPath();
+
+ // STRATEGY: If file is in local changes towards HEAD, let IntelliJ handle it
+ // This ensures gutter change bars work correctly for actively modified files
+ Collection localChanges = viewService.getLocalChangesTowardsHead();
+ if (localChanges != null) {
+ for (Change change : localChanges) {
+ VirtualFile changeFile = change.getVirtualFile();
+ if (changeFile != null && changeFile.getPath().equals(filePath)) {
+ // File is actively being modified - let IntelliJ's default provider handle it
+ return null;
+ }
+ }
+ }
+
+ // File is NOT in local changes - check if it's in the Git Scope
+ Collection scopeChanges = viewService.getCurrentScopeChanges();
+ if (scopeChanges == null || scopeChanges.isEmpty()) {
+ // No changes in scope - return null to fall back to default behavior
+ return null;
+ }
+
+ // Check if this file has changes in the current scope
+ for (Change change : scopeChanges) {
+ VirtualFile changeFile = change.getVirtualFile();
+ if (changeFile != null && changeFile.getPath().equals(filePath)) {
+ // File is in Git Scope but NOT in local changes - we control the color
+ // Use the FileStatus directly from the Change object
+ return change.getFileStatus();
+ }
+ }
+
+ // File not in scope changes - return null to let default provider handle it
+ return null;
+ }
+
+ /**
+ * Helper to get project from VirtualFile context.
+ * This is a workaround since FileStatusProvider doesn't pass project directly.
+ */
+ private @Nullable Project getProjectFromFile(@NotNull VirtualFile virtualFile) {
+ // FileStatusProvider is project-specific, so we can use ProjectManager
+ com.intellij.openapi.project.ProjectManager projectManager =
+ com.intellij.openapi.project.ProjectManager.getInstance();
+
+ for (com.intellij.openapi.project.Project project : projectManager.getOpenProjects()) {
+ if (project.isDisposed()) {
+ continue;
+ }
+ // Check if this file belongs to this project
+ String basePath = project.getBasePath();
+ if (basePath != null && virtualFile.getPath().startsWith(basePath)) {
+ return project;
+ }
+ }
+ return null;
+ }
+
+}
diff --git a/src/main/java/model/MyModel.java b/src/main/java/model/MyModel.java
index adda2c1..5046210 100644
--- a/src/main/java/model/MyModel.java
+++ b/src/main/java/model/MyModel.java
@@ -13,7 +13,8 @@
public class MyModel extends MyModelBase {
private final PublishSubject changeObservable = PublishSubject.create();
private final boolean isHeadTab;
- private Collection changes;
+ private Collection changes; // Merged changes (scope + local)
+ private Collection localChanges; // Local changes towards HEAD only
private boolean isActive;
private String customTabName; // Added field for custom tab name
@@ -101,6 +102,14 @@ public void setChanges(Collection changes) {
changeObservable.onNext(field.changes);
}
+ public Collection getLocalChanges() {
+ return localChanges;
+ }
+
+ public void setLocalChanges(Collection localChanges) {
+ this.localChanges = localChanges;
+ }
+
public Observable getObservable() {
return changeObservable;
}
diff --git a/src/main/java/service/ViewService.java b/src/main/java/service/ViewService.java
index 4750700..50ebfc6 100644
--- a/src/main/java/service/ViewService.java
+++ b/src/main/java/service/ViewService.java
@@ -4,6 +4,7 @@
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.vcs.changes.Change;
+import com.intellij.openapi.vfs.VirtualFile;
import com.intellij.ui.content.Content;
import com.intellij.ui.content.ContentManager;
import com.intellij.util.concurrency.SequentialTaskExecutor;
@@ -88,6 +89,41 @@ private void doUpdateDebounced(Collection changes) {
debouncer.debounce(Void.class, () -> onUpdate(changes), DEBOUNCE_MS, TimeUnit.MILLISECONDS);
}
+ /**
+ * Refreshes file status colors for files in the current scope and previous scope.
+ * Call this when the active scope changes to update colors based on new scope.
+ *
+ * This method refreshes files from BOTH the new active model and the previous active model to ensure:
+ * - Files in the new scope get the new colors
+ * - Files that were in the old scope but not in the new scope revert to default colors
+ *
+ */
+ public void refreshFileColors() {
+ if (project.isDisposed()) {
+ return;
+ }
+
+ // Execute file status refresh on background thread to avoid EDT slow operations
+ ApplicationManager.getApplication().executeOnPooledThread(() -> {
+ if (project.isDisposed()) {
+ return;
+ }
+
+ // Then update on EDT with bulk refresh
+ ApplicationManager.getApplication().invokeLater(() -> {
+ if (project.isDisposed()) {
+ return;
+ }
+
+ com.intellij.openapi.vcs.FileStatusManager fileStatusManager =
+ com.intellij.openapi.vcs.FileStatusManager.getInstance(project);
+
+ // Bulk update all file statuses
+ fileStatusManager.fileStatusesChanged();
+ });
+ });
+ }
+
public void load() {
// Load models from state
List collection = new ArrayList<>();
@@ -345,12 +381,18 @@ private void subscribeToObservable(MyModel model) {
if (model.isActive()) {
Collection changes = model.getChanges();
doUpdateDebounced(changes);
+ // Note: We don't manually refresh file colors here because:
+ // 1. It interferes with gutter change bars being set up
+ // 2. FileStatusProvider is automatically queried by IntelliJ when needed
+ // 3. Manual refresh causes race conditions with the line status tracker
}
}
// TODO: collectChanges: tab switched
case active -> {
incrementUpdate(); // Increment generation to cancel any stale updates for previous tab
collectChanges(model, true);
+ // Refresh file colors when switching tabs
+ refreshFileColors();
}
case tabName -> {
if (!isProcessingTabRename) {
@@ -470,13 +512,14 @@ private void collectChangesInternal(MyModel model, TargetBranchMap targetBranchM
// serialize collection behind a single-threaded executor
changesExecutor.execute(() -> {
- changesService.collectChangesWithCallback(finalTargetBranchMap, changes -> {
+ changesService.collectChangesWithCallback(finalTargetBranchMap, result -> {
ApplicationManager.getApplication().invokeLater(() -> {
try {
long currentGen = applyGeneration.get();
if (!project.isDisposed() && currentGen == gen) {
LOG.debug("Applying changes for generation " + gen);
- model.setChanges(changes);
+ model.setChanges(result.mergedChanges);
+ model.setLocalChanges(result.localChanges);
} else {
LOG.debug("Discarding changes for generation " + gen + " (current generation is " + currentGen + ")");
}
@@ -566,6 +609,30 @@ public Collection getCurrentScopeChanges() {
return current.getChanges();
}
+ /**
+ * Gets the local changes (modifications towards HEAD) for all repositories.
+ * These are files that are currently being modified and should use IntelliJ's default file coloring
+ * (including gutter change bars).
+ *
+ * Returns cached local changes from the current scope to avoid querying ChangeListManager repeatedly.
+ *
+ * @return Collection of local changes towards HEAD, or null if not initialized
+ */
+ public Collection getLocalChangesTowardsHead() {
+ // Early return if ViewService is not fully initialized yet
+ if (toolWindowService == null) {
+ return null;
+ }
+
+ MyModel current = getCurrent();
+ if (current == null) {
+ return null;
+ }
+
+ // Return the cached local changes
+ return current.getLocalChanges();
+ }
+
public void removeTab(int tabIndex) {
int modelIndex = getModelIndex(tabIndex);
// Check if the index is valid before removing
diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml
index f469215..c02537d 100644
--- a/src/main/resources/META-INF/plugin.xml
+++ b/src/main/resources/META-INF/plugin.xml
@@ -48,6 +48,9 @@
+
+
+
From 1053cb79982188ce0b37aa98b19aec9b421eb079 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Magnus=20W=C3=A5llberg?=
Date: Mon, 29 Dec 2025 11:35:12 +0100
Subject: [PATCH 04/14] Resore missing "Diff" popup selection
Fixes comod/git-scope-pro#76
---
.../java/toolwindow/elements/MySimpleChangesBrowser.java | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/src/main/java/toolwindow/elements/MySimpleChangesBrowser.java b/src/main/java/toolwindow/elements/MySimpleChangesBrowser.java
index 2362528..9580a4b 100644
--- a/src/main/java/toolwindow/elements/MySimpleChangesBrowser.java
+++ b/src/main/java/toolwindow/elements/MySimpleChangesBrowser.java
@@ -61,8 +61,11 @@ private MySimpleChangesBrowser(@NotNull Project project, @NotNull Collection e
@Override
protected @NotNull List createPopupMenuActions() {
- // Return the SAME static list instance every time to prevent toolbar recreation
- return STATIC_POPUP_ACTIONS;
+ // Include parent actions (which provide diff functionality) plus our custom actions
+ List actions = new ArrayList<>(super.createPopupMenuActions());
+ actions.add(SHOW_IN_PROJECT_ACTION);
+ actions.add(ROLLBACK_ACTION);
+ return actions;
}
@Override
From 13b6561a066b4e4dcc55f1a14160fc50e16d5490 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Magnus=20W=C3=A5llberg?=
Date: Tue, 30 Dec 2025 02:10:59 +0100
Subject: [PATCH 05/14] Performance optimization of tab and project scope
coloring.
---
.../GitScopeFileStatusProvider.java | 35 ++++++--------
src/main/java/model/MyModel.java | 36 ++++++++++++++
src/main/java/service/ViewService.java | 47 ++++++++++---------
3 files changed, 75 insertions(+), 43 deletions(-)
diff --git a/src/main/java/implementation/fileStatus/GitScopeFileStatusProvider.java b/src/main/java/implementation/fileStatus/GitScopeFileStatusProvider.java
index 1d3dd6d..018ea01 100644
--- a/src/main/java/implementation/fileStatus/GitScopeFileStatusProvider.java
+++ b/src/main/java/implementation/fileStatus/GitScopeFileStatusProvider.java
@@ -9,7 +9,7 @@
import org.jetbrains.annotations.Nullable;
import service.ViewService;
-import java.util.Collection;
+import java.util.Map;
/**
* Provides custom file status colors based on the active Git Scope tab
@@ -43,32 +43,27 @@ public class GitScopeFileStatusProvider implements FileStatusProvider {
// STRATEGY: If file is in local changes towards HEAD, let IntelliJ handle it
// This ensures gutter change bars work correctly for actively modified files
- Collection localChanges = viewService.getLocalChangesTowardsHead();
- if (localChanges != null) {
- for (Change change : localChanges) {
- VirtualFile changeFile = change.getVirtualFile();
- if (changeFile != null && changeFile.getPath().equals(filePath)) {
- // File is actively being modified - let IntelliJ's default provider handle it
- return null;
- }
- }
+ // Use HashMap lookup for O(1) performance instead of iterating through all changes
+ Map localChangesMap = viewService.getLocalChangesTowardsHeadMap();
+ if (localChangesMap != null && localChangesMap.containsKey(filePath)) {
+ // File is actively being modified - let IntelliJ's default provider handle it
+ return null;
}
// File is NOT in local changes - check if it's in the Git Scope
- Collection scopeChanges = viewService.getCurrentScopeChanges();
- if (scopeChanges == null || scopeChanges.isEmpty()) {
+ // Use HashMap lookup for O(1) performance instead of iterating through all changes
+ Map scopeChangesMap = viewService.getCurrentScopeChangesMap();
+ if (scopeChangesMap == null || scopeChangesMap.isEmpty()) {
// No changes in scope - return null to fall back to default behavior
return null;
}
- // Check if this file has changes in the current scope
- for (Change change : scopeChanges) {
- VirtualFile changeFile = change.getVirtualFile();
- if (changeFile != null && changeFile.getPath().equals(filePath)) {
- // File is in Git Scope but NOT in local changes - we control the color
- // Use the FileStatus directly from the Change object
- return change.getFileStatus();
- }
+ // Check if this file has changes in the current scope using O(1) lookup
+ Change change = scopeChangesMap.get(filePath);
+ if (change != null) {
+ // File is in Git Scope but NOT in local changes - we control the color
+ // Use the FileStatus directly from the Change object
+ return change.getFileStatus();
}
// File not in scope changes - return null to let default provider handle it
diff --git a/src/main/java/model/MyModel.java b/src/main/java/model/MyModel.java
index 5046210..b822e31 100644
--- a/src/main/java/model/MyModel.java
+++ b/src/main/java/model/MyModel.java
@@ -1,6 +1,7 @@
package model;
import com.intellij.openapi.vcs.changes.Change;
+import com.intellij.openapi.vfs.VirtualFile;
import git4idea.repo.GitRepository;
import io.reactivex.rxjava3.core.Observable;
import io.reactivex.rxjava3.subjects.PublishSubject;
@@ -8,6 +9,7 @@
import service.GitService;
import java.util.Collection;
+import java.util.HashMap;
import java.util.Map;
public class MyModel extends MyModelBase {
@@ -15,6 +17,8 @@ public class MyModel extends MyModelBase {
private final boolean isHeadTab;
private Collection changes; // Merged changes (scope + local)
private Collection localChanges; // Local changes towards HEAD only
+ private Map changesMap; // Cached map of changes by file path
+ private Map localChangesMap; // Cached map of local changes by file path
private boolean isActive;
private String customTabName; // Added field for custom tab name
@@ -99,6 +103,7 @@ public Collection getChanges() {
public void setChanges(Collection changes) {
this.changes = changes;
+ this.changesMap = buildChangesByPathMap(changes);
changeObservable.onNext(field.changes);
}
@@ -108,6 +113,37 @@ public Collection getLocalChanges() {
public void setLocalChanges(Collection localChanges) {
this.localChanges = localChanges;
+ this.localChangesMap = buildChangesByPathMap(localChanges);
+ }
+
+ public Map getChangesMap() {
+ return changesMap;
+ }
+
+ public Map getLocalChangesMap() {
+ return localChangesMap;
+ }
+
+ /**
+ * Helper method to build a HashMap from a collection of changes indexed by file path.
+ * This provides O(1) lookup performance for file status checks.
+ *
+ * @param changes Collection of changes to convert to a map
+ * @return Map of file path to Change, or null if changes is null
+ */
+ private Map buildChangesByPathMap(Collection changes) {
+ if (changes == null) {
+ return null;
+ }
+
+ Map changeMap = new HashMap<>();
+ for (Change change : changes) {
+ VirtualFile file = change.getVirtualFile();
+ if (file != null) {
+ changeMap.put(file.getPath(), change);
+ }
+ }
+ return changeMap;
}
public Observable getObservable() {
diff --git a/src/main/java/service/ViewService.java b/src/main/java/service/ViewService.java
index 50ebfc6..f683fae 100644
--- a/src/main/java/service/ViewService.java
+++ b/src/main/java/service/ViewService.java
@@ -25,13 +25,16 @@
import java.awt.*;
import java.util.ArrayList;
import java.util.Collection;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Consumer;
+import java.util.function.Function;
public class ViewService implements Disposable {
private static final com.intellij.openapi.diagnostic.Logger LOG = Defs.getLogger(ViewService.class);
@@ -591,12 +594,13 @@ public void setCollection(List collection) {
}
/**
- * Gets the changes from the currently active scope/tab.
- * Used by FileStatusProvider to color files based on active scope.
+ * Core private method to retrieve a cached HashMap from the current MyModel.
+ * Handles initialization checks and returns the pre-built map.
*
- * @return Collection of changes in current scope, or null if no active scope or not initialized
+ * @param mapGetter Function to retrieve the cached map from MyModel
+ * @return Map of file path to Change, or null if not initialized
*/
- public Collection getCurrentScopeChanges() {
+ private Map getChangesMapInternal(Function> mapGetter) {
// Early return if ViewService is not fully initialized yet
if (toolWindowService == null) {
return null;
@@ -606,31 +610,28 @@ public Collection getCurrentScopeChanges() {
if (current == null) {
return null;
}
- return current.getChanges();
+
+ return mapGetter.apply(current);
}
/**
- * Gets the local changes (modifications towards HEAD) for all repositories.
- * These are files that are currently being modified and should use IntelliJ's default file coloring
- * (including gutter change bars).
- *
- * Returns cached local changes from the current scope to avoid querying ChangeListManager repeatedly.
+ * Gets a HashMap of local changes indexed by file path for O(1) lookup.
+ * Returns the cached map that was built when changes were set.
*
- * @return Collection of local changes towards HEAD, or null if not initialized
+ * @return Map of file path to Change, or null if not initialized
*/
- public Collection getLocalChangesTowardsHead() {
- // Early return if ViewService is not fully initialized yet
- if (toolWindowService == null) {
- return null;
- }
-
- MyModel current = getCurrent();
- if (current == null) {
- return null;
- }
+ public Map getLocalChangesTowardsHeadMap() {
+ return getChangesMapInternal(MyModel::getLocalChangesMap);
+ }
- // Return the cached local changes
- return current.getLocalChanges();
+ /**
+ * Gets a HashMap of scope changes indexed by file path for O(1) lookup.
+ * Returns the cached map that was built when changes were set.
+ *
+ * @return Map of file path to Change, or null if not initialized
+ */
+ public Map getCurrentScopeChangesMap() {
+ return getChangesMapInternal(MyModel::getChangesMap);
}
public void removeTab(int tabIndex) {
From dfdd1cdb3de89b209ac0f4d6b33edc72586df2db Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Magnus=20W=C3=A5llberg?=
Date: Tue, 30 Dec 2025 02:23:39 +0100
Subject: [PATCH 06/14] Additional code cleanups.
---
.../implementation/compare/ChangesService.java | 14 +++++---------
.../lineStatusTracker/CommitDiffWorkaround.java | 1 -
.../MyLineStatusTrackerImpl.java | 2 --
src/main/java/model/MyModel.java | 4 ++--
src/main/java/model/TargetBranchMap.java | 17 ++++-------------
src/main/java/service/TargetBranchService.java | 2 +-
src/main/java/service/ViewService.java | 6 ++----
src/main/java/state/WindowPositionTracker.java | 15 +++------------
src/main/java/toolwindow/BranchSelectView.java | 2 +-
src/main/java/toolwindow/elements/VcsTree.java | 2 +-
10 files changed, 19 insertions(+), 46 deletions(-)
diff --git a/src/main/java/implementation/compare/ChangesService.java b/src/main/java/implementation/compare/ChangesService.java
index fc917f2..06b2791 100644
--- a/src/main/java/implementation/compare/ChangesService.java
+++ b/src/main/java/implementation/compare/ChangesService.java
@@ -44,15 +44,11 @@ public static class ErrorStateList extends AbstractList implements Error
/**
* Container for both merged changes and local changes towards HEAD.
+ *
+ * @param mergedChanges Scope changes + local changes
+ * @param localChanges Local changes towards HEAD only
*/
- public static class ChangesResult {
- public final Collection mergedChanges; // Scope changes + local changes
- public final Collection localChanges; // Local changes towards HEAD only
-
- public ChangesResult(Collection mergedChanges, Collection localChanges) {
- this.mergedChanges = mergedChanges;
- this.localChanges = localChanges;
- }
+ public record ChangesResult(Collection mergedChanges, Collection localChanges) {
}
private final Project project;
private final GitService git;
@@ -69,7 +65,7 @@ private static String getBranchToCompare(TargetBranchMap targetBranchByRepo, Git
if (targetBranchByRepo == null) {
branchToCompare = GitService.BRANCH_HEAD;
} else {
- branchToCompare = targetBranchByRepo.getValue().get(repo.toString());
+ branchToCompare = targetBranchByRepo.value().get(repo.toString());
}
if (branchToCompare == null) {
branchToCompare = GitService.BRANCH_HEAD;
diff --git a/src/main/java/implementation/lineStatusTracker/CommitDiffWorkaround.java b/src/main/java/implementation/lineStatusTracker/CommitDiffWorkaround.java
index 5691e7d..00133dd 100644
--- a/src/main/java/implementation/lineStatusTracker/CommitDiffWorkaround.java
+++ b/src/main/java/implementation/lineStatusTracker/CommitDiffWorkaround.java
@@ -11,7 +11,6 @@
import com.intellij.openapi.fileEditor.FileEditorManager;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.util.text.StringUtil;
-import com.intellij.openapi.vcs.VcsException;
import com.intellij.openapi.vcs.changes.Change;
import com.intellij.openapi.vcs.changes.ChangeListManager;
import com.intellij.openapi.vcs.changes.ContentRevision;
diff --git a/src/main/java/implementation/lineStatusTracker/MyLineStatusTrackerImpl.java b/src/main/java/implementation/lineStatusTracker/MyLineStatusTrackerImpl.java
index b620b88..ba6d18d 100644
--- a/src/main/java/implementation/lineStatusTracker/MyLineStatusTrackerImpl.java
+++ b/src/main/java/implementation/lineStatusTracker/MyLineStatusTrackerImpl.java
@@ -8,9 +8,7 @@
import com.intellij.openapi.editor.EditorKind;
import com.intellij.openapi.editor.event.EditorFactoryEvent;
import com.intellij.openapi.editor.event.EditorFactoryListener;
-import com.intellij.openapi.editor.ex.DocumentEx;
import com.intellij.util.DocumentUtil;
-import com.intellij.openapi.editor.ex.EditorGutterComponentEx;
import com.intellij.openapi.fileEditor.FileDocumentManager;
import com.intellij.openapi.fileEditor.FileEditor;
import com.intellij.openapi.fileEditor.FileEditorManager;
diff --git a/src/main/java/model/MyModel.java b/src/main/java/model/MyModel.java
index b822e31..bac023b 100644
--- a/src/main/java/model/MyModel.java
+++ b/src/main/java/model/MyModel.java
@@ -155,7 +155,7 @@ public boolean isNew() {
if (targetBranchMap == null) {
return true;
}
- return targetBranchMap.getValue().isEmpty();
+ return targetBranchMap.value().isEmpty();
}
public boolean isActive() {
@@ -181,7 +181,7 @@ public enum field {
private String getFirstBranchValue() {
TargetBranchMap branchMap = getTargetBranchMap();
if (branchMap == null) return null;
- Map values = branchMap.getValue();
+ Map values = branchMap.value();
if (values == null || values.isEmpty()) return null;
for (String v : values.values()) {
if (v != null && !v.trim().isEmpty()) {
diff --git a/src/main/java/model/TargetBranchMap.java b/src/main/java/model/TargetBranchMap.java
index 44e71f1..c2fa091 100644
--- a/src/main/java/model/TargetBranchMap.java
+++ b/src/main/java/model/TargetBranchMap.java
@@ -3,25 +3,16 @@
import java.util.HashMap;
import java.util.Map;
-public class TargetBranchMap {
- /**
- * Repo, BranchToCompare
- **/
- public final Map value;
-
- public TargetBranchMap(Map targetBranch) {
- this.value = targetBranch;
- }
+/**
+ * @param value Repo, BranchToCompare
+ */
+public record TargetBranchMap(Map value) {
public static TargetBranchMap create() {
Map map = new HashMap<>();
return new TargetBranchMap(map);
}
- public Map getValue() {
- return value;
- }
-
public void add(String repo, String branch) {
this.value.put(repo, branch);
}
diff --git a/src/main/java/service/TargetBranchService.java b/src/main/java/service/TargetBranchService.java
index e794571..14ad857 100644
--- a/src/main/java/service/TargetBranchService.java
+++ b/src/main/java/service/TargetBranchService.java
@@ -77,7 +77,7 @@ public String getTargetBranchByRepository(GitRepository repo, TargetBranchMap re
return null;
}
- return repositoryTargetBranchMap.getValue().get(repo.toString());
+ return repositoryTargetBranchMap.value().get(repo.toString());
}
}
\ No newline at end of file
diff --git a/src/main/java/service/ViewService.java b/src/main/java/service/ViewService.java
index f683fae..c059ed3 100644
--- a/src/main/java/service/ViewService.java
+++ b/src/main/java/service/ViewService.java
@@ -4,7 +4,6 @@
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.vcs.changes.Change;
-import com.intellij.openapi.vfs.VirtualFile;
import com.intellij.ui.content.Content;
import com.intellij.ui.content.ContentManager;
import com.intellij.util.concurrency.SequentialTaskExecutor;
@@ -25,7 +24,6 @@
import java.awt.*;
import java.util.ArrayList;
import java.util.Collection;
-import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
@@ -521,8 +519,8 @@ private void collectChangesInternal(MyModel model, TargetBranchMap targetBranchM
long currentGen = applyGeneration.get();
if (!project.isDisposed() && currentGen == gen) {
LOG.debug("Applying changes for generation " + gen);
- model.setChanges(result.mergedChanges);
- model.setLocalChanges(result.localChanges);
+ model.setChanges(result.mergedChanges());
+ model.setLocalChanges(result.localChanges());
} else {
LOG.debug("Discarding changes for generation " + gen + " (current generation is " + currentGen + ")");
}
diff --git a/src/main/java/state/WindowPositionTracker.java b/src/main/java/state/WindowPositionTracker.java
index 78e2ed2..42f77a6 100644
--- a/src/main/java/state/WindowPositionTracker.java
+++ b/src/main/java/state/WindowPositionTracker.java
@@ -694,19 +694,10 @@ public void keyTyped(KeyEvent e) {
}
// Simple data class to hold scroll position
- public static class ScrollPosition {
- public final int verticalValue;
- public final int horizontalValue;
- public final boolean isValid;
-
- public ScrollPosition(int vertical, int horizontal, boolean valid) {
- this.verticalValue = vertical;
- this.horizontalValue = horizontal;
- this.isValid = valid;
- }
+ public record ScrollPosition(int verticalValue, int horizontalValue, boolean isValid) {
public static ScrollPosition invalid() {
- return new ScrollPosition(0, 0, false);
+ return new ScrollPosition(0, 0, false);
+ }
}
- }
}
\ No newline at end of file
diff --git a/src/main/java/toolwindow/BranchSelectView.java b/src/main/java/toolwindow/BranchSelectView.java
index 7be8847..c68b611 100644
--- a/src/main/java/toolwindow/BranchSelectView.java
+++ b/src/main/java/toolwindow/BranchSelectView.java
@@ -32,7 +32,7 @@ public class BranchSelectView {
private final Project project;
private final GitService gitService;
private final State state;
- private SearchTextField search;
+ private final SearchTextField search;
private JPanel createManualInputPanel(GitRepository repository, BranchTree branchTree) {
JPanel manualInputPanel = new JPanel(new BorderLayout());
diff --git a/src/main/java/toolwindow/elements/VcsTree.java b/src/main/java/toolwindow/elements/VcsTree.java
index c44f47d..67a3325 100644
--- a/src/main/java/toolwindow/elements/VcsTree.java
+++ b/src/main/java/toolwindow/elements/VcsTree.java
@@ -361,7 +361,7 @@ private void setComponent(Component component) {
if (positionTracker.isScrollPositionRestored()) {
ScrollPosition currentPosition = positionTracker.saveScrollPosition();
- if (currentPosition.isValid) {
+ if (currentPosition.isValid()) {
positionTracker.setSavedScrollPosition(currentTabId, currentPosition);
}
}
From 24b3b94380649b80337bc48577ea24eed5bb2270 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Magnus=20W=C3=A5llberg?=
Date: Tue, 30 Dec 2025 02:53:01 +0100
Subject: [PATCH 07/14] Ensure tab coloring is applied at startup.
---
src/main/java/service/ViewService.java | 15 ++++++++++-----
1 file changed, 10 insertions(+), 5 deletions(-)
diff --git a/src/main/java/service/ViewService.java b/src/main/java/service/ViewService.java
index c059ed3..ddbe76f 100644
--- a/src/main/java/service/ViewService.java
+++ b/src/main/java/service/ViewService.java
@@ -60,6 +60,7 @@ public class ViewService implements Disposable {
private int lastTabIndex;
private Integer savedTabIndex;
private final AtomicBoolean tabInitializationInProgress = new AtomicBoolean(false);
+ private final AtomicBoolean initialFileColorsRefreshed = new AtomicBoolean(false);
public ViewService(Project project) {
this.project = project;
@@ -92,7 +93,7 @@ private void doUpdateDebounced(Collection changes) {
/**
* Refreshes file status colors for files in the current scope and previous scope.
- * Call this when the active scope changes to update colors based on new scope.
+ * Call this when the active scope changes to update colors.
*
* This method refreshes files from BOTH the new active model and the previous active model to ensure:
* - Files in the new scope get the new colors
@@ -382,10 +383,14 @@ private void subscribeToObservable(MyModel model) {
if (model.isActive()) {
Collection changes = model.getChanges();
doUpdateDebounced(changes);
- // Note: We don't manually refresh file colors here because:
- // 1. It interferes with gutter change bars being set up
- // 2. FileStatusProvider is automatically queried by IntelliJ when needed
- // 3. Manual refresh causes race conditions with the line status tracker
+
+ // Refresh file colors once on initial startup after changes are loaded
+ // This ensures proper file coloring is applied when IDE starts
+ if (!initialFileColorsRefreshed.get() && changes != null && !changes.isEmpty()) {
+ if (initialFileColorsRefreshed.compareAndSet(false, true)) {
+ refreshFileColors();
+ }
+ }
}
}
// TODO: collectChanges: tab switched
From df8c12da233202d19725c8d360b8e51764bf917c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Magnus=20W=C3=A5llberg?=
Date: Tue, 30 Dec 2025 03:07:52 +0100
Subject: [PATCH 08/14] Dynamic plugin changes
---
.../compare/ChangesService.java | 11 ++-
.../listener/MyDynamicPluginListener.java | 67 +++++++++++++++++--
src/main/java/service/StatusBarService.java | 14 +++-
src/main/java/service/ToolWindowService.java | 9 ++-
src/main/java/service/ViewService.java | 23 +++++++
src/main/resources/META-INF/plugin.xml | 2 +-
6 files changed, 112 insertions(+), 14 deletions(-)
diff --git a/src/main/java/implementation/compare/ChangesService.java b/src/main/java/implementation/compare/ChangesService.java
index 06b2791..6aea19e 100644
--- a/src/main/java/implementation/compare/ChangesService.java
+++ b/src/main/java/implementation/compare/ChangesService.java
@@ -1,5 +1,6 @@
package implementation.compare;
+import com.intellij.openapi.Disposable;
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.diagnostic.Logger;
import com.intellij.openapi.progress.ProgressIndicator;
@@ -28,7 +29,7 @@
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
-public class ChangesService extends GitCompareWithRefAction {
+public class ChangesService extends GitCompareWithRefAction implements Disposable {
private static final Logger LOG = Defs.getLogger(ChangesService.class);
public interface ErrorStateMarker {}
@@ -183,11 +184,17 @@ public void onThrowable(@NotNull Throwable error) {
task.queue();
}
+ @Override
+ public void dispose() {
+ // Clear cache to release memory
+ clearCache();
+ }
+
// Method to clear cache when needed
public void clearCache() {
changesCache.clear();
}
-
+
// Method to clear cache for specific repo
public void clearCache(GitRepository repo) {
String cacheKey = repo.getRoot().getPath();
diff --git a/src/main/java/listener/MyDynamicPluginListener.java b/src/main/java/listener/MyDynamicPluginListener.java
index 67e52f8..a85a627 100644
--- a/src/main/java/listener/MyDynamicPluginListener.java
+++ b/src/main/java/listener/MyDynamicPluginListener.java
@@ -2,37 +2,90 @@
import com.intellij.ide.plugins.DynamicPluginListener;
import com.intellij.ide.plugins.IdeaPluginDescriptor;
+import com.intellij.openapi.diagnostic.Logger;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.project.ProjectManager;
import org.jetbrains.annotations.NotNull;
import service.ViewService;
+import implementation.compare.ChangesService;
+import system.Defs;
+/**
+ * Handles dynamic plugin loading and unloading events.
+ * This listener ensures proper cleanup when the plugin is unloaded/updated
+ * and proper initialization when the plugin is loaded.
+ */
public class MyDynamicPluginListener implements DynamicPluginListener {
+ private static final Logger LOG = Defs.getLogger(MyDynamicPluginListener.class);
public MyDynamicPluginListener() {
}
@Override
public void beforePluginUnload(@NotNull IdeaPluginDescriptor pluginDescriptor, boolean isUpdate) {
+ // Only handle our own plugin
+ if (!isOurPlugin(pluginDescriptor)) {
+ return;
+ }
+
+ LOG.info("Git Scope plugin unloading" + (isUpdate ? " (update)" : ""));
+
for (Project project : ProjectManager.getInstance().getOpenProjects()) {
if (project.isDisposed()) continue;
- ViewService viewService = project.getService(ViewService.class);
- if (viewService != null) {
- // do whatever is needed per project before unload
+
+ try {
+ // Save state before unloading
+ ViewService viewService = project.getService(ViewService.class);
+ if (viewService != null) {
+ viewService.save();
+ }
+
+ // Clear caches in ChangesService
+ ChangesService changesService = project.getService(ChangesService.class);
+ if (changesService != null) {
+ changesService.clearCache();
+ }
+
+ LOG.debug("Successfully prepared project '" + project.getName() + "' for plugin unload");
+ } catch (Exception e) {
+ LOG.error("Error preparing project for plugin unload: " + project.getName(), e);
}
}
}
@Override
public void pluginLoaded(@NotNull IdeaPluginDescriptor pluginDescriptor) {
+ // Only handle our own plugin
+ if (!isOurPlugin(pluginDescriptor)) {
+ return;
+ }
+
+ LOG.info("Git Scope plugin loaded");
+
for (Project project : ProjectManager.getInstance().getOpenProjects()) {
if (project.isDisposed()) continue;
- ViewService viewService = project.getService(ViewService.class);
- if (viewService != null) {
- // do whatever is needed per project after load
+
+ try {
+ // Reinitialize services if needed
+ ViewService viewService = project.getService(ViewService.class);
+ if (viewService != null) {
+ // Services are automatically initialized by the platform
+ // No explicit reinitialization needed
+ }
+
+ LOG.debug("Successfully initialized plugin for project: " + project.getName());
+ } catch (Exception e) {
+ LOG.error("Error initializing plugin for project: " + project.getName(), e);
}
}
}
-
+ /**
+ * Checks if the plugin descriptor refers to our plugin
+ */
+ private boolean isOurPlugin(@NotNull IdeaPluginDescriptor pluginDescriptor) {
+ String pluginId = pluginDescriptor.getPluginId().getIdString();
+ // Match the plugin ID from plugin.xml
+ return "Git Scope".equals(pluginId);
+ }
}
diff --git a/src/main/java/service/StatusBarService.java b/src/main/java/service/StatusBarService.java
index 06d908f..4c9de79 100644
--- a/src/main/java/service/StatusBarService.java
+++ b/src/main/java/service/StatusBarService.java
@@ -1,9 +1,10 @@
package service;
+import com.intellij.openapi.Disposable;
import statusBar.MyStatusBarPanel;
-public class StatusBarService {
- private final MyStatusBarPanel panel;
+public class StatusBarService implements Disposable {
+ private MyStatusBarPanel panel;
public StatusBarService() {
this.panel = new MyStatusBarPanel();
@@ -14,6 +15,13 @@ public MyStatusBarPanel getPanel() {
}
public void updateText(String text) {
- panel.updateText(text);
+ if (panel != null) {
+ panel.updateText(text);
+ }
+ }
+
+ @Override
+ public void dispose() {
+ panel = null;
}
}
diff --git a/src/main/java/service/ToolWindowService.java b/src/main/java/service/ToolWindowService.java
index b86d5bc..8d694cf 100644
--- a/src/main/java/service/ToolWindowService.java
+++ b/src/main/java/service/ToolWindowService.java
@@ -1,5 +1,6 @@
package service;
+import com.intellij.openapi.Disposable;
import com.intellij.openapi.components.Service;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.vfs.VirtualFile;
@@ -21,7 +22,7 @@
import java.util.Map;
@Service(Service.Level.PROJECT)
-public final class ToolWindowService implements ToolWindowServiceInterface {
+public final class ToolWindowService implements ToolWindowServiceInterface, Disposable {
private final Project project;
private final Map contentToViewMap = new HashMap<>();
private final TabOperations tabOperations;
@@ -31,6 +32,12 @@ public ToolWindowService(Project project) {
this.tabOperations = new TabOperations(project);
}
+ @Override
+ public void dispose() {
+ // Clear the content to view map to release memory
+ contentToViewMap.clear();
+ }
+
@Override
public void removeAllTabs() {
getContentManager().removeAllContents(true);
diff --git a/src/main/java/service/ViewService.java b/src/main/java/service/ViewService.java
index ddbe76f..9d586af 100644
--- a/src/main/java/service/ViewService.java
+++ b/src/main/java/service/ViewService.java
@@ -73,6 +73,29 @@ public ViewService(Project project) {
@Override
public void dispose() {
+ // Shutdown the executor service to prevent memory leaks
+ if (changesExecutor != null && !changesExecutor.isShutdown()) {
+ changesExecutor.shutdown();
+ try {
+ if (!changesExecutor.awaitTermination(5, TimeUnit.SECONDS)) {
+ changesExecutor.shutdownNow();
+ }
+ } catch (InterruptedException e) {
+ changesExecutor.shutdownNow();
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ // Clear all collections to release memory
+ if (collection != null) {
+ collection.clear();
+ }
+
+ // Dispose of the line status tracker if it has dispose logic
+ myLineStatusTrackerImpl = null;
+ myScope = null;
+ debouncer = null;
+ myHeadModel = null;
}
public void initDependencies() {
diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml
index c02537d..3b4defe 100644
--- a/src/main/resources/META-INF/plugin.xml
+++ b/src/main/resources/META-INF/plugin.xml
@@ -1,5 +1,5 @@
-
+Git ScopeGit ScopeWOELKIT, M.Wållberg
From 5118ab8686ca7ff2dfe10fedb1b920e3961381a4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Magnus=20W=C3=A5llberg?=
Date: Tue, 30 Dec 2025 13:14:29 +0100
Subject: [PATCH 09/14] Proper cleanup of plugin udispose
---
CLASSES.md | 147 ++++++++++++++++++
.../compare/ChangesService.java | 36 +++--
.../GitScopeFileStatusProvider.java | 7 +-
.../CommitDiffWorkaround.java | 14 +-
.../MyLineStatusTrackerImpl.java | 140 ++++++++++-------
.../java/implementation/scope/MyScope.java | 18 +++
.../listener/MyDynamicPluginListener.java | 10 +-
src/main/java/model/Debounce.java | 29 +++-
src/main/java/service/ToolWindowService.java | 39 ++++-
src/main/java/service/ViewService.java | 127 +++++++++++----
.../java/statusBar/MyStatusBarWidget.java | 6 +-
.../java/toolwindow/BranchSelectView.java | 15 ++
src/main/java/toolwindow/TabOperations.java | 80 +++++++---
src/main/java/toolwindow/ToolWindowView.java | 43 ++++-
.../elements/MySimpleChangesBrowser.java | 83 +++++-----
.../java/toolwindow/elements/VcsTree.java | 37 +++--
src/main/java/utils/CustomRollback.java | 5 +-
src/main/resources/META-INF/plugin.xml | 3 +-
18 files changed, 648 insertions(+), 191 deletions(-)
create mode 100644 CLASSES.md
diff --git a/CLASSES.md b/CLASSES.md
new file mode 100644
index 0000000..b830c33
--- /dev/null
+++ b/CLASSES.md
@@ -0,0 +1,147 @@
+# Plugin Classes to Check in HPROF Analysis
+
+This document lists all plugin classes to search for when analyzing heap dumps to identify memory leaks after plugin unload. All classes should have **count = 0** after successful plugin unload.
+
+## Services
+*Expected: 1 instance per project, or 0 after unload*
+
+- `service.ViewService`
+- `service.ToolWindowService`
+- `service.ToolWindowServiceInterface` *(interface - unlikely to leak but check if implemented by plugin classes)*
+- `service.StatusBarService`
+- `service.GitService`
+- `service.TargetBranchService`
+- `implementation.compare.ChangesService`
+
+## State/Persistence
+
+- `state.State`
+- `state.MyModelConverter`
+- `state.WindowPositionTracker`
+
+## Listeners
+*Expected: 0 after unload*
+
+- `listener.MyBulkFileListener`
+- `listener.MyDynamicPluginListener`
+- `listener.MyToolWindowListener`
+- `listener.VcsStartup`
+- `listener.MyChangeListListener`
+- `listener.MyGitRepositoryChangeListener`
+- `listener.MyFileEditorManagerListener`
+- `listener.MyTabContentListener`
+- `listener.MyTreeSelectionListener`
+- `listener.ToggleHeadAction`
+- `listener.VcsContextMenuAction`
+
+## UI Components
+
+### Main Components
+- `toolwindow.ToolWindowView`
+- `toolwindow.ToolWindowUIFactory`
+- `toolwindow.BranchSelectView`
+- `toolwindow.TabOperations`
+- `toolwindow.TabMoveActions`
+- `toolwindow.TabMoveActions$MoveTabLeft`
+- `toolwindow.TabMoveActions$MoveTabRight`
+- `toolwindow.VcsTreeActions`
+
+### UI Elements
+- `toolwindow.elements.VcsTree`
+- `toolwindow.elements.BranchTree`
+- `toolwindow.elements.BranchTreeEntry`
+- `toolwindow.elements.MySimpleChangesBrowser`
+- `toolwindow.elements.CurrentBranch`
+- `toolwindow.elements.TargetBranch`
+
+## Status Bar
+
+- `statusBar.MyStatusBarWidget`
+- `statusBar.MyStatusBarWidgetFactory`
+- `statusBar.MyStatusBarPanel`
+
+## Models
+
+- `model.MyModel`
+- `model.MyModel$field` *(enum - check for RxJava subscription leaks)*
+- `model.MyModelBase`
+- `model.TargetBranchMap`
+- `model.Debounce`
+
+## Implementation Classes
+
+### Line Status Tracker
+- `implementation.lineStatusTracker.MyLineStatusTrackerImpl`
+- `implementation.lineStatusTracker.CommitDiffWorkaround`
+
+### Scope
+- `implementation.scope.MyScope`
+- `implementation.scope.MyPackageSet` *(registered with NamedScopeManager - critical leak if not unregistered)*
+- `implementation.scope.MyScopeInTarget`
+- `implementation.scope.MyScopeNameSupplier`
+
+### File Status
+- `implementation.fileStatus.GitScopeFileStatusProvider`
+
+## Utility Classes
+
+- `utils.CustomRollback`
+- `utils.GitCommitReflection`
+- `utils.GitUtil`
+- `utils.Notification`
+- `system.Defs`
+
+## Anonymous/Inner Classes to Look For
+
+*These are patterns - search for classes matching these names:*
+
+- `TabOperations$1` *(rename action)*
+- `TabOperations$2` *(reset action)*
+- `TabOperations$3` *(move left action)*
+- `TabOperations$4` *(move right action)*
+- `VcsTree$$Lambda` *(any lambda from VcsTree)*
+- `MyLineStatusTrackerImpl$1` *(BaseRevisionSwitcher anonymous inner class - circular reference)*
+- `MyLineStatusTrackerImpl$$Lambda` *(lambdas from line status tracker)*
+- `MySimpleChangesBrowser$1` *(anonymous MouseAdapter)*
+- `BranchTree$MyColoredTreeCellRenderer`
+- Any class ending with `$$Lambda$...`
+
+---
+
+## How to Search Efficiently
+
+### 1. Search by Package Prefix
+Filter the HPROF classes view using these prefixes:
+- `service.`
+- `listener.`
+- `toolwindow.`
+- `implementation.`
+- `model.`
+- `state.`
+- `statusBar.`
+- `utils.`
+
+### 2. Filter the Classes View
+1. Sort by "Count" column
+2. Look for `count != 0`
+3. Focus on YOUR packages (ignore `com.intellij.*`, `java.*`, `kotlin.*`)
+
+### 3. Priority Classes to Check
+*Most likely to leak:*
+
+1. **All listeners** - Must be unregistered
+2. **TabOperations and its anonymous classes** - Actions must be unregistered
+3. **ToolWindowView** - UI components must be disposed
+4. **ViewService** - RxJava subscriptions must be disposed
+5. **MyLineStatusTrackerImpl** - Background tasks must be cancelled
+6. **Any class with `$` in the name** - Anonymous/inner classes often capture outer references
+
+---
+
+## Analysis Steps
+
+1. Open the `.hprof` file in IntelliJ's memory profiler
+2. Navigate to the "Classes" view
+3. Sort by "Count" column in descending order
+4. Search for each class using the package prefixes above
+5. **Report back any classes with `count != 0`** and we'll fix them!
diff --git a/src/main/java/implementation/compare/ChangesService.java b/src/main/java/implementation/compare/ChangesService.java
index 6aea19e..d2cb5f3 100644
--- a/src/main/java/implementation/compare/ChangesService.java
+++ b/src/main/java/implementation/compare/ChangesService.java
@@ -2,6 +2,7 @@
import com.intellij.openapi.Disposable;
import com.intellij.openapi.application.ApplicationManager;
+import com.intellij.openapi.application.ModalityState;
import com.intellij.openapi.diagnostic.Logger;
import com.intellij.openapi.progress.ProgressIndicator;
import com.intellij.openapi.progress.Task;
@@ -27,6 +28,7 @@
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
public class ChangesService extends GitCompareWithRefAction implements Disposable {
@@ -54,6 +56,7 @@ public record ChangesResult(Collection mergedChanges, Collection
private final Project project;
private final GitService git;
private Task.Backgroundable task;
+ private final AtomicBoolean disposing = new AtomicBoolean(false);
public ChangesService(Project project) {
this.project = project;
@@ -88,6 +91,11 @@ public void collectChangesWithCallback(TargetBranchMap targetBranchByRepo, Consu
@Override
public void run(@NotNull ProgressIndicator indicator) {
+ // Early exit if disposing
+ if (disposing.get() || indicator.isCanceled()) {
+ return;
+ }
+
Collection _changes = new ArrayList<>();
Collection _localChanges = new ArrayList<>();
List errorRepos = new ArrayList<>();
@@ -98,12 +106,17 @@ public void run(@NotNull ProgressIndicator indicator) {
ChangeListManager changeListManager = ChangeListManager.getInstance(currentProject);
Collection allLocalChanges = changeListManager.getAllChanges();
+ // Clear cache if checkFs is true (force fresh fetch)
+ if (checkFs) {
+ changesCache.clear();
+ }
+
repositories.forEach(repo -> {
try {
String branchToCompare = getBranchToCompare(targetBranchByRepo, repo);
- // Use only repo path as cache key
- String cacheKey = repo.getRoot().getPath();
+ // Use repo path + target branch as cache key to ensure different branches don't share cache
+ String cacheKey = repo.getRoot().getPath() + "|" + branchToCompare;
Collection changesPerRepo = null;
@@ -153,8 +166,9 @@ public void run(@NotNull ProgressIndicator indicator) {
}
});
- // Only return ERROR_STATE if ALL repositories failed
- if (!errorRepos.isEmpty() && _changes.isEmpty()) {
+ // Return ERROR_STATE if ANY repository had an invalid reference
+ // Since target branch is per-repo, if the specified repo fails, the entire scope is invalid
+ if (!errorRepos.isEmpty()) {
result = new ChangesResult(ERROR_STATE, new ArrayList<>());
} else {
result = new ChangesResult(_changes, _localChanges);
@@ -169,7 +183,7 @@ public void onSuccess() {
if (!currentProject.isDisposed() && callBack != null) {
callBack.accept(this.result);
}
- });
+ }, ModalityState.defaultModalityState(), __ -> disposing.get());
}
@Override
@@ -178,7 +192,7 @@ public void onThrowable(@NotNull Throwable error) {
if (!currentProject.isDisposed() && callBack != null) {
callBack.accept(new ChangesResult(ERROR_STATE, new ArrayList<>()));
}
- });
+ }, ModalityState.defaultModalityState(), __ -> disposing.get());
}
};
task.queue();
@@ -186,6 +200,9 @@ public void onThrowable(@NotNull Throwable error) {
@Override
public void dispose() {
+ // Set disposing flag to prevent queued callbacks from executing
+ disposing.set(true);
+
// Clear cache to release memory
clearCache();
}
@@ -195,10 +212,11 @@ public void clearCache() {
changesCache.clear();
}
- // Method to clear cache for specific repo
+ // Method to clear cache for specific repo (clears all entries for this repo across all branches)
public void clearCache(GitRepository repo) {
- String cacheKey = repo.getRoot().getPath();
- changesCache.remove(cacheKey);
+ String repoPath = repo.getRoot().getPath();
+ // Remove all cache entries that start with this repo path
+ changesCache.keySet().removeIf(key -> key.startsWith(repoPath + "|"));
}
/**
diff --git a/src/main/java/implementation/fileStatus/GitScopeFileStatusProvider.java b/src/main/java/implementation/fileStatus/GitScopeFileStatusProvider.java
index 018ea01..fa56461 100644
--- a/src/main/java/implementation/fileStatus/GitScopeFileStatusProvider.java
+++ b/src/main/java/implementation/fileStatus/GitScopeFileStatusProvider.java
@@ -1,6 +1,7 @@
package implementation.fileStatus;
import com.intellij.openapi.project.Project;
+import com.intellij.openapi.project.ProjectManager;
import com.intellij.openapi.vcs.FileStatus;
import com.intellij.openapi.vcs.changes.Change;
import com.intellij.openapi.vcs.impl.FileStatusProvider;
@@ -35,7 +36,7 @@ public class GitScopeFileStatusProvider implements FileStatusProvider {
// Get the ViewService to access current scope's changes
ViewService viewService = project.getService(ViewService.class);
- if (viewService == null) {
+ if (viewService == null || viewService.isDisposed()) {
return null;
}
@@ -76,8 +77,8 @@ public class GitScopeFileStatusProvider implements FileStatusProvider {
*/
private @Nullable Project getProjectFromFile(@NotNull VirtualFile virtualFile) {
// FileStatusProvider is project-specific, so we can use ProjectManager
- com.intellij.openapi.project.ProjectManager projectManager =
- com.intellij.openapi.project.ProjectManager.getInstance();
+ ProjectManager projectManager =
+ ProjectManager.getInstance();
for (com.intellij.openapi.project.Project project : projectManager.getOpenProjects()) {
if (project.isDisposed()) {
diff --git a/src/main/java/implementation/lineStatusTracker/CommitDiffWorkaround.java b/src/main/java/implementation/lineStatusTracker/CommitDiffWorkaround.java
index 00133dd..f7d238c 100644
--- a/src/main/java/implementation/lineStatusTracker/CommitDiffWorkaround.java
+++ b/src/main/java/implementation/lineStatusTracker/CommitDiffWorkaround.java
@@ -2,7 +2,9 @@
import com.intellij.openapi.Disposable;
import com.intellij.openapi.application.ApplicationManager;
+import com.intellij.openapi.application.ModalityState;
import com.intellij.openapi.diagnostic.Logger;
+import com.intellij.util.Alarm;
import com.intellij.openapi.editor.Document;
import com.intellij.openapi.editor.Editor;
import com.intellij.openapi.editor.EditorKind;
@@ -195,7 +197,7 @@ public void handleCommitDiffEditorReleased(@NotNull Editor editor) {
*/
public void handleSwitchedToCommitDiff() {
// Delay activation to allow diff window to fully render before switching base
- com.intellij.util.Alarm alarm = new com.intellij.util.Alarm(this);
+ Alarm alarm = new Alarm(this);
alarm.addRequest(() -> {
if (disposing.get()) return;
activateHeadBaseForAllCommitDiffs();
@@ -264,13 +266,13 @@ private void scheduleActivationIfCommitDiffSelected() {
if (selectedEditor != null &&
selectedEditor.getClass().getSimpleName().equals("BackendDiffRequestProcessorEditor")) {
// Delay activation to allow diff window to fully render
- com.intellij.util.Alarm alarm = new com.intellij.util.Alarm(this);
+ Alarm alarm = new Alarm(this);
alarm.addRequest(() -> {
if (disposing.get()) return;
activateHeadBaseForAllCommitDiffs();
}, ACTIVATION_DELAY_MS);
}
- });
+ }, ModalityState.defaultModalityState(), __ -> disposing.get());
}
/**
@@ -325,7 +327,7 @@ private void activateHeadBaseForAllCommitDiffs() {
ApplicationManager.getApplication().invokeLater(() -> {
if (disposing.get()) return;
baseRevisionSwitcher.switchToHeadBase(doc, finalHeadContent);
- });
+ }, ModalityState.defaultModalityState(), __ -> disposing.get());
}
}
}
@@ -362,13 +364,13 @@ private void restoreCustomBaseForDocument(@NotNull Document doc) {
ApplicationManager.getApplication().invokeLater(() -> {
if (disposing.get()) return;
baseRevisionSwitcher.switchToCustomBase(doc, customContent);
- });
+ }, ModalityState.defaultModalityState(), __ -> disposing.get());
}
}
private String fetchHeadRevisionContent(@NotNull VirtualFile file) {
try {
- if (project == null || project.isDisposed()) {
+ if (project.isDisposed()) {
return null;
}
diff --git a/src/main/java/implementation/lineStatusTracker/MyLineStatusTrackerImpl.java b/src/main/java/implementation/lineStatusTracker/MyLineStatusTrackerImpl.java
index ba6d18d..728235a 100644
--- a/src/main/java/implementation/lineStatusTracker/MyLineStatusTrackerImpl.java
+++ b/src/main/java/implementation/lineStatusTracker/MyLineStatusTrackerImpl.java
@@ -2,6 +2,7 @@
import com.intellij.openapi.Disposable;
import com.intellij.openapi.application.ApplicationManager;
+import com.intellij.openapi.application.ModalityState;
import com.intellij.openapi.editor.Document;
import com.intellij.openapi.editor.Editor;
import com.intellij.openapi.editor.EditorFactory;
@@ -38,14 +39,20 @@ public class MyLineStatusTrackerImpl implements Disposable {
private static final com.intellij.openapi.diagnostic.Logger LOG = Defs.getLogger(MyLineStatusTrackerImpl.class);
private final Project project;
- private final MessageBusConnection messageBusConnection;
- private final LineStatusTrackerManagerI trackerManager;
- private final CommitDiffWorkaround commitDiffWorkaround;
+ private MessageBusConnection messageBusConnection;
+ private LineStatusTrackerManagerI trackerManager;
+ private CommitDiffWorkaround commitDiffWorkaround;
// Single, consistent requester for this component's lifetime
private final Object requester = new Object();
private final AtomicBoolean disposing = new AtomicBoolean(false);
+ // Lightweight disposable token to check disposal state without capturing 'this'
+ private static class DisposalToken {
+ volatile boolean disposed = false;
+ }
+ private final DisposalToken disposalToken = new DisposalToken();
+
// Track per-document holds and base content
private final Map trackers = new HashMap<>();
@@ -65,6 +72,7 @@ private static final class TrackerInfo {
@Override
public void dispose() {
+ disposalToken.disposed = true;
releaseAll();
}
@@ -172,31 +180,32 @@ public void selectionChanged(@NotNull FileEditorManagerEvent event) {
);
// Listen to editor lifecycle to detect commit panel diff editors
- EditorFactory.getInstance().addEditorFactoryListener(
- new EditorFactoryListener() {
- @Override
- public void editorCreated(@NotNull EditorFactoryEvent event) {
- Editor editor = event.getEditor();
- if (editor.getEditorKind() == EditorKind.DIFF) {
- // Check if it's a commit panel diff - hierarchy might not be ready yet
- ApplicationManager.getApplication().invokeLater(() -> {
- if (commitDiffWorkaround.isCommitPanelDiff(editor)) {
- commitDiffWorkaround.handleCommitDiffEditorCreated(editor);
- }
- });
- }
- }
-
- @Override
- public void editorReleased(@NotNull EditorFactoryEvent event) {
- Editor editor = event.getEditor();
+ // Use disposalToken to avoid capturing 'this' in lambdas
+ final DisposalToken token = this.disposalToken;
+ EditorFactory.getInstance().addEditorFactoryListener(new EditorFactoryListener() {
+ @Override
+ public void editorCreated(@NotNull EditorFactoryEvent event) {
+ Editor editor = event.getEditor();
+ if (editor.getEditorKind() == EditorKind.DIFF) {
+ // Check if it's a commit panel diff - hierarchy might not be ready yet
+ ApplicationManager.getApplication().invokeLater(() -> {
+ if (token.disposed) return;
if (commitDiffWorkaround.isCommitPanelDiff(editor)) {
- commitDiffWorkaround.handleCommitDiffEditorReleased(editor);
+ commitDiffWorkaround.handleCommitDiffEditorCreated(editor);
}
- }
- },
- parentDisposable
- );
+ }, ModalityState.defaultModalityState(), __ -> token.disposed);
+ }
+ }
+
+ @Override
+ public void editorReleased(@NotNull EditorFactoryEvent event) {
+ Editor editor = event.getEditor();
+ if (token.disposed) return;
+ if (commitDiffWorkaround.isCommitPanelDiff(editor)) {
+ commitDiffWorkaround.handleCommitDiffEditorReleased(editor);
+ }
+ }
+ }, parentDisposable);
Disposer.register(parentDisposable, this);
Disposer.register(parentDisposable, commitDiffWorkaround);
@@ -211,31 +220,34 @@ public void update(Collection changes, @Nullable VirtualFile targetFile)
return;
}
+ final DisposalToken token = this.disposalToken;
ApplicationManager.getApplication().executeOnPooledThread(() -> {
- if (disposing.get()) return;
+ if (token.disposed) return;
- Map fileToRevisionMap = collectFileRevisionMap(changes);
+ // Extract content from ContentRevision objects on background thread
+ Map fileToContentMap = collectFileContentMap(changes);
ApplicationManager.getApplication().invokeLater(() -> {
- if (disposing.get()) return;
+ if (token.disposed) return;
Editor[] editors = EditorFactory.getInstance().getAllEditors();
for (Editor editor : editors) {
if (isDiffView(editor)) continue;
// Platform handles gutter repainting automatically - no need to force it
- updateLineStatusByChangesForEditorSafe(editor, fileToRevisionMap);
+ updateLineStatusByChangesForEditorSafe(editor, fileToContentMap);
}
- });
+ }, ModalityState.defaultModalityState(), __ -> token.disposed);
});
}
- private Map collectFileRevisionMap(Collection changes) {
- return ApplicationManager.getApplication().runReadAction((Computable
\ No newline at end of file
From 746d6242da6edf6532750f59a44493c5e131ff04 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Magnus=20W=C3=A5llberg?=
Date: Fri, 2 Jan 2026 19:21:33 +0100
Subject: [PATCH 13/14] Fixed set base revision cannot be called in bulk mode
---
.../MyLineStatusTrackerImpl.java | 26 +++++--------------
1 file changed, 7 insertions(+), 19 deletions(-)
diff --git a/src/main/java/implementation/lineStatusTracker/MyLineStatusTrackerImpl.java b/src/main/java/implementation/lineStatusTracker/MyLineStatusTrackerImpl.java
index abb1468..ea66cf4 100644
--- a/src/main/java/implementation/lineStatusTracker/MyLineStatusTrackerImpl.java
+++ b/src/main/java/implementation/lineStatusTracker/MyLineStatusTrackerImpl.java
@@ -9,19 +9,16 @@
import com.intellij.openapi.editor.EditorKind;
import com.intellij.openapi.editor.event.EditorFactoryEvent;
import com.intellij.openapi.editor.event.EditorFactoryListener;
-import com.intellij.util.DocumentUtil;
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.project.Project;
-import com.intellij.openapi.util.Computable;
import com.intellij.openapi.util.Disposer;
import com.intellij.openapi.util.text.StringUtil;
import com.intellij.openapi.vcs.VcsException;
import com.intellij.openapi.vcs.changes.Change;
-import com.intellij.openapi.vcs.changes.ContentRevision;
import com.intellij.openapi.vcs.ex.LineStatusTracker;
import com.intellij.openapi.vcs.impl.LineStatusTrackerManagerI;
import com.intellij.openapi.vfs.VirtualFile;
@@ -160,7 +157,7 @@ public void fileClosed(@NotNull FileEditorManager source, @NotNull VirtualFile f
if (doc != null) {
// Workaround: Don't release if commit diff editors still need this document
if (!commitDiffWorkaround.hasCommitDiffEditorsFor(doc)) {
- safeRelease(doc);
+ release(doc);
}
}
@@ -228,12 +225,12 @@ public void update(Map scopeChangesMap) {
for (Editor editor : editors) {
if (isDiffView(editor)) continue;
// Platform handles gutter repainting automatically - no need to force it
- updateLineStatusByChangesForEditorSafe(editor, scopeChangesMap);
+ updateLineStatusByChangesForEditor(editor, scopeChangesMap);
}
}, ModalityState.defaultModalityState(), __ -> token.disposed);
}
- private void updateLineStatusByChangesForEditorSafe(Editor editor, Map scopeChangesMap) {
+ private void updateLineStatusByChangesForEditor(Editor editor, Map scopeChangesMap) {
if (editor == null || disposing.get()) return;
Document doc = editor.getDocument();
@@ -355,19 +352,10 @@ private void updateTrackerBaseRevision(LineStatusTracker> tracker, String cont
if (disposing.get()) {
return;
}
-
- // Use bulk update mode to batch changes and prevent flickering
- Document document = tracker.getDocument();
-
- DocumentUtil.executeInBulk(document, () -> {
- try {
- setBaseRevisionMethod.invoke(tracker, content);
- } catch (Exception e) {
- LOG.error("Failed to invoke setBaseRevision method", e);
- }
- });
+
+ setBaseRevisionMethod.invoke(tracker, content);
} catch (Exception e) {
- LOG.error("Failed to execute in bulk mode", e);
+ LOG.error("Failed to invoke setBaseRevision method", e);
}
});
} else {
@@ -397,7 +385,7 @@ private Method findMethodInHierarchy(Class> clazz, String methodName, Class>
/**
* Release for a specific document if we hold it.
*/
- private synchronized void safeRelease(@NotNull Document document) {
+ private synchronized void release(@NotNull Document document) {
TrackerInfo info = trackers.get(document);
if (info == null || !info.held) return;
From a8cf713e5e8df7212cae491ca922fd34a089c8e3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Magnus=20W=C3=A5llberg?=
Date: Sat, 3 Jan 2026 00:19:45 +0100
Subject: [PATCH 14/14] Some tweaks to README.md and plugin description.
---
CHANGELOG.md | 2 +-
README.md | 4 +++-
src/main/resources/META-INF/plugin.xml | 24 +++++++++++++++++++++++-
3 files changed, 27 insertions(+), 3 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b7bc82d..0136174 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,7 +4,7 @@
- Fixed [Diff popup selection missing](https://github.com/comod/git-scope-pro/issues/76)
- Fixed [HEAD diff logic does not handle root directories outside git repo](https://github.com/comod/git-scope-pro/issues/75)
-- Fixed several minor refresh issues
+- Fixed several refresh&stability issues
### Added
diff --git a/README.md b/README.md
index b1501c5..b50047e 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,9 @@ changes over time. This plugin addresses that problem by making committed change
Create custom "scopes" for any Git reference—branch, tag, or commit hash. Each defined scope appears as a selectable tab
in the **GIT SCOPE** tool window. The currently selected scope visualizes changes through:
-- **Project tree diff** — Shows all modified files in the GIT SCOPE tool window
+- **Scope tree diff** — Shows all modified files in the GIT SCOPE tool window
+- **File colors** - Files highlighted in editor tabs and project window according to the GIT SCOPE status (added;
+ modified; deleted; ...)
- **Editor line status** — Displays change markers in the editor gutter for open files
- **Custom scope** — Enables filtered search, replace, and inspection operations
- **Status bar widget** — Displays the current scope selection
diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml
index 6a33a94..d0669c1 100644
--- a/src/main/resources/META-INF/plugin.xml
+++ b/src/main/resources/META-INF/plugin.xml
@@ -3,7 +3,29 @@
Git ScopeWOELKIT, M.Wållbergauto
- Create custom Git "scopes" for any target branch.
+ See all your feature branch changes at a glance—even after committing.
+
+
IntelliJ's built-in version control only shows uncommitted changes. Once you commit, all change indicators disappear.
+ Git Scope solves this by letting you compare your current work against any Git reference—branches, tags, or commits—making
+ it perfect for tracking feature branch progress across multiple commits.
+
+
Key Features:
+
+
Visual change tracking — See exactly what changed between HEAD and your target branch
+
Editor gutter indicators — Line-by-line change markers (added/modified/deleted) right in your editor
+
File tree diff browser — Browse all modified files in a dedicated tool window