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