diff --git a/CHANGELOG.md b/CHANGELOG.md
index bebcc1f..0136174 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,15 @@
+## [2025.3.2]
+
+### Fixes
+
+- 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 refresh&stability issues
+
+### Added
+
+- Added [filnames in project explorer and tabs colored based on GitScope](https://github.com/comod/git-scope-pro/issues/74)
+
## [2025.3.1]
### Fixes
diff --git a/CLASSES.md b/CLASSES.md
new file mode 100644
index 0000000..6735247
--- /dev/null
+++ b/CLASSES.md
@@ -0,0 +1,163 @@
+# 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.VcsTreeActions`
+
+#### Actions
+- `toolwindow.actions.TabMoveActions`
+- `toolwindow.actions.TabMoveActions$MoveTabLeft`
+- `toolwindow.actions.TabMoveActions$MoveTabRight`
+- `toolwindow.actions.RenameTabAction`
+- `toolwindow.actions.ResetTabNameAction`
+
+### 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/README.md b/README.md
index 0701c24..b50047e 100644
--- a/README.md
+++ b/README.md
@@ -4,19 +4,20 @@
### Story
-I think every developer loves to check their changes with **version control** before committing.
-But there is a big problem with the current IntelliJ-based IDEs after committing the code: All changes in **version
-control** and also the line status disappear completely. Usually a branch contains more than one commit. This plugin
-helps you to make these commits visible again in an intuitive way!
-
-To make changes visible you can create custom "scopes" for any target branch, tag or any valid git reference. Each of
-the defined scopes will be selectable as a tab in the **GIT SCOPE** tool window. The current selected "scope" is
-displayed as a:
-
-- Overall project tree diff in the **GIT SCOPE** tool window
-- Editor "line status" in the "line gutter" for each opened file (if file gutter is enabled)
-- Custom "scope" that can be used for example when searching and finally as a
-- Status bar widget
+Developers rely on version control to review changes before committing. However, IntelliJ-based IDEs have a significant
+limitation: after committing code, all change indicators in version control and line status annotations disappear
+completely. Since feature branches typically contain multiple commits, this makes it difficult to track accumulated
+changes over time. This plugin addresses that problem by making committed changes visible again.
+
+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:
+
+- **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
### Plugin Basics
diff --git a/gradle.properties b/gradle.properties
index c9cbdb9..edf29ad 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -2,12 +2,12 @@
pluginGroup=org.woelkit.plugins
pluginName=Git Scope
pluginRepositoryUrl=https://github.com/comod/git-scope-pro
-pluginVersion=2025.3.1
+pluginVersion=2025.3.2
pluginSinceBuild=243
platformType=IU
#platformVersion=LATEST-EAP-SNAPSHOT
-platformVersion=2025.3
+platformVersion=2025.3.1
platformBundledPlugins=Git4Idea
gradleVersion=9.2.1
diff --git a/src/main/java/implementation/compare/ChangesService.java b/src/main/java/implementation/compare/ChangesService.java
index 7793d3f..d2cb5f3 100644
--- a/src/main/java/implementation/compare/ChangesService.java
+++ b/src/main/java/implementation/compare/ChangesService.java
@@ -1,6 +1,9 @@
package implementation.compare;
+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;
import com.intellij.openapi.project.Project;
@@ -25,20 +28,35 @@
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicBoolean;
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 {}
+
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.
+ *
+ * @param mergedChanges Scope changes + local changes
+ * @param localChanges Local changes towards HEAD only
+ */
+ public record ChangesResult(Collection mergedChanges, Collection localChanges) {
+ }
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;
@@ -51,7 +69,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;
@@ -62,122 +80,189 @@ 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) {
+ // Early exit if disposing
+ if (disposing.get() || indicator.isCanceled()) {
+ return;
+ }
+
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();
+
+ // Clear cache if checkFs is true (force fresh fetch)
+ if (checkFs) {
+ changesCache.clear();
+ }
+
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)) {
- // 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
+ try {
+ String branchToCompare = getBranchToCompare(targetBranchByRepo, repo);
+
+ // 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;
+
+ 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
+ }
}
- }
- 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);
+ // 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
+ LOG.warn("Unexpected error processing repository " + repo.getRoot().getPath(), e);
+ errorRepos.add(repo.getRoot().getPath());
}
});
- // Only return ERROR_STATE if ALL repositories failed
- if (!errorRepos.isEmpty() && _changes.isEmpty()) {
- changes = ERROR_STATE;
+ // 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 {
- 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);
}
- });
+ }, ModalityState.defaultModalityState(), __ -> disposing.get());
}
@Override
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<>()));
}
- });
+ }, ModalityState.defaultModalityState(), __ -> disposing.get());
}
};
task.queue();
}
+ @Override
+ public void dispose() {
+ // Set disposing flag to prevent queued callbacks from executing
+ disposing.set(true);
+
+ // Clear cache to release memory
+ clearCache();
+ }
+
// Method to clear cache when needed
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 + "|"));
}
- 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 +279,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 +300,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,20 +338,17 @@ 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);
- }
- }
-
- } catch (VcsException ignored) {
+ // 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 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/implementation/fileStatus/GitScopeFileStatusProvider.java b/src/main/java/implementation/fileStatus/GitScopeFileStatusProvider.java
new file mode 100644
index 0000000..fa56461
--- /dev/null
+++ b/src/main/java/implementation/fileStatus/GitScopeFileStatusProvider.java
@@ -0,0 +1,96 @@
+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;
+import com.intellij.openapi.vfs.VirtualFile;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import service.ViewService;
+
+import java.util.Map;
+
+/**
+ * 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 || viewService.isDisposed()) {
+ 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
+ // 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
+ // 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 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
+ 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
+ ProjectManager projectManager =
+ 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/implementation/lineStatusTracker/CommitDiffWorkaround.java b/src/main/java/implementation/lineStatusTracker/CommitDiffWorkaround.java
index 5691e7d..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;
@@ -11,7 +13,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;
@@ -196,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();
@@ -265,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());
}
/**
@@ -326,7 +327,7 @@ private void activateHeadBaseForAllCommitDiffs() {
ApplicationManager.getApplication().invokeLater(() -> {
if (disposing.get()) return;
baseRevisionSwitcher.switchToHeadBase(doc, finalHeadContent);
- });
+ }, ModalityState.defaultModalityState(), __ -> disposing.get());
}
}
}
@@ -363,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 b620b88..ea66cf4 100644
--- a/src/main/java/implementation/lineStatusTracker/MyLineStatusTrackerImpl.java
+++ b/src/main/java/implementation/lineStatusTracker/MyLineStatusTrackerImpl.java
@@ -2,27 +2,23 @@
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;
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;
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;
@@ -40,14 +36,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<>();
@@ -67,6 +69,7 @@ private static final class TrackerInfo {
@Override
public void dispose() {
+ disposalToken.disposed = true;
releaseAll();
}
@@ -154,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);
}
}
@@ -174,31 +177,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);
@@ -208,73 +212,55 @@ private boolean isDiffView(Editor editor) {
return editor.getEditorKind() == EditorKind.DIFF;
}
- public void update(Collection changes, @Nullable VirtualFile targetFile) {
- if (changes == null || disposing.get()) {
+ public void update(Map scopeChangesMap) {
+ if (scopeChangesMap == null || disposing.get()) {
return;
}
- ApplicationManager.getApplication().executeOnPooledThread(() -> {
- if (disposing.get()) return;
-
- Map fileToRevisionMap = collectFileRevisionMap(changes);
-
- ApplicationManager.getApplication().invokeLater(() -> {
- if (disposing.get()) 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);
- }
- });
- });
- }
-
- private Map collectFileRevisionMap(Collection changes) {
- return ApplicationManager.getApplication().runReadAction((Computable
+
+
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