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>) () -> { - Map map = new HashMap<>(); - for (Change change : changes) { - if (change == null) continue; - - VirtualFile vcsFile = change.getVirtualFile(); // background thread - if (vcsFile == null) continue; + final DisposalToken token = this.disposalToken; + ApplicationManager.getApplication().invokeLater(() -> { + if (token.disposed) return; - String filePath = vcsFile.getPath(); - ContentRevision beforeRevision = change.getBeforeRevision(); - if (beforeRevision != null) { - map.put(filePath, beforeRevision); - } + Editor[] editors = EditorFactory.getInstance().getAllEditors(); + for (Editor editor : editors) { + if (isDiffView(editor)) continue; + // Platform handles gutter repainting automatically - no need to force it + updateLineStatusByChangesForEditor(editor, scopeChangesMap); } - return map; - }); + }, ModalityState.defaultModalityState(), __ -> token.disposed); } - private boolean updateLineStatusByChangesForEditorSafe(Editor editor, Map fileToRevisionMap) { - if (editor == null || disposing.get()) return false; + private void updateLineStatusByChangesForEditor(Editor editor, Map scopeChangesMap) { + if (editor == null || disposing.get()) return; Document doc = editor.getDocument(); VirtualFile file = FileDocumentManager.getInstance().getFile(doc); - if (file == null) return false; + if (file == null) return; String filePath = file.getPath(); - ContentRevision contentRevision = fileToRevisionMap.get(filePath); + + // Look up the change for this editor's file using the map + Change changeForFile = scopeChangesMap.get(filePath); String content; - if (contentRevision == null) { - content = doc.getCharsSequence().toString(); - } else { + if (changeForFile != null && changeForFile.getBeforeRevision() != null) { + // Extract content for this specific file only try { - String revisionContent = contentRevision.getContent(); - content = revisionContent != null ? revisionContent : ""; + content = changeForFile.getBeforeRevision().getContent(); } catch (VcsException e) { - LOG.warn("Error getting content for revision", e); - return false; + LOG.warn("Error getting content for revision: " + filePath, e); + content = null; + } + + if (content == null) { + content = doc.getCharsSequence().toString(); } + } else { + // No revision content available, use current document content + content = doc.getCharsSequence().toString(); } updateTrackerBaseContent(doc, content); - return true; } /** @@ -302,9 +288,10 @@ private void updateTrackerBaseContent(Document document, String content) { if (content == null || disposing.get()) return; final String finalContent = StringUtil.convertLineSeparators(content); + final DisposalToken token = this.disposalToken; ApplicationManager.getApplication().invokeLater(() -> { - if (disposing.get()) return; + if (token.disposed) return; try { ensureRequested(document); @@ -326,7 +313,7 @@ private void updateTrackerBaseContent(Document document, String content) { } catch (Exception e) { LOG.error("Error updating line status tracker with new base content", e); } - }); + }, ModalityState.defaultModalityState(), __ -> token.disposed); } /** @@ -365,22 +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(); - if (document == null) { - return; - } - - 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 { @@ -410,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; @@ -434,6 +409,12 @@ public void releaseAll() { return; // already disposing } + // Dispose the commit diff workaround to break circular reference + // (commitDiffWorkaround holds BaseRevisionSwitcher anonymous inner class that captures this) + if (commitDiffWorkaround != null) { + commitDiffWorkaround.dispose(); + } + if (messageBusConnection != null) { messageBusConnection.disconnect(); } @@ -462,6 +443,11 @@ public void releaseAll() { } else { ApplicationManager.getApplication().invokeAndWait(release); } + + // Null out references to platform services to prevent retention + trackerManager = null; + messageBusConnection = null; + commitDiffWorkaround = null; } /** diff --git a/src/main/java/implementation/scope/MyScope.java b/src/main/java/implementation/scope/MyScope.java index 7e93155..13d1324 100644 --- a/src/main/java/implementation/scope/MyScope.java +++ b/src/main/java/implementation/scope/MyScope.java @@ -72,4 +72,22 @@ public void update(Collection changes) { ApplicationManager.getApplication().invokeLater(this::updateProjectFilter); } } + + /** + * Remove the named scope from the scope manager to prevent memory leaks. + * Must be called when the plugin is being unloaded. + */ + public void dispose() { + // Remove our scope from the NamedScopeManager to break the reference to MyPackageSet + List scopes = new ArrayList<>(Arrays.asList(scopeManager.getEditableScopes())); + scopes.removeIf(scope -> SCOPE_ID.equals(scope.getScopeId())); + scopes.removeIf(scope -> OLD_SCOPE_ID.equals(scope.getScopeId())); + scopeManager.setScopes(scopes.toArray(new NamedScope[0])); + + // Clear the package set reference + if (myPackageSet != null) { + myPackageSet.setChanges(Collections.emptyList()); + myPackageSet = null; + } + } } diff --git a/src/main/java/listener/MyBulkFileListener.java b/src/main/java/listener/MyBulkFileListener.java index 20d80e8..a6c5df7 100644 --- a/src/main/java/listener/MyBulkFileListener.java +++ b/src/main/java/listener/MyBulkFileListener.java @@ -22,7 +22,7 @@ public void after(@NotNull List events) { ViewService viewService = project.getService(ViewService.class); if (viewService != null) { // TODO: collectChanges: bulk file event (disabled) - // viewService.collectChanges(false); + viewService.collectChanges(true); } } } diff --git a/src/main/java/listener/MyDynamicPluginListener.java b/src/main/java/listener/MyDynamicPluginListener.java index 67e52f8..43688fe 100644 --- a/src/main/java/listener/MyDynamicPluginListener.java +++ b/src/main/java/listener/MyDynamicPluginListener.java @@ -2,37 +2,59 @@ 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) { - 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 - } + // Only handle our own plugin + if (isAlienPlugin(pluginDescriptor)) { + return; } - } - @Override - public void pluginLoaded(@NotNull IdeaPluginDescriptor pluginDescriptor) { 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 { + // 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(); + } + } catch (Exception e) { + LOG.error("Error preparing project for plugin unload: " + project.getName(), e); } } } - + /** + * Checks if the plugin descriptor refers to some other plugin + */ + private boolean isAlienPlugin(@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/model/Debounce.java b/src/main/java/model/Debounce.java index 422fc6e..a5caf6f 100644 --- a/src/main/java/model/Debounce.java +++ b/src/main/java/model/Debounce.java @@ -1,9 +1,11 @@ package model; +import com.intellij.util.concurrency.AppExecutorUtil; + import java.util.concurrent.*; public class Debounce { - private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); + private final ScheduledExecutorService scheduler = AppExecutorUtil.createBoundedScheduledExecutorService("Debounce", 1); private final ConcurrentHashMap> delayedMap = new ConcurrentHashMap<>(); /** @@ -23,4 +25,29 @@ public void debounce(final Object key, final Runnable runnable, long delay, Time } } + /** + * Shuts down the scheduler and cancels all pending tasks. + * Should be called when the Debounce instance is no longer needed. + */ + public void shutdown() { + // Cancel all pending futures + for (Future future : delayedMap.values()) { + future.cancel(true); + } + delayedMap.clear(); + + // Shutdown the scheduler + if (!scheduler.isShutdown()) { + scheduler.shutdown(); + try { + if (!scheduler.awaitTermination(2, TimeUnit.SECONDS)) { + scheduler.shutdownNow(); + } + } catch (InterruptedException e) { + scheduler.shutdownNow(); + Thread.currentThread().interrupt(); + } + } + } + } diff --git a/src/main/java/model/MyModel.java b/src/main/java/model/MyModel.java index adda2c1..bac023b 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,12 +9,16 @@ import service.GitService; import java.util.Collection; +import java.util.HashMap; import java.util.Map; 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 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 @@ -98,9 +103,49 @@ public Collection getChanges() { public void setChanges(Collection changes) { this.changes = changes; + this.changesMap = buildChangesByPathMap(changes); changeObservable.onNext(field.changes); } + public Collection getLocalChanges() { + return localChanges; + } + + 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() { return changeObservable; } @@ -110,7 +155,7 @@ public boolean isNew() { if (targetBranchMap == null) { return true; } - return targetBranchMap.getValue().isEmpty(); + return targetBranchMap.value().isEmpty(); } public boolean isActive() { @@ -136,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/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/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/ToolWindowService.java b/src/main/java/service/ToolWindowService.java index b86d5bc..f7de2b9 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,19 @@ public ToolWindowService(Project project) { this.tabOperations = new TabOperations(project); } + @Override + public void dispose() { + // Dispose all ToolWindowView instances to clean up UI components + for (ToolWindowView view : contentToViewMap.values()) { + if (view != null) { + view.dispose(); + } + } + + // Clear the content to view map to release memory + contentToViewMap.clear(); + } + @Override public void removeAllTabs() { getContentManager().removeAllContents(true); @@ -38,15 +52,20 @@ public void removeAllTabs() { } public ToolWindow getToolWindow() { + if (project.isDisposed()) { + return null; + } ToolWindowManager toolWindowManager = ToolWindowManager.getInstance(project); - ToolWindow toolWindow = toolWindowManager.getToolWindow(Defs.TOOL_WINDOW_NAME); - assert toolWindow != null; - return toolWindow; + return toolWindowManager.getToolWindow(Defs.TOOL_WINDOW_NAME); } @NotNull private ContentManager getContentManager() { - return getToolWindow().getContentManager(); + ToolWindow toolWindow = getToolWindow(); + if (toolWindow == null) { + throw new IllegalStateException("Tool window is not available"); + } + return toolWindow.getContentManager(); } public void addTab(MyModel myModel, String tabName, boolean closeable) { @@ -85,9 +104,6 @@ public VcsTree getVcsTree() { public void addListener() { ContentManager contentManager = getContentManager(); contentManager.addContentManagerListener(new MyTabContentListener(project)); - - // Register all tab actions (rename, reset, move) in the tab context menu - tabOperations.registerTabActions(); } @Override @@ -100,12 +116,39 @@ public void changeTabName(String title) { tabOperations.changeTabName(title, getContentManager()); } + @Override + public void changeTabNameForModel(MyModel model, String title) { + // Find the Content for this model + Content targetContent = null; + for (Map.Entry entry : contentToViewMap.entrySet()) { + ToolWindowView view = entry.getValue(); + if (view != null && view.getModel() == model) { + targetContent = entry.getKey(); + break; + } + } + + // Update the tab name for the specific content + if (targetContent != null) { + String currentName = targetContent.getDisplayName(); + if (!currentName.equals(title)) { + targetContent.setDisplayName(title); + } + } + } + public void removeTab(int index) { @Nullable Content content = getContentManager().getContent(index); if (content == null) { return; } - contentToViewMap.remove(content); + + // Dispose the view associated with this content before removing + ToolWindowView view = contentToViewMap.remove(content); + if (view != null) { + view.dispose(); + } + getContentManager().removeContent(content, false); } @@ -114,7 +157,13 @@ public void removeCurrentTab() { if (content == null) { return; } - contentToViewMap.remove(content); + + // Dispose the view associated with this content before removing + ToolWindowView view = contentToViewMap.remove(content); + if (view != null) { + view.dispose(); + } + getContentManager().removeContent(content, true); } diff --git a/src/main/java/service/ToolWindowServiceInterface.java b/src/main/java/service/ToolWindowServiceInterface.java index 7670e32..6c9669e 100644 --- a/src/main/java/service/ToolWindowServiceInterface.java +++ b/src/main/java/service/ToolWindowServiceInterface.java @@ -10,6 +10,8 @@ public interface ToolWindowServiceInterface { void changeTabName(String title); + void changeTabNameForModel(MyModel model, String title); + void setupTabTooltip(MyModel model); void addListener(); diff --git a/src/main/java/service/ViewService.java b/src/main/java/service/ViewService.java index d16da8c..28b789e 100644 --- a/src/main/java/service/ViewService.java +++ b/src/main/java/service/ViewService.java @@ -2,8 +2,16 @@ import com.intellij.openapi.Disposable; import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.application.ModalityState; +import com.intellij.openapi.editor.Editor; +import com.intellij.openapi.editor.EditorFactory; +import com.intellij.openapi.editor.EditorKind; +import com.intellij.openapi.editor.ex.EditorEx; import com.intellij.openapi.project.Project; +import com.intellij.openapi.vcs.FileStatusManager; import com.intellij.openapi.vcs.changes.Change; +import com.intellij.openapi.vcs.impl.LineStatusTrackerManagerI; +import com.intellij.openapi.wm.ToolWindow; import com.intellij.ui.content.Content; import com.intellij.ui.content.ContentManager; import com.intellij.util.concurrency.SequentialTaskExecutor; @@ -25,23 +33,31 @@ import java.util.ArrayList; import java.util.Collection; import java.util.List; +import java.util.Map; 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; 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); 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; + private volatile boolean isDisposed = false; + + // Lightweight disposal token to avoid capturing 'this' in lambdas + private static class DisposalToken { + volatile boolean disposed = false; + } + private final DisposalToken disposalToken = new DisposalToken(); + private boolean isProcessingTabRename = false; private boolean isProcessingTabReorder = false; private ToolWindowServiceInterface toolWindowService; @@ -60,6 +76,8 @@ 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); + private final List rxSubscriptions = new ArrayList<>(); public ViewService(Project project) { this.project = project; @@ -72,6 +90,68 @@ public ViewService(Project project) { @Override public void dispose() { + // Set disposed flag FIRST to prevent any further operations + isDisposed = true; + disposalToken.disposed = true; + + // Dispose all RxJava subscriptions to break references to MyModel.field enum + for (io.reactivex.rxjava3.disposables.Disposable subscription : rxSubscriptions) { + if (subscription != null && !subscription.isDisposed()) { + subscription.dispose(); + } + } + rxSubscriptions.clear(); + + // Shutdown the debouncer scheduler FIRST to cancel pending tasks + if (debouncer != null) { + debouncer.shutdown(); + } + + // Shutdown the executor service to prevent memory leaks + if (!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(); + collection = null; + } + + // Null out all service references to break potential circular references + toolWindowService = null; + changesService = null; + statusBarService = null; + gitService = null; + targetBranchService = null; + state = null; + + // Dispose MyScope to unregister NamedScope from NamedScopeManager + if (myScope != null) { + myScope.dispose(); + myScope = null; + } + + // Dispose of the line status tracker if it has dispose logic + myLineStatusTrackerImpl = null; + debouncer = null; + myHeadModel = null; + } + + /** + * Check if this service has been disposed. + * Used by FileStatusProvider to avoid accessing disposed service. + */ + public boolean isDisposed() { + return isDisposed; } public void initDependencies() { @@ -90,6 +170,100 @@ 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. + * IMPORTANT: Calling FileStatusManager.fileStatusesChanged() can trigger VCS machinery that + * invalidates LineStatusTrackers, causing gutter markers to disappear. We protect against this + * by ensuring trackers are maintained and repainted after the status update. + */ + public void refreshFileColors() { + if (project.isDisposed() || isDisposed) { + return; + } + + final DisposalToken token = this.disposalToken; + // Execute on background thread first, then switch to EDT for the actual work + ApplicationManager.getApplication().executeOnPooledThread(() -> { + if (!token.disposed) { + ApplicationManager.getApplication().invokeLater( + () -> doRefreshFileColorsOnEdt(token), + ModalityState.any(), + __ -> token.disposed + ); + } + }); + } + + /** + * Performs the actual file colors refresh on EDT. + * Collects open editors, triggers file status update, and schedules gutter repaint. + */ + private void doRefreshFileColorsOnEdt(DisposalToken token) { + if (project.isDisposed() || token.disposed) { + return; + } + + // Collect editors that need gutter repaint after the status change + //List editorsToRepaint = collectMainEditors(); + + // Trigger the file status update (may invalidate trackers) + FileStatusManager.getInstance(project).fileStatusesChanged(); + + // Schedule gutter repaint after trackers are updated + // TODO: Currently removed since this still seem to cause gutter refresh issues + //scheduleGutterRepaint(editorsToRepaint, token); + } + + /** + * Collects all main editors for the current project. + */ + private List collectMainEditors() { + List result = new ArrayList<>(); + Editor[] allEditors = EditorFactory.getInstance().getAllEditors(); + + for (Editor editor : allEditors) { + if (editor.getProject() == project && editor.getEditorKind() == EditorKind.MAIN_EDITOR) { + result.add(editor); + } + } + + return result; + } + + /** + * Schedules gutter repaint after LineStatusTracker updates complete. + */ + private void scheduleGutterRepaint(List editorsToRepaint, DisposalToken token) { + LineStatusTrackerManagerI trackerManager = project.getService(LineStatusTrackerManagerI.class); + + if (trackerManager != null) { + trackerManager.invokeAfterUpdate(() -> { + if (!token.disposed && !project.isDisposed()) { + repaintEditorGutters(editorsToRepaint, token); + } + }); + } + } + + /** + * Repaints gutters for all specified editors on EDT. + */ + private void repaintEditorGutters(List editors, DisposalToken token) { + ApplicationManager.getApplication().invokeLater(() -> { + if (token.disposed || project.isDisposed()) { + return; + } + + for (Editor editor : editors) { + if (!editor.isDisposed() && editor instanceof EditorEx) { + ((EditorEx) editor).getGutterComponentEx().repaint(); + } + } + }, ModalityState.any(), __ -> token.disposed); + } + public void load() { // Load models from state List collection = new ArrayList<>(); @@ -130,14 +304,14 @@ public void save() { List modelData = new ArrayList<>(); collection.forEach(myModel -> { - MyModelBase myModelBase = new MyModelBase(); - // Save the target branch map TargetBranchMap targetBranchMap = myModel.getTargetBranchMap(); if (targetBranchMap == null) { - LOG.debug("Skipping model with null targetBranchMap during save"); + LOG.warn("Model has null targetBranchMap during save - this should not happen in steady state. Skipping."); return; } + + MyModelBase myModelBase = new MyModelBase(); myModelBase.setTargetBranchMap(targetBranchMap); // Save the custom tab name @@ -242,29 +416,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) { @@ -286,9 +438,12 @@ public void addRevisionTab(String revision) { // Set up tooltip after target branches are added if (myModel.getCustomTabName() != null && !myModel.getCustomTabName().isEmpty()) { + final DisposalToken token = disposalToken; ApplicationManager.getApplication().invokeLater(() -> { - toolWindowService.setupTabTooltip(myModel); - }); + if (!token.disposed && toolWindowService != null) { + toolWindowService.setupTabTooltip(myModel); + } + }, ModalityState.any(), __ -> token.disposed); } }); } @@ -354,33 +509,55 @@ public void toggleActionInvoked() { private void subscribeToObservable(MyModel model) { Observable observable = model.getObservable(); - observable.subscribe(field -> { + io.reactivex.rxjava3.disposables.Disposable subscription = observable.subscribe(field -> { switch (field) { case targetBranch -> { - getTargetBranchDisplayAsync(model, tabName -> { - toolWindowService.changeTabName(tabName); - }); + // Don't update tab names during initialization - tabs are named correctly during creation + if (!tabInitializationInProgress.get()) { + getTargetBranchDisplayAsync(model, tabName -> { + // Use model-specific tab name change to avoid changing wrong tab + toolWindowService.changeTabNameForModel(model, tabName); + }); + // Set up tooltip when target branch changes (for tabs with custom names) + if (model.getCustomTabName() != null && !model.getCustomTabName().isEmpty()) { + toolWindowService.setupTabTooltip(model); + } + } // TODO: collectChanges: target branch selected (Git Scope selected) incrementUpdate(); collectChanges(model, true); - save(); + // Don't save during initialization + if (!tabInitializationInProgress.get()) { + save(); + } } case changes -> { if (model.isActive()) { Collection changes = model.getChanges(); doUpdateDebounced(changes); + + // Refresh file colors once on initial startup after changes are loaded for the active model + // This handles the boot case where setActiveModel() is called before changes are collected + if (!initialFileColorsRefreshed.get() && changes != null && !changes.isEmpty()) { + if (initialFileColorsRefreshed.compareAndSet(false, true)) { + refreshFileColors(); + } + } } } // TODO: collectChanges: tab switched case active -> { incrementUpdate(); // Increment generation to cancel any stale updates for previous tab - collectChanges(model, true); + // Refresh file colors AFTER scopeChangesMap is updated + // This ensures GitScopeFileStatusProvider sees the correct scope + collectChanges(model, true).thenRun(this::refreshFileColors); } case tabName -> { if (!isProcessingTabRename) { String customName = model.getCustomTabName(); if (customName != null && !customName.isEmpty()) { - toolWindowService.changeTabName(customName); + // Use model-specific tab name change to avoid changing wrong tab + toolWindowService.changeTabNameForModel(model, customName); toolWindowService.setupTabTooltip(model); } } @@ -390,6 +567,9 @@ private void subscribeToObservable(MyModel model) { }, (e -> { })); + + // Track the subscription so it can be disposed when ViewService is disposed + rxSubscriptions.add(subscription); } private void getTargetBranchDisplayCurrent(Consumer callback) { @@ -440,50 +620,90 @@ 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 + final DisposalToken token = this.disposalToken; changesExecutor.execute(() -> { - changesService.collectChangesWithCallback(targetBranchMap, changes -> { + changesService.collectChangesWithCallback(finalTargetBranchMap, result -> { ApplicationManager.getApplication().invokeLater(() -> { try { long currentGen = applyGeneration.get(); - if (!project.isDisposed() && currentGen == gen) { + if (!project.isDisposed() && !token.disposed && 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 + ")"); } } finally { done.complete(null); } - }); + }, ModalityState.any(), __ -> token.disposed); }, checkFs); }); - - return done; } // helper to enqueue UI work strictly after the currently queued collections public void runAfterCurrentChangeCollection(Runnable uiTask) { - changesExecutor.execute(() -> - ApplicationManager.getApplication().invokeLater(() -> { - if (!project.isDisposed()) uiTask.run(); - }) - ); + if (isDisposed) return; + final DisposalToken token = this.disposalToken; + changesExecutor.execute(() -> { + if (token.disposed) return; + ApplicationManager.getApplication().invokeLater(() -> { + if (!project.isDisposed() && !token.disposed) uiTask.run(); + }, ModalityState.any(), __ -> token.disposed); + }); } @@ -495,8 +715,19 @@ public MyModel addModel() { } public MyModel getCurrent() { + // Check if disposed or not initialized + if (isDisposed || toolWindowService == null) { + return myHeadModel; // Safe fallback + } + + // Get tool window safely + ToolWindow toolWindow = toolWindowService.getToolWindow(); + if (toolWindow == null) { + return myHeadModel; // Tool window not available + } + // Get the currently selected tab's model directly from ContentManager - ContentManager contentManager = toolWindowService.getToolWindow().getContentManager(); + ContentManager contentManager = toolWindow.getContentManager(); Content selectedContent = contentManager.getSelectedContent(); if (selectedContent == null) { @@ -531,6 +762,47 @@ public void setCollection(List collection) { this.collection = collection; } + /** + * Core private method to retrieve a cached HashMap from the current MyModel. + * Handles initialization checks and returns the pre-built map. + * + * @param mapGetter Function to retrieve the cached map from MyModel + * @return Map of file path to Change, or null if not initialized + */ + private Map getChangesMapInternal(Function> mapGetter) { + // Early return if ViewService is not fully initialized yet + if (toolWindowService == null) { + return null; + } + + MyModel current = getCurrent(); + if (current == null) { + return null; + } + + return mapGetter.apply(current); + } + + /** + * 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 Map of file path to Change, or null if not initialized + */ + public Map getLocalChangesTowardsHeadMap() { + return getChangesMapInternal(MyModel::getLocalChangesMap); + } + + /** + * 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) { int modelIndex = getModelIndex(tabIndex); // Check if the index is valid before removing @@ -628,6 +900,10 @@ public void onTabRenamed(int tabIndex, String newName) { try { isProcessingTabRename = true; + // Rebuild collection from tab order to ensure consistency + // This is critical because the collection might be out of sync with actual tabs + rebuildCollectionFromTabOrder(); + // Update the model with custom name if needed int modelIndex = getModelIndex(tabIndex); if (modelIndex >= 0 && modelIndex < collection.size()) { @@ -680,25 +956,38 @@ public void setActiveModel() { } public void onUpdate(Collection changes) { - if (changes == null) { + if (changes == null || isDisposed) { return; } // Run UI updates on EDT + final DisposalToken token = this.disposalToken; ApplicationManager.getApplication().invokeLater(() -> { + if (token.disposed) return; + updateStatusBarWidget(); - myLineStatusTrackerImpl.update(changes, null); + // Get the current scope changes map to pass to line status tracker + Map scopeChangesMap = getCurrentScopeChangesMap(); + myLineStatusTrackerImpl.update(scopeChangesMap); myScope.update(changes); // Perform scroll restoration after all UI updates are complete // Use another invokeLater to ensure everything is fully rendered + // Update VcsTree with changes + VcsTree vcsTree = toolWindowService.getVcsTree(); + if (vcsTree != null) { + vcsTree.update(changes); + } + SwingUtilities.invokeLater(() -> { - VcsTree vcsTree = toolWindowService.getVcsTree(); - if (vcsTree != null) { - vcsTree.performScrollRestoration(); + if (token.disposed || toolWindowService == null) return; + + VcsTree tree = toolWindowService.getVcsTree(); + if (tree != null) { + tree.performScrollRestoration(); } }); - }); + }, ModalityState.any(), __ -> token.disposed); } private void updateStatusBarWidget() { 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/statusBar/MyStatusBarWidget.java b/src/main/java/statusBar/MyStatusBarWidget.java index 80e3420..b29963f 100644 --- a/src/main/java/statusBar/MyStatusBarWidget.java +++ b/src/main/java/statusBar/MyStatusBarWidget.java @@ -40,6 +40,10 @@ public String ID() { @Override public void dispose() { -// keep + // Clean up the status bar panel to break JNI references + JComponent panel = getComponent(); + if (panel != null) { + panel.removeAll(); + } } } diff --git a/src/main/java/toolwindow/BranchSelectView.java b/src/main/java/toolwindow/BranchSelectView.java index 7be8847..bb9a6f4 100644 --- a/src/main/java/toolwindow/BranchSelectView.java +++ b/src/main/java/toolwindow/BranchSelectView.java @@ -32,7 +32,8 @@ public class BranchSelectView { private final Project project; private final GitService gitService; private final State state; - private SearchTextField search; + private final SearchTextField search; + private final java.util.List branchTrees = new java.util.ArrayList<>(); private JPanel createManualInputPanel(GitRepository repository, BranchTree branchTree) { JPanel manualInputPanel = new JPanel(new BorderLayout()); @@ -145,6 +146,7 @@ public void mouseEntered(MouseEvent me) { } BranchTree branchTree = createBranchTree(project, node); + branchTrees.add(branchTree); // Track for cleanup main.add(createManualInputPanel(gitRepository, branchTree)); main.add(branchTree); }); @@ -217,4 +219,17 @@ public JPanel getRootPanel() { return rootPanel; } + public void dispose() { + // Clean up all branch trees (removes listeners) + for (BranchTree branchTree : branchTrees) { + if (branchTree != null) { + branchTree.removeAll(); + } + } + branchTrees.clear(); + + // Clean up root panel + rootPanel.removeAll(); + } + } \ No newline at end of file diff --git a/src/main/java/toolwindow/TabOperations.java b/src/main/java/toolwindow/TabOperations.java index f4fb717..617b1ca 100644 --- a/src/main/java/toolwindow/TabOperations.java +++ b/src/main/java/toolwindow/TabOperations.java @@ -1,318 +1,28 @@ package toolwindow; -import com.intellij.openapi.actionSystem.*; import com.intellij.openapi.application.ApplicationManager; import com.intellij.openapi.project.Project; -import com.intellij.openapi.ui.Messages; import com.intellij.openapi.wm.ToolWindow; import com.intellij.openapi.wm.ToolWindowManager; import com.intellij.ui.content.Content; import com.intellij.ui.content.ContentManager; import model.MyModel; -import org.jetbrains.annotations.NotNull; import service.TargetBranchService; -import service.ToolWindowServiceInterface; import service.ViewService; import system.Defs; -import java.awt.*; -import java.lang.reflect.Field; import java.util.Map; +/** + * Helper class for tab operations (tooltip setup, tab name changes). + * Note: Tab context menu actions (rename, reset, move left/right) are now registered in plugin.xml + * and implemented in separate action classes (RenameTabAction, ResetTabNameAction, TabMoveActions). + */ public class TabOperations { private final Project project; - // Reuse action instances to avoid creating new instances on every registration (IntelliJ 2025.3+ requirement) - private final AnAction moveLeftAction; - private final AnAction moveRightAction; - private final AnAction renameAction; - private final AnAction resetTabNameAction; - public TabOperations(Project project) { this.project = project; - - // Initialize actions once - they must be instance fields, not local variables - this.moveLeftAction = new TabMoveActions.MoveTabLeft(); - this.moveRightAction = new TabMoveActions.MoveTabRight(); - - // Create a rename action that will be added to the tab context menu - this.renameAction = new AnAction("Rename Tab") { - @Override - public @NotNull ActionUpdateThread getActionUpdateThread() { - return ActionUpdateThread.EDT; - } - - @Override - public void actionPerformed(@NotNull AnActionEvent e) { - // Get the right-clicked tab, not the selected one - Content targetContent = getContentFromContextMenuEvent(e); - if (targetContent != null) { - renameTab(targetContent); - } - } - - @Override - public void update(@NotNull AnActionEvent e) { - // By default, hide the action - e.getPresentation().setEnabledAndVisible(false); - - // Get the project from the action event - Project eventProject = e.getProject(); - if (eventProject == null || !eventProject.equals(project)) { - return; // Not our project or no project - } - - // Get the tool window directly from the event data - ToolWindow toolWindow = e.getData(PlatformDataKeys.TOOL_WINDOW); - - // Only proceed for our Git Scope tool window - if (toolWindow != null && Defs.TOOL_WINDOW_NAME.equals(toolWindow.getId())) { - // Get the content that was right-clicked, not the selected one - Content targetContent = getContentFromContextMenuEvent(e); - if (targetContent != null) { - ContentManager contentManager = toolWindow.getContentManager(); - int index = contentManager.getIndexOfContent(targetContent); - String currentName = targetContent.getDisplayName(); - - // Don't allow renaming special tabs (HEAD tab or PLUS tab) - boolean enabled = index > 0 && !ViewService.PLUS_TAB_LABEL.equals(currentName); - e.getPresentation().setEnabledAndVisible(enabled); - } - } - } - }; - - // Create a reset tab name action - this.resetTabNameAction = new AnAction("Reset Tab Name") { - @Override - public @NotNull ActionUpdateThread getActionUpdateThread() { - return ActionUpdateThread.EDT; - } - - @Override - public void actionPerformed(@NotNull AnActionEvent e) { - // Get the right-clicked tab - Content targetContent = getContentFromContextMenuEvent(e); - if (targetContent != null) { - resetTabName(targetContent); - } - } - - @Override - public void update(@NotNull AnActionEvent e) { - // By default, hide the action - e.getPresentation().setEnabledAndVisible(false); - - // Get the project from the action event - Project eventProject = e.getProject(); - if (eventProject == null || !eventProject.equals(project)) { - return; - } - - // Get the tool window from the event data - ToolWindow toolWindow = e.getData(PlatformDataKeys.TOOL_WINDOW); - - // Only proceed for our Git Scope tool window - if (toolWindow != null && Defs.TOOL_WINDOW_NAME.equals(toolWindow.getId())) { - // Get the content that was right-clicked - Content targetContent = getContentFromContextMenuEvent(e); - if (targetContent != null) { - ContentManager contentManager = toolWindow.getContentManager(); - int index = contentManager.getIndexOfContent(targetContent); - String currentName = targetContent.getDisplayName(); - - // Enable only for non-special tabs that have a custom name - boolean isSpecialTab = index == 0 || ViewService.PLUS_TAB_LABEL.equals(currentName); - if (!isSpecialTab) { - // Check if this tab has a custom name - ViewService viewService = project.getService(ViewService.class); - int modelIndex = viewService.getModelIndex(index); - if (modelIndex >= 0 && modelIndex < viewService.getCollection().size()) { - MyModel model = viewService.getCollection().get(modelIndex); - boolean hasCustomName = model.getCustomTabName() != null && !model.getCustomTabName().isEmpty(); - e.getPresentation().setEnabledAndVisible(hasCustomName); - } - } - } - } - } - }; - } - - public void registerTabActions() { - // Get the action manager and register our actions - ActionManager actionManager = ActionManager.getInstance(); - - // Register the move left action - String moveLeftActionId = "GitScope.MoveTabLeft"; - if (actionManager.getAction(moveLeftActionId) == null) { - actionManager.registerAction(moveLeftActionId, moveLeftAction); - } else { - actionManager.unregisterAction(moveLeftActionId); - actionManager.registerAction(moveLeftActionId, moveLeftAction); - } - - // Register the move right action - String moveRightActionId = "GitScope.MoveTabRight"; - if (actionManager.getAction(moveRightActionId) == null) { - actionManager.registerAction(moveRightActionId, moveRightAction); - } else { - actionManager.unregisterAction(moveRightActionId); - actionManager.registerAction(moveRightActionId, moveRightAction); - } - - // Register the rename action - String renameActionId = "GitScope.RenameTab"; - if (actionManager.getAction(renameActionId) == null) { - actionManager.registerAction(renameActionId, renameAction); - } else { - actionManager.unregisterAction(renameActionId); - actionManager.registerAction(renameActionId, renameAction); - } - - // Register the reset action - String resetActionId = "GitScope.ResetTabName"; - if (actionManager.getAction(resetActionId) == null) { - actionManager.registerAction(resetActionId, resetTabNameAction); - } else { - actionManager.unregisterAction(resetActionId); - actionManager.registerAction(resetActionId, resetTabNameAction); - } - - // Add the actions to the ToolWindowContextMenu group - DefaultActionGroup contextMenuGroup = (DefaultActionGroup) actionManager.getAction("ToolWindowContextMenu"); - if (contextMenuGroup != null) { - // Remove any existing instances of our actions first to avoid duplicates - AnAction[] actions = contextMenuGroup.getChildActionsOrStubs(); - for (AnAction action : actions) { - if (action.getTemplateText() != null && - (action.getTemplateText().equals("Rename Tab") || - action.getTemplateText().equals("Reset Tab Name") || - action.getTemplateText().equals("Move Tab Left") || - action.getTemplateText().equals("Move Tab Right"))) { - contextMenuGroup.remove(action); - } - } - - // Add our actions to the group in the desired order: - // 1. Move Tab Left - // 2. Move Tab Right - // 3. Rename Tab - // 4. Reset Tab Name - // Note: Add null checks to prevent IllegalArgumentException in IntelliJ 2025.3+ - if (resetTabNameAction != null) { - contextMenuGroup.add(resetTabNameAction, Constraints.FIRST); - } - if (renameAction != null) { - contextMenuGroup.add(renameAction, Constraints.FIRST); - } - if (moveRightAction != null) { - contextMenuGroup.add(moveRightAction, Constraints.FIRST); - } - if (moveLeftAction != null) { - contextMenuGroup.add(moveLeftAction, Constraints.FIRST); - } - } - } - - /** - * Resets a tab name to its original branch-based name by clearing the custom name - */ - public void resetTabName(Content content) { - ContentManager contentManager = getContentManager(); - int index = contentManager.getIndexOfContent(content); - - // Don't allow resetting special tabs - if (index == 0 || content.getDisplayName().equals(ViewService.PLUS_TAB_LABEL)) { - return; - } - - ViewService viewService = project.getService(ViewService.class); - if (viewService != null) { - int modelIndex = viewService.getModelIndex(index); - if (modelIndex >= 0 && modelIndex < viewService.getCollection().size()) { - MyModel model = viewService.getCollection().get(modelIndex); - - // Clear the custom name - model.setCustomTabName(null); - - // Update the UI with the branch-based name - TargetBranchService targetBranchService = project.getService(TargetBranchService.class); - targetBranchService.getTargetBranchDisplayAsync(model.getTargetBranchMap(), branchName -> { - ApplicationManager.getApplication().invokeLater(() -> { - // Update the tab name in the UI - content.setDisplayName(branchName); - // Clear the tooltip - content.setDescription(null); - - // Notify the view service of the change - viewService.onTabRenamed(index, branchName); - }); - }); - } - } - } - - /** - * Gets the Content that was right-clicked in a context menu event - */ - private Content getContentFromContextMenuEvent(AnActionEvent e) { - // Try to get the specific component that was clicked on - Component component = e.getData(PlatformDataKeys.CONTEXT_COMPONENT); - if (component == null) { - return null; - } - Component contextComponent = e.getData(PlatformDataKeys.CONTEXT_COMPONENT); - try { - // Try to access the "myComponent" field using reflection - assert contextComponent != null; - Field myComponentField = contextComponent.getClass().getDeclaredField("myContent"); - myComponentField.setAccessible(true); // Make private field accessible - - Object myComponentObject = myComponentField.get(contextComponent); - if (myComponentObject instanceof Content) { - return (Content) myComponentObject; - } - } catch (NoSuchFieldException | IllegalAccessException ignored) { - } - return null; - } - - public void renameTab(Content content) { - ContentManager contentManager = getContentManager(); - int index = contentManager.getIndexOfContent(content); - String currentName = content.getDisplayName(); - - // Don't allow renaming special tabs - if (index == 0 || currentName.equals(ViewService.PLUS_TAB_LABEL)) { - return; - } - - String newName = Messages.showInputDialog( - contentManager.getComponent(), - "Enter new tab name:", - "Rename Tab", - Messages.getQuestionIcon(), - currentName, - null - ); - - if (newName != null && !newName.isEmpty()) { - content.setDisplayName(newName); - - // Update the model - ViewService viewService = project.getService(ViewService.class); - if (viewService != null) { - viewService.onTabRenamed(index, newName); - - int modelIndex = viewService.getModelIndex(index); - if (modelIndex >= 0 && modelIndex < viewService.getCollection().size()) { - MyModel model = viewService.getCollection().get(modelIndex); - ToolWindowServiceInterface toolWindowService = project.getService(ToolWindowServiceInterface.class); - toolWindowService.setupTabTooltip(model); - } - } - } } public void setupTabTooltip(MyModel model, Map contentToViewMap) { @@ -377,4 +87,4 @@ private ContentManager getContentManager() { assert toolWindow != null; return toolWindow.getContentManager(); } -} \ No newline at end of file +} diff --git a/src/main/java/toolwindow/ToolWindowView.java b/src/main/java/toolwindow/ToolWindowView.java index 6b99dba..3b9078f 100644 --- a/src/main/java/toolwindow/ToolWindowView.java +++ b/src/main/java/toolwindow/ToolWindowView.java @@ -1,5 +1,6 @@ package toolwindow; +import com.intellij.openapi.Disposable; import com.intellij.openapi.project.Project; import com.intellij.openapi.vcs.changes.Change; import model.MyModel; @@ -10,7 +11,7 @@ import javax.swing.*; import java.awt.*; -public class ToolWindowView { +public class ToolWindowView implements Disposable { private final MyModel myModel; private final Project project; @@ -19,16 +20,50 @@ public class ToolWindowView { private VcsTree vcsTree; private JPanel sceneA; private JPanel sceneB; + private BranchSelectView branchSelectView; + private io.reactivex.rxjava3.disposables.Disposable subscription; public ToolWindowView(Project project, MyModel myModel) { this.project = project; this.myModel = myModel; - myModel.getObservable().subscribe(model -> render()); + subscription = myModel.getObservable().subscribe(model -> render()); draw(); render(); } + @Override + public void dispose() { + // Dispose the observable subscription first to prevent further updates + if (subscription != null && !subscription.isDisposed()) { + subscription.dispose(); + subscription = null; + } + + // Dispose BranchSelectView (removes listeners from trees, checkboxes, etc.) + if (branchSelectView != null) { + branchSelectView.dispose(); + branchSelectView = null; + } + + // Explicitly cleanup VcsTree first (cancels futures, disposes browsers) + if (vcsTree != null) { + vcsTree.cleanup(); + vcsTree = null; + } + + // Remove all components from panels to break JNI references + if (sceneB != null) { + sceneB.removeAll(); + sceneB = null; + } + if (sceneA != null) { + sceneA.removeAll(); + sceneA = null; + } + rootPanel.removeAll(); + } + private void draw() { this.sceneA = getBranchSelectPanel(); this.sceneB = getChangesPanel(); @@ -37,8 +72,8 @@ private void draw() { } private JPanel getBranchSelectPanel() { - BranchSelectView branchSelectPanel = new BranchSelectView(project); - return branchSelectPanel.getRootPanel(); + branchSelectView = new BranchSelectView(project); + return branchSelectView.getRootPanel(); } private JPanel getChangesPanel() { diff --git a/src/main/java/toolwindow/actions/RenameTabAction.java b/src/main/java/toolwindow/actions/RenameTabAction.java new file mode 100644 index 0000000..173a67d --- /dev/null +++ b/src/main/java/toolwindow/actions/RenameTabAction.java @@ -0,0 +1,125 @@ +package toolwindow.actions; + +import com.intellij.openapi.actionSystem.*; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.ui.Messages; +import com.intellij.openapi.wm.ToolWindow; +import com.intellij.ui.content.Content; +import com.intellij.ui.content.ContentManager; +import model.MyModel; +import org.jetbrains.annotations.NotNull; +import service.ToolWindowServiceInterface; +import service.ViewService; +import system.Defs; + +import java.awt.*; +import java.lang.reflect.Field; + +/** + * Action to rename a tab in the Git Scope tool window. + * Registered in plugin.xml and works across all projects. + */ +public class RenameTabAction extends AnAction { + + @Override + public void actionPerformed(@NotNull AnActionEvent e) { + Project project = e.getProject(); + if (project == null) return; + + Content targetContent = getContentFromContextMenuEvent(e); + if (targetContent == null) return; + + ToolWindowServiceInterface toolWindowService = project.getService(ToolWindowServiceInterface.class); + ToolWindow toolWindow = toolWindowService.getToolWindow(); + if (toolWindow == null) return; + + ContentManager contentManager = toolWindow.getContentManager(); + int index = contentManager.getIndexOfContent(targetContent); + String currentName = targetContent.getDisplayName(); + + // Don't allow renaming special tabs + if (index == 0 || currentName.equals(ViewService.PLUS_TAB_LABEL)) { + return; + } + + String newName = Messages.showInputDialog( + contentManager.getComponent(), + "Enter new tab name:", + "Rename Tab", + Messages.getQuestionIcon(), + currentName, + null + ); + + if (newName != null && !newName.isEmpty()) { + targetContent.setDisplayName(newName); + + // Update the model + ViewService viewService = project.getService(ViewService.class); + if (viewService != null) { + viewService.onTabRenamed(index, newName); + + int modelIndex = viewService.getModelIndex(index); + if (modelIndex >= 0 && modelIndex < viewService.getCollection().size()) { + MyModel model = viewService.getCollection().get(modelIndex); + toolWindowService.setupTabTooltip(model); + } + } + } + } + + @Override + public void update(@NotNull AnActionEvent e) { + // By default, hide the action + e.getPresentation().setEnabledAndVisible(false); + + Project project = e.getProject(); + if (project == null) { + return; + } + + // Check if this is our tool window + ToolWindow toolWindow = e.getData(PlatformDataKeys.TOOL_WINDOW); + if (toolWindow == null || !Defs.TOOL_WINDOW_NAME.equals(toolWindow.getId())) { + return; + } + + // Get the content that was right-clicked + Content targetContent = getContentFromContextMenuEvent(e); + if (targetContent != null) { + ContentManager contentManager = toolWindow.getContentManager(); + int index = contentManager.getIndexOfContent(targetContent); + String currentName = targetContent.getDisplayName(); + + // Don't allow renaming special tabs (HEAD tab or PLUS tab) + boolean enabled = index > 0 && !ViewService.PLUS_TAB_LABEL.equals(currentName); + e.getPresentation().setEnabledAndVisible(enabled); + } + } + + @Override + public @NotNull ActionUpdateThread getActionUpdateThread() { + return ActionUpdateThread.EDT; + } + + /** + * Gets the Content that was right-clicked in a context menu event + */ + private Content getContentFromContextMenuEvent(AnActionEvent e) { + Component contextComponent = e.getData(PlatformDataKeys.CONTEXT_COMPONENT); + if (contextComponent == null) { + return null; + } + try { + Field myContentField = contextComponent.getClass().getDeclaredField("myContent"); + myContentField.setAccessible(true); + Object myContentObject = myContentField.get(contextComponent); + if (myContentObject instanceof Content) { + return (Content) myContentObject; + } + } catch (NoSuchFieldException | IllegalAccessException ignored) { + } + return null; + } +} diff --git a/src/main/java/toolwindow/actions/ResetTabNameAction.java b/src/main/java/toolwindow/actions/ResetTabNameAction.java new file mode 100644 index 0000000..1d3267c --- /dev/null +++ b/src/main/java/toolwindow/actions/ResetTabNameAction.java @@ -0,0 +1,133 @@ +package toolwindow.actions; + +import com.intellij.openapi.actionSystem.*; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.wm.ToolWindow; +import com.intellij.ui.content.Content; +import com.intellij.ui.content.ContentManager; +import model.MyModel; +import org.jetbrains.annotations.NotNull; +import service.TargetBranchService; +import service.ViewService; +import system.Defs; + +import java.awt.*; +import java.lang.reflect.Field; + +/** + * Action to reset a tab name to its original branch-based name. + * Registered in plugin.xml and works across all projects. + */ +public class ResetTabNameAction extends AnAction { + + @Override + public void actionPerformed(@NotNull AnActionEvent e) { + Project project = e.getProject(); + if (project == null) return; + + Content targetContent = getContentFromContextMenuEvent(e); + if (targetContent == null) return; + + ToolWindow toolWindow = e.getData(PlatformDataKeys.TOOL_WINDOW); + if (toolWindow == null || !Defs.TOOL_WINDOW_NAME.equals(toolWindow.getId())) { + return; + } + + ContentManager contentManager = toolWindow.getContentManager(); + int index = contentManager.getIndexOfContent(targetContent); + + // Don't allow resetting special tabs + if (index == 0 || targetContent.getDisplayName().equals(ViewService.PLUS_TAB_LABEL)) { + return; + } + + ViewService viewService = project.getService(ViewService.class); + if (viewService != null) { + int modelIndex = viewService.getModelIndex(index); + if (modelIndex >= 0 && modelIndex < viewService.getCollection().size()) { + MyModel model = viewService.getCollection().get(modelIndex); + + // Clear the custom name - this effectively resets to the default branch-based name + model.setCustomTabName(null); + + // Save the change + viewService.save(); + + // Update the UI with the branch-based name + TargetBranchService targetBranchService = project.getService(TargetBranchService.class); + targetBranchService.getTargetBranchDisplayAsync(model.getTargetBranchMap(), branchName -> { + ApplicationManager.getApplication().invokeLater(() -> { + // Update the tab name in the UI + targetContent.setDisplayName(branchName); + // Clear the tooltip + targetContent.setDescription(null); + }); + }); + } + } + } + + @Override + public void update(@NotNull AnActionEvent e) { + // By default, hide the action + e.getPresentation().setEnabledAndVisible(false); + + Project project = e.getProject(); + if (project == null) { + return; + } + + // Check if this is our tool window + ToolWindow toolWindow = e.getData(PlatformDataKeys.TOOL_WINDOW); + if (toolWindow == null || !Defs.TOOL_WINDOW_NAME.equals(toolWindow.getId())) { + return; + } + + // Get the content that was right-clicked + Content targetContent = getContentFromContextMenuEvent(e); + if (targetContent != null) { + ContentManager contentManager = toolWindow.getContentManager(); + int index = contentManager.getIndexOfContent(targetContent); + String currentName = targetContent.getDisplayName(); + + // Enable only for non-special tabs that have a custom name + boolean isSpecialTab = index == 0 || ViewService.PLUS_TAB_LABEL.equals(currentName); + if (!isSpecialTab) { + // Check if this tab has a custom name + ViewService viewService = project.getService(ViewService.class); + int modelIndex = viewService.getModelIndex(index); + if (modelIndex >= 0 && modelIndex < viewService.getCollection().size()) { + MyModel model = viewService.getCollection().get(modelIndex); + boolean hasCustomName = model.getCustomTabName() != null && !model.getCustomTabName().isEmpty(); + e.getPresentation().setEnabledAndVisible(hasCustomName); + } + } + } + } + + @Override + public @NotNull ActionUpdateThread getActionUpdateThread() { + return ActionUpdateThread.EDT; + } + + /** + * Gets the Content that was right-clicked in a context menu event + */ + private Content getContentFromContextMenuEvent(AnActionEvent e) { + Component contextComponent = e.getData(PlatformDataKeys.CONTEXT_COMPONENT); + if (contextComponent == null) { + return null; + } + try { + Field myContentField = contextComponent.getClass().getDeclaredField("myContent"); + myContentField.setAccessible(true); + Object myContentObject = myContentField.get(contextComponent); + if (myContentObject instanceof Content) { + return (Content) myContentObject; + } + } catch (NoSuchFieldException | IllegalAccessException ignored) { + } + return null; + } +} diff --git a/src/main/java/toolwindow/TabMoveActions.java b/src/main/java/toolwindow/actions/TabMoveActions.java similarity index 56% rename from src/main/java/toolwindow/TabMoveActions.java rename to src/main/java/toolwindow/actions/TabMoveActions.java index a61399f..e501484 100644 --- a/src/main/java/toolwindow/TabMoveActions.java +++ b/src/main/java/toolwindow/actions/TabMoveActions.java @@ -1,4 +1,4 @@ -package toolwindow; +package toolwindow.actions; import com.intellij.openapi.actionSystem.ActionUpdateThread; import com.intellij.openapi.actionSystem.AnAction; @@ -47,39 +47,113 @@ private static Content getContentFromContextMenuEvent(AnActionEvent e) { } /** - * Action to move the current tab to the left + * Helper class to hold validation result for update() and actionPerformed() methods */ - public static class MoveTabLeft extends AnAction { - public MoveTabLeft() { - super("Move Tab Left"); + private static class UpdateContext { + final ContentManager contentManager; + final Content targetContent; + final int currentIndex; + final String tabName; + + UpdateContext(ContentManager contentManager, Content targetContent, int currentIndex, String tabName) { + this.contentManager = contentManager; + this.targetContent = targetContent; + this.currentIndex = currentIndex; + this.tabName = tabName; } + } - @Override - public void actionPerformed(@NotNull AnActionEvent e) { - Project project = e.getProject(); - if (project == null) return; + /** + * Helper class to hold action context including project and validated context + */ + private static class ActionContext { + final Project project; + final ContentManager contentManager; + final Content targetContent; + final int currentIndex; + + ActionContext(Project project, ContentManager contentManager, Content targetContent, int currentIndex) { + this.project = project; + this.contentManager = contentManager; + this.targetContent = targetContent; + this.currentIndex = currentIndex; + } + } + + /** + * Shared validation logic for actionPerformed() methods. + * Returns ActionContext if validation passes, null otherwise. + */ + @Nullable + private static ActionContext validateActionContext(AnActionEvent e) { + Project project = e.getProject(); + if (project == null) return null; + + // Get the right-clicked tab content + Content targetContent = getContentFromContextMenuEvent(e); + if (targetContent == null) return null; + + ToolWindowServiceInterface toolWindowService = project.getService(ToolWindowServiceInterface.class); + ContentManager contentManager = toolWindowService.getToolWindow().getContentManager(); - // Get the right-clicked tab content - Content targetContent = getContentFromContextMenuEvent(e); - if (targetContent == null) return; + int currentIndex = contentManager.getIndexOfContent(targetContent); - ToolWindowServiceInterface toolWindowService = project.getService(ToolWindowServiceInterface.class); - ContentManager contentManager = toolWindowService.getToolWindow().getContentManager(); + return new ActionContext(project, contentManager, targetContent, currentIndex); + } + + /** + * Shared validation logic for update() methods. + * Returns UpdateContext if validation passes, null otherwise. + */ + @Nullable + private static UpdateContext validateUpdateContext(AnActionEvent e) { + Project project = e.getProject(); + if (project == null) { + return null; + } - int currentIndex = contentManager.getIndexOfContent(targetContent); - int newIndex = currentIndex - 1; + // Check if this is our tool window + ToolWindow toolWindow = e.getData(PlatformDataKeys.TOOL_WINDOW); + if (toolWindow == null || !Defs.TOOL_WINDOW_NAME.equals(toolWindow.getId())) { + return null; + } + + // Get the right-clicked tab content + Content targetContent = getContentFromContextMenuEvent(e); + if (targetContent == null) { + return null; + } + + ContentManager contentManager = toolWindow.getContentManager(); + int currentIndex = contentManager.getIndexOfContent(targetContent); + String tabName = targetContent.getTabName(); + + return new UpdateContext(contentManager, targetContent, currentIndex, tabName); + } + + /** + * Action to move the current tab to the left. + * Registered in plugin.xml and works across all projects. + */ + public static class MoveTabLeft extends AnAction { + @Override + public void actionPerformed(@NotNull AnActionEvent e) { + ActionContext ctx = validateActionContext(e); + if (ctx == null) return; + + int newIndex = ctx.currentIndex - 1; // Cannot move HEAD tab (index 0) or move before HEAD tab - if (currentIndex <= 1 || newIndex < 1) { + if (ctx.currentIndex <= 1 || newIndex < 1) { return; } // Cannot move + tab - if (PLUS_TAB_LABEL.equals(targetContent.getTabName())) { + if (PLUS_TAB_LABEL.equals(ctx.targetContent.getTabName())) { return; } - moveTab(project, contentManager, targetContent, currentIndex, newIndex); + moveTab(ctx.project, ctx.contentManager, ctx.targetContent, ctx.currentIndex, newIndex); } @Override @@ -87,29 +161,13 @@ public void update(@NotNull AnActionEvent e) { // By default, hide the action e.getPresentation().setEnabledAndVisible(false); - Project project = e.getProject(); - if (project == null) { - return; - } - - // Check if this is our tool window - ToolWindow toolWindow = e.getData(PlatformDataKeys.TOOL_WINDOW); - if (toolWindow == null || !Defs.TOOL_WINDOW_NAME.equals(toolWindow.getId())) { - return; - } - - // Get the right-clicked tab content - Content targetContent = getContentFromContextMenuEvent(e); - if (targetContent == null) { + UpdateContext ctx = validateUpdateContext(e); + if (ctx == null) { return; } - ContentManager contentManager = toolWindow.getContentManager(); - int currentIndex = contentManager.getIndexOfContent(targetContent); - String tabName = targetContent.getTabName(); - // Enable only if not HEAD tab (index 0), not + tab, and can move left (index > 1) - boolean enabled = currentIndex > 1 && !PLUS_TAB_LABEL.equals(tabName); + boolean enabled = ctx.currentIndex > 1 && !PLUS_TAB_LABEL.equals(ctx.tabName); e.getPresentation().setEnabledAndVisible(enabled); } @@ -120,40 +178,29 @@ public void update(@NotNull AnActionEvent e) { } /** - * Action to move the current tab to the right + * Action to move the current tab to the right. + * Registered in plugin.xml and works across all projects. */ public static class MoveTabRight extends AnAction { - public MoveTabRight() { - super("Move Tab Right"); - } - @Override public void actionPerformed(@NotNull AnActionEvent e) { - Project project = e.getProject(); - if (project == null) return; - - // Get the right-clicked tab content - Content targetContent = getContentFromContextMenuEvent(e); - if (targetContent == null) return; + ActionContext ctx = validateActionContext(e); + if (ctx == null) return; - ToolWindowServiceInterface toolWindowService = project.getService(ToolWindowServiceInterface.class); - ContentManager contentManager = toolWindowService.getToolWindow().getContentManager(); - - int currentIndex = contentManager.getIndexOfContent(targetContent); - int newIndex = currentIndex + 1; - int lastIndex = contentManager.getContentCount() - 1; + int newIndex = ctx.currentIndex + 1; + int lastIndex = ctx.contentManager.getContentCount() - 1; // Cannot move HEAD tab (index 0) or move past + tab - if (currentIndex == 0 || newIndex >= lastIndex) { + if (ctx.currentIndex == 0 || newIndex >= lastIndex) { return; } // Cannot move + tab - if (PLUS_TAB_LABEL.equals(targetContent.getTabName())) { + if (PLUS_TAB_LABEL.equals(ctx.targetContent.getTabName())) { return; } - moveTab(project, contentManager, targetContent, currentIndex, newIndex); + moveTab(ctx.project, ctx.contentManager, ctx.targetContent, ctx.currentIndex, newIndex); } @Override @@ -161,30 +208,15 @@ public void update(@NotNull AnActionEvent e) { // By default, hide the action e.getPresentation().setEnabledAndVisible(false); - Project project = e.getProject(); - if (project == null) { + UpdateContext ctx = validateUpdateContext(e); + if (ctx == null) { return; } - // Check if this is our tool window - ToolWindow toolWindow = e.getData(PlatformDataKeys.TOOL_WINDOW); - if (toolWindow == null || !Defs.TOOL_WINDOW_NAME.equals(toolWindow.getId())) { - return; - } - - // Get the right-clicked tab content - Content targetContent = getContentFromContextMenuEvent(e); - if (targetContent == null) { - return; - } - - ContentManager contentManager = toolWindow.getContentManager(); - int currentIndex = contentManager.getIndexOfContent(targetContent); - int lastIndex = contentManager.getContentCount() - 1; - String tabName = targetContent.getTabName(); + int lastIndex = ctx.contentManager.getContentCount() - 1; // Enable only if not HEAD tab (index 0), not + tab, and can move right (not already at second-to-last position) - boolean enabled = currentIndex > 0 && currentIndex < lastIndex - 1 && !PLUS_TAB_LABEL.equals(tabName); + boolean enabled = ctx.currentIndex > 0 && ctx.currentIndex < lastIndex - 1 && !PLUS_TAB_LABEL.equals(ctx.tabName); e.getPresentation().setEnabledAndVisible(enabled); } diff --git a/src/main/java/toolwindow/elements/MySimpleChangesBrowser.java b/src/main/java/toolwindow/elements/MySimpleChangesBrowser.java index 2362528..5565ca9 100644 --- a/src/main/java/toolwindow/elements/MySimpleChangesBrowser.java +++ b/src/main/java/toolwindow/elements/MySimpleChangesBrowser.java @@ -14,6 +14,7 @@ import com.intellij.openapi.vcs.changes.ui.SimpleAsyncChangesBrowser; import com.intellij.openapi.vfs.VirtualFile; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import system.Defs; import toolwindow.VcsTreeActions; @@ -21,9 +22,7 @@ import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.lang.reflect.Method; -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; +import java.util.*; import java.util.concurrent.*; public class MySimpleChangesBrowser extends SimpleAsyncChangesBrowser { @@ -31,19 +30,21 @@ public class MySimpleChangesBrowser extends SimpleAsyncChangesBrowser { private final Project myProject; UISettings uiSettings = UISettings.getInstance(); - // Reuse action instances to avoid creating new instances on every toolbar update (IntelliJ 2025.3+ requirement) - // These must be static to be shared across all browser instances - private static final AnAction SELECT_OPENED_FILE_ACTION = new VcsTreeActions.SelectOpenedFileAction(); - private static final AnAction SHOW_IN_PROJECT_ACTION = new VcsTreeActions.ShowInProjectAction(); - private static final AnAction ROLLBACK_ACTION = new VcsTreeActions.RollbackAction(); - - // Pre-create static immutable lists to ensure the SAME list instance is returned every time - private static final List STATIC_TOOLBAR_ACTIONS = java.util.Collections.unmodifiableList( - java.util.Collections.singletonList(SELECT_OPENED_FILE_ACTION) - ); - private static final List STATIC_POPUP_ACTIONS = java.util.Collections.unmodifiableList( - java.util.Arrays.asList(SHOW_IN_PROJECT_ACTION, ROLLBACK_ACTION) - ); + // Instance-level actions to avoid static references that prevent plugin unloading + // Use lazy initialization since super() constructor may call createToolbarActions() before field initialization + private AnAction selectOpenedFileAction; + private AnAction showInProjectAction; + private AnAction rollbackAction; + private List toolbarActions; + + private void initializeActions() { + if (selectOpenedFileAction == null) { + selectOpenedFileAction = new VcsTreeActions.SelectOpenedFileAction(); + showInProjectAction = new VcsTreeActions.ShowInProjectAction(); + rollbackAction = new VcsTreeActions.RollbackAction(); + toolbarActions = Collections.singletonList(selectOpenedFileAction); + } + } /** * Constructor for MySimpleChangesBrowser. @@ -61,14 +62,21 @@ private MySimpleChangesBrowser(@NotNull Project project, @NotNull Collection createPopupMenuActions() { - // Return the SAME static list instance every time to prevent toolbar recreation - return STATIC_POPUP_ACTIONS; + initializeActions(); + // Include parent actions (which provide diff functionality) plus our custom actions + List actions = new ArrayList<>(super.createPopupMenuActions()); + actions.add(showInProjectAction); + actions.add(rollbackAction); + return actions; } @Override protected @NotNull List createToolbarActions() { - // Return the SAME static list instance every time to prevent toolbar recreation - return STATIC_TOOLBAR_ACTIONS; + initializeActions(); + // Include parent actions first (on the left), then add our custom action (on the right) + List actions = new ArrayList<>(super.createToolbarActions()); + actions.add(selectOpenedFileAction); + return actions; } /** @@ -133,28 +141,11 @@ private void openInPreviewTab(Project project, VirtualFile file) { options = withReuseOpen.invoke(options, true); // Look for the openFile method with FileEditorOpenOptions - Method openFileMethod = null; - Class currentClass = editorManager.getClass(); - - while (currentClass != null && openFileMethod == null) { - for (Method method : currentClass.getDeclaredMethods()) { - if ("openFile".equals(method.getName())) { - Class[] paramTypes = method.getParameterTypes(); - if (paramTypes.length == 3 && - VirtualFile.class.isAssignableFrom(paramTypes[0]) && - optionsClass.isAssignableFrom(paramTypes[2])) { - openFileMethod = method; - break; - } - } - } - currentClass = currentClass.getSuperclass(); - } + Method openFileMethod = getMethod(editorManager, optionsClass); if (openFileMethod != null) { openFileMethod.setAccessible(true); openFileMethod.invoke(editorManager, file, null, options); - LOG.debug("Successfully opened file in preview tab: " + file.getName()); } else { LOG.debug("Preview tab method not found, doing nothing for single click"); } @@ -164,6 +155,27 @@ private void openInPreviewTab(Project project, VirtualFile file) { } } + private static @Nullable Method getMethod(FileEditorManager editorManager, Class optionsClass) { + Method openFileMethod = null; + Class currentClass = editorManager.getClass(); + + while (currentClass != null && openFileMethod == null) { + for (Method method : currentClass.getDeclaredMethods()) { + if ("openFile".equals(method.getName())) { + Class[] paramTypes = method.getParameterTypes(); + if (paramTypes.length == 3 && + VirtualFile.class.isAssignableFrom(paramTypes[0]) && + optionsClass.isAssignableFrom(paramTypes[2])) { + openFileMethod = method; + break; + } + } + } + currentClass = currentClass.getSuperclass(); + } + return openFileMethod; + } + /** * Factory method that creates a MySimpleChangesBrowser instance asynchronously. * This properly handles slow operations by performing them in a background thread diff --git a/src/main/java/toolwindow/elements/VcsTree.java b/src/main/java/toolwindow/elements/VcsTree.java index c44f47d..3b51193 100644 --- a/src/main/java/toolwindow/elements/VcsTree.java +++ b/src/main/java/toolwindow/elements/VcsTree.java @@ -172,7 +172,7 @@ private int calculateChangesHashCode(Collection changes) { return 0; } - java.util.List filePaths = changes.stream() + List filePaths = changes.stream() .filter(Objects::nonNull) .map(this::getChangePath) .filter(path -> !path.isEmpty()) @@ -194,6 +194,8 @@ public void update(Collection changes) { } if (shouldSkipUpdate(changes)) { + LOG.debug("VcsTree.update() SKIPPED - changes match last update (size: " + + (changes != null ? changes.size() : "null") + ")"); return; } @@ -213,7 +215,6 @@ public void update(Collection changes) { if (changes == null || changes.isEmpty() || changes instanceof ChangesService.ErrorStateMarker) { JLabel statusLabel = createStatusLabel(changes); SwingUtilities.invokeLater(() -> setComponentIfCurrent(statusLabel, sequenceNumber)); - //currentBrowser = null; return; } @@ -232,20 +233,19 @@ public void update(Collection changes) { // Reuse single browser instance if it exists if (singleBrowser != null) { - LOG.debug("VcsTree: Reusing single browser instance"); - return CompletableFuture.supplyAsync(() -> { - SwingUtilities.invokeLater(() -> { - if (!project.isDisposed()) { - singleBrowser.setChangesToDisplay(changesCopy); - } - }); - return singleBrowser; - }); + return CompletableFuture.completedFuture(singleBrowser) + .thenApply(browser -> { + SwingUtilities.invokeLater(() -> { + if (!project.isDisposed()) { + browser.setChangesToDisplay(changesCopy); + } + }); + return browser; + }); } // Check if browser is already being created if (pendingBrowserCreation != null && !pendingBrowserCreation.isDone()) { - LOG.debug("VcsTree: Waiting for pending browser creation"); // Wait for the pending creation to complete return pendingBrowserCreation.thenApply(browser -> { // Update with new changes @@ -276,11 +276,9 @@ public void update(Collection changes) { .thenAccept(browser -> { SwingUtilities.invokeLater(() -> { if (isCurrentSequence(sequenceNumber) && !project.isDisposed()) { - // Set component if it's different from current - if (currentBrowser != browser) { - setComponent(browser); - currentBrowser = browser; - } + // Always set component to ensure we update the UI with the new browser + setComponent(browser); + currentBrowser = browser; } }); }) @@ -361,7 +359,7 @@ private void setComponent(Component component) { if (positionTracker.isScrollPositionRestored()) { ScrollPosition currentPosition = positionTracker.saveScrollPosition(); - if (currentPosition.isValid) { + if (currentPosition.isValid()) { positionTracker.setSavedScrollPosition(currentTabId, currentPosition); } } @@ -403,21 +401,39 @@ private void setComponent(Component component) { } } - @Override - public void removeNotify() { - super.removeNotify(); - - positionTracker.cleanup(); - + public void cleanup() { + // Cancel any pending updates CompletableFuture current = currentUpdate.get(); if (current != null && !current.isDone()) { current.cancel(true); } + + // Clean up position tracker + positionTracker.cleanup(); + + // Clear all maps lastChangesPerTab.clear(); lastChangesHashCodePerTab.clear(); - // Clear the single browser instance for this VcsTree - singleBrowser = null; + // Clear browser instances (parent will dispose SimpleAsyncChangesBrowser) + if (singleBrowser != null) { + singleBrowser = null; + } + + if (currentBrowser != null) { + currentBrowser = null; + } + pendingBrowserCreation = null; + + // Remove all components to break JNI references + removeAll(); + } + + @Override + public void removeNotify() { + super.removeNotify(); + // DO NOT call cleanup() here - removeNotify() is called when switching tabs + // cleanup() should only be called from ToolWindowView.dispose() when the tab is actually closed } } \ No newline at end of file diff --git a/src/main/java/utils/CustomRollback.java b/src/main/java/utils/CustomRollback.java index dd4d95a..ad2f305 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; @@ -22,9 +23,11 @@ import git4idea.commands.Git; import com.intellij.openapi.progress.Task; import com.intellij.openapi.progress.ProgressIndicator; +import com.intellij.openapi.progress.ProgressManager; 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 +36,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( @@ -190,7 +194,7 @@ public List revertToBeforeRev(@NotNull Project project, @NotNull List allRepos = git4idea.GitUtil.getRepositoryManager(project).getRepositories(); for (Change change : changes) { - com.intellij.openapi.progress.ProgressManager.checkCanceled(); + ProgressManager.checkCanceled(); indicator.checkCanceled(); FilePath afterPath = ChangesUtil.getAfterPath(change); @@ -221,7 +225,7 @@ public List revertToBeforeRev(@NotNull Project project, @NotNull List> entry : rootToChanges.entrySet()) { - com.intellij.openapi.progress.ProgressManager.checkCanceled(); + ProgressManager.checkCanceled(); indicator.checkCanceled(); VirtualFile root = entry.getKey(); @@ -271,6 +275,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; + } } } diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index f469215..d0669c1 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -1,10 +1,31 @@ - Git Scope Git Scope WOELKIT, M.Wållberg auto - 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
  • +
  • Project file colors — Instantly spot changed files with color-coded highlighting
  • +
  • Smart scopes — Use Git Scope with IntelliJ's search, replace, and inspection features
  • +
+

Perfect for:

+
    +
  • Reviewing all changes in a feature branch before creating a pull request
  • +
  • Tracking progress against your target branch (e.g., main, develop, release)
  • +
  • Performing code inspections or refactoring only on files you've changed
  • +
  • Understanding what's different between your work and any historical reference
  • +
+ ]]>
auto com.intellij.modules.platform @@ -48,6 +69,7 @@ + @@ -59,6 +81,20 @@ + + + + + + + + + + + + + +
\ No newline at end of file