diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3c7de05..bd607da 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -30,6 +30,9 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@v6 + - name: Run Tests + run: ./gradlew :backend:test + - name: Build Plugin run: ./gradlew buildPlugin diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dbc1c6..d35209e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,23 @@ +## [2026.2.1] + +### Fixes + +- Fixed ["Only Changes Since Common Ancestor" collected every commit in the range instead of the pull-request diff](https://github.com/comod/git-scope-pro/issues/104) + - The scope is now `git diff ...HEAD`: files reverted within the range disappear, and each + file's diff, gutter markers and rollback use the merge base rather than a single intermediate commit. +- Fixed [Files are red even after resolving conflicts](https://github.com/comod/git-scope-pro/issues/78) +- Fixed missing gutter markers over slow or unstable Remote Development connections +- Fixed the scope tab context menu (rename, reset, move left/right) missing in Remote Development, and the tooltip + showing the original name of a renamed tab +- Fixed reordering scope tabs by dragging them, which mixed up the tabs and lost one on the next IDE start +- Fixed the step-between-changes actions jumping erratically: they now follow the order the scope tree shows, skip + deleted, binary and directory entries, and keep the focus in the Git Scope window +- Fixed the scope listing files that Git reports as unchanged, which survived IDE restarts and only cleared once an + unrelated commit happened to force a refresh + - The IDE restores its changelist on startup and afterwards only re-checks files it has marked as changed, so an + entry whose file no longer exists was never looked at again. Git Scope now forces one full rescan once Git is + ready, and ignores entries whose file is gone. + ## [2026.2] Remote Development support, built on a split of the plugin into separate frontend and backend modules. Local IDE diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index 3846f31..7232339 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -31,4 +31,5 @@ dependencies { implementation(project(":shared")) compileOnly("com.google.code.gson:gson:2.14.0") + testImplementation("junit:junit:4.13.2") } diff --git a/backend/src/main/java/implementation/compare/ChangesService.java b/backend/src/main/java/implementation/compare/ChangesService.java index adaebfb..313ad6a 100644 --- a/backend/src/main/java/implementation/compare/ChangesService.java +++ b/backend/src/main/java/implementation/compare/ChangesService.java @@ -15,11 +15,9 @@ import com.intellij.openapi.vcs.changes.ChangesUtil; import com.intellij.openapi.vcs.changes.CurrentContentRevision; import com.intellij.openapi.vfs.VirtualFile; -import git4idea.GitCommit; import git4idea.GitReference; import git4idea.GitRevisionNumber; import git4idea.actions.GitCompareWithRefAction; -import git4idea.history.GitHistoryUtils; import git4idea.repo.GitRepository; import model.TargetBranchMap; import org.jetbrains.annotations.NotNull; @@ -28,6 +26,7 @@ import system.Defs; import utils.PlatformApiReflection; import utils.GitUtil; +import utils.ScopeRefRange; import java.util.*; import java.util.concurrent.ConcurrentHashMap; @@ -88,12 +87,29 @@ private static String getBranchToCompare(TargetBranchMap targetBranchByRepo, Git // Cache for storing changes per repository (stores RepoChangesResult to preserve scope/local separation) private final Map changesCache = new ConcurrentHashMap<>(); + /** + * Collects changes and reports them to {@code callBack}. + * + *

The callback is always invoked exactly once (unless the project is disposed + * first): with the collected result, or with {@code null} when this collection was superseded + * or cancelled by a newer one. Callers chain UI work on the callback, so dropping it — as a + * cancelled {@code Task} used to — silently lost that work (e.g. the file-colors refresh + * after a tab switch). + */ 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; final long gen = collectionGeneration.incrementAndGet(); + // Clear stale results at scheduling time, not inside run(): a task superseded before its + // run() started never reached the clear, so entries cached DURING a git operation + // (e.g. conflict-state changes mid-rebase) survived it and were served to the next + // cache-permitted collection (issue #78). + if (checkFs) { + changesCache.clear(); + } + task = new Task.Backgroundable(currentProject, "Collecting " + Defs.APPLICATION_NAME, true) { private ChangesResult result; @@ -102,8 +118,13 @@ public void collectChangesWithCallback(TargetBranchMap targetBranchByRepo, Consu public void run(@NotNull ProgressIndicator indicator) { currentIndicator.set(indicator); try { - // Early exit if disposing or superseded by a newer collection request + // Early exit if disposing or superseded by a newer collection request. + // Nothing is applied and no callback runs, so this is a silent no-update: worth a + // line when tracking down a scope that stopped refreshing. if (disposing.get() || indicator.isCanceled() || collectionGeneration.get() != gen) { + LOG.debug("Collection " + gen + " abandoned before start (disposing=" + disposing.get() + + ", cancelled=" + indicator.isCanceled() + + ", latestGeneration=" + collectionGeneration.get() + ")"); return; } @@ -113,14 +134,19 @@ public void run(@NotNull ProgressIndicator indicator) { List errorRepos = new ArrayList<>(); Collection repositories = currentGitService.getRepositories(); - - // Clear cache if checkFs is true (force fresh fetch) - if (checkFs) { - changesCache.clear(); + if (repositories.isEmpty()) { + // Happens before the VCS mapping is ready; the scope stays empty until some + // later event triggers another collection. + LOG.debug("Collection " + gen + ": no git repositories registered yet"); } repositories.forEach(repo -> { - if (indicator.isCanceled() || collectionGeneration.get() != gen) return; + if (indicator.isCanceled() || collectionGeneration.get() != gen) { + LOG.debug("Collection " + gen + " interrupted at " + repo.getRoot().getPath() + + " (cancelled=" + indicator.isCanceled() + + ", latestGeneration=" + collectionGeneration.get() + ")"); + return; + } try { String branchToCompare = getBranchToCompare(targetBranchByRepo, repo); @@ -130,8 +156,10 @@ public void run(@NotNull ProgressIndicator indicator) { RepoChangesResult repoResult; if (!checkFs && changesCache.containsKey(cacheKey)) { - // Use cached result (includes merged, scope, and local changes) + // Use cached result (includes merged, scope, and local changes). + // A cache hit means the filesystem was NOT re-read for this repository. repoResult = changesCache.get(cacheKey); + LOG.debug("Collection " + gen + ": cache hit for " + cacheKey); } else { // Fetch fresh changes repoResult = doCollectChanges(currentProject, repo, branchToCompare); @@ -186,8 +214,15 @@ public void run(@NotNull ProgressIndicator indicator) { // Return ERROR_STATE only if ALL repositories failed (e.g. commit hash not found in any repo). // Individual repo failures are expected in multi-repo setups where a commit exists in only one repo. if (!errorRepos.isEmpty() && errorRepos.size() == repositories.size()) { + LOG.debug("Collection " + gen + ": all " + errorRepos.size() + + " repositories failed -> ERROR_STATE"); result = new ChangesResult(ERROR_STATE, new ArrayList<>(), new ArrayList<>()); } else { + if (LOG.isDebugEnabled()) { + LOG.debug("Collection " + gen + " finished: merged=" + _changes.size() + + ", scope=" + _scopeChanges.size() + ", local=" + _localChanges.size() + + ", failedRepos=" + errorRepos.size()); + } result = new ChangesResult(_changes, _scopeChanges, _localChanges); } } finally { @@ -200,14 +235,30 @@ public void onSuccess() { // 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 && this.result != null) { - callBack.accept(this.result); + if (currentProject.isDisposed() || callBack == null) return; + if (this.result == null) { + // The run() above returned early (superseded); a newer collection owns the + // model. Complete the callback with null so chained work still runs. + LOG.debug("Collection " + gen + " superseded, completing callback without data"); } + callBack.accept(this.result); + }, ModalityState.defaultModalityState(), __ -> disposing.get()); + } + + @Override + public void onCancel() { + // Queueing a newer collection cancels this one's indicator; the platform then calls + // onCancel instead of onSuccess. The callback chain must still complete. + ApplicationManager.getApplication().invokeLater(() -> { + if (currentProject.isDisposed() || callBack == null) return; + LOG.debug("Collection " + gen + " cancelled, completing callback without data"); + callBack.accept(null); }, ModalityState.defaultModalityState(), __ -> disposing.get()); } @Override public void onThrowable(@NotNull Throwable error) { + LOG.warn("Change collection " + gen + " failed, scope shows an error state", error); ApplicationManager.getApplication().invokeLater(() -> { if (!currentProject.isDisposed() && callBack != null) { callBack.accept(new ChangesResult(ERROR_STATE, new ArrayList<>(), new ArrayList<>())); @@ -219,7 +270,19 @@ public void onThrowable(@NotNull Throwable error) { if (prev != null) { prev.cancel(); } - task.queue(); + + // Collect against a settled changelist. ChangeListManager restores the list persisted in + // workspace.xml when the project opens and refreshes it only afterwards, so reading + // getAllChanges() straight away can hand back entries for files git considers clean — or + // that no longer exist at that path at all. Capture the task locally: a newer collection + // reassigns the field before this callback runs. + final Task.Backgroundable queuedTask = task; + ChangeListManager.getInstance(currentProject).invokeAfterUpdate(false, () -> { + if (disposing.get() || currentProject.isDisposed()) { + return; + } + queuedTask.queue(); + }); } @Override @@ -260,7 +323,13 @@ private Collection filterLocalChanges(Collection localChanges, S FilePath changePath = ChangesUtil.getFilePath(change); String changePathStr = changePath.getPath(); - if (!showDeletedFiles && change.getType() == Change.Type.DELETED) { + if (change.getType() == Change.Type.DELETED) { + if (!showDeletedFiles) { + continue; + } + } else if (!isPresentOnDisk(changePath)) { + // Stale changelist entry: nothing lives at this path any more, so rendering it would + // put a dead node in the tree. DELETED is exempt because absence is what it reports. continue; } @@ -290,17 +359,15 @@ private Collection filterLocalChanges(Collection localChanges, S return filtered; } - @NotNull - public Collection getChangesByHistory(Project project, GitRepository repo, String branchToCompare) throws VcsException { - List commits = GitHistoryUtils.history(project, repo.getRoot(), branchToCompare); - Map changeMap = new HashMap<>(); - for (GitCommit commit : commits) { - for (Change change : PlatformApiReflection.getCommitChanges(commit)) { - FilePath path = ChangesUtil.getFilePath(change); - changeMap.put(path, change); - } - } - return new ArrayList<>(changeMap.values()); + /** + * Reports whether a change still has a live file behind it. + * + *

A {@link VirtualFile} can outlive the file it points at when the deletion happened outside + * the IDE, so validity is checked alongside presence. + */ + private static boolean isPresentOnDisk(FilePath path) { + VirtualFile virtualFile = path.getVirtualFile(); + return virtualFile != null && virtualFile.isValid(); } /** @@ -334,31 +401,34 @@ public RepoChangesResult doCollectChanges(Project project, GitRepository repo, S try { // Local Changes ChangeListManager changeListManager = ChangeListManager.getInstance(project); - Collection localChanges = changeListManager.getAllChanges(); + Collection localChanges = new ArrayList<>(changeListManager.getAllChanges()); String repoPath = repo.getRoot().getPath(); - // Filter local changes for this repository - repoLocalChanges = filterLocalChanges(localChanges, repoPath, null); - - // Add unversioned (untracked) files if the setting is enabled + // Add unversioned (untracked) files if the setting is enabled. They join the changelist + // entries *before* filtering so they get the same repository and staleness checks — + // appending them afterwards let untracked paths bypass both. if (GitScopeSettings.getInstance().isShowUntrackedFiles()) { for (FilePath unversionedPath : changeListManager.getUnversionedFilesPaths()) { - String filePathStr = unversionedPath.getPath(); - if (filePathStr.startsWith(repoPath)) { - Change untrackedChange = new Change(null, new CurrentContentRevision(unversionedPath), FileStatus.UNKNOWN); - repoLocalChanges.add(untrackedChange); - } + localChanges.add(new Change(null, new CurrentContentRevision(unversionedPath), FileStatus.UNKNOWN)); } } + // Filter local changes for this repository + repoLocalChanges = filterLocalChanges(localChanges, repoPath, null); + // Special handling for HEAD - return local changes only, no scope changes if (scopeRef.equals(GitService.BRANCH_HEAD)) { return new RepoChangesResult(new ArrayList<>(repoLocalChanges), new ArrayList<>(), repoLocalChanges); } // Diff Changes - these are the pure scope changes - if (scopeRef.contains("..")) { - scopeChanges = getChangesByHistory(project, repo, scopeRef); + GitRevisionNumber revisionNumber; + if (ScopeRefRange.isRange(scopeRef)) { + // A range scope ("main..HEAD") asks for everything on HEAD since it diverged from the + // selected ref, so the base is their merge base. An unsupported range yields no ref + // and falls through to ERROR_STATE rather than being misread as a different diff. + String selectedRef = ScopeRefRange.selectedRef(scopeRef); + revisionNumber = selectedRef == null ? null : GitUtil.resolveMergeBase(repo, selectedRef); } else { GitReference gitReference; @@ -369,7 +439,6 @@ public RepoChangesResult doCollectChanges(Project project, GitRepository repo, S gitReference = PlatformApiReflection.findTagByName(repo, scopeRef); } - GitRevisionNumber revisionNumber; if (gitReference == null) { // Finally resort to try a generic reference (HEAD~2, , ...) revisionNumber = GitUtil.resolveGitReference(repo, scopeRef); @@ -377,16 +446,19 @@ public RepoChangesResult doCollectChanges(Project project, GitRepository repo, S else { revisionNumber = new GitRevisionNumber(gitReference.getFullName()); } + } - if (revisionNumber != null) { - // We have a valid GitReference - scopeChanges = GitUtil.getDiffChanges(repo, file, revisionNumber); - LOG.debug("ChangesService - Repository: " + repoPath + ", Scope: " + scopeRef + ", scopeChanges count: " + scopeChanges.size()); - } - else { - // We do not have a valid GitReference => return ERROR_STATE - return new RepoChangesResult(ERROR_STATE, new ArrayList<>(), new ArrayList<>()); - } + if (revisionNumber != null) { + // Diff a single base tree against HEAD. Range scopes pass their merge base, which makes + // the result the net pull-request diff instead of a union of every commit's changes. + scopeChanges = GitUtil.getDiffChanges(repo, file, revisionNumber); + LOG.debug("ChangesService - Repository: " + repoPath + ", Scope: " + scopeRef + ", base: " + revisionNumber.asString() + ", scopeChanges count: " + scopeChanges.size()); + } + else { + // We do not have a valid GitReference => return ERROR_STATE + LOG.debug("ChangesService - Repository: " + repoPath + ", Scope: " + scopeRef + + " could not be resolved to a revision -> ERROR_STATE"); + return new RepoChangesResult(ERROR_STATE, new ArrayList<>(), new ArrayList<>()); } // Log what we collected diff --git a/backend/src/main/java/implementation/lineStatusTracker/MyLineStatusTrackerImpl.java b/backend/src/main/java/implementation/lineStatusTracker/MyLineStatusTrackerImpl.java index b8a540e..64c85df 100644 --- a/backend/src/main/java/implementation/lineStatusTracker/MyLineStatusTrackerImpl.java +++ b/backend/src/main/java/implementation/lineStatusTracker/MyLineStatusTrackerImpl.java @@ -89,13 +89,21 @@ public void fileClosed(@NotNull FileEditorManager source, @NotNull VirtualFile f * Computes ranges on background threads and publishes results to GutterDataService. */ public void update(Map scopeChangesMap, Map localChangesMap) { - if (scopeChangesMap == null || disposing.get()) return; + if (scopeChangesMap == null || disposing.get()) { + LOG.debug("Gutter update skipped (scopeChangesMap=" + (scopeChangesMap == null ? "null" : "present") + + ", disposing=" + disposing.get() + ")"); + return; + } final DisposalToken token = this.disposalToken; final long gen = updateGeneration.incrementAndGet(); updateExecutor.execute(() -> { - if (token.disposed || updateGeneration.get() != gen) return; + if (token.disposed || updateGeneration.get() != gen) { + LOG.debug("Gutter update " + gen + " superseded before start (latest=" + + updateGeneration.get() + ")"); + return; + } Editor[] editors = EditorFactory.getInstance().getAllEditors(); @@ -111,7 +119,13 @@ public void update(Map scopeChangesMap, Map loca } } - if (editorsToUpdate.isEmpty()) return; + if (editorsToUpdate.isEmpty()) { + // No open editor matches the scope, so nothing is published and the gutter keeps + // whatever it last showed. + LOG.debug("Gutter update " + gen + ": none of the " + editors.length + + " open editor(s) are in scope (" + scopeChangesMap.size() + " changed file(s))"); + return; + } Map updates = new ConcurrentHashMap<>(); CountDownLatch latch = new CountDownLatch(editorsToUpdate.size()); @@ -184,15 +198,17 @@ private UpdateInfo prepareUpdateForEditor(Editor editor, Map sco String currentContent; if (changeForFile != null && changeForFile.getBeforeRevision() != null) { - LOG.debug("MyLineStatusTrackerImpl - File: " + filePath + ", beforeRevision: " + - (changeForFile.getBeforeRevision() != null ? changeForFile.getBeforeRevision().getRevisionNumber() : "null") + - ", afterRevision: " + - (changeForFile.getAfterRevision() != null ? changeForFile.getAfterRevision().getRevisionNumber() : "null")); + if (LOG.isDebugEnabled()) { + LOG.debug("MyLineStatusTrackerImpl - File: " + filePath + ", beforeRevision: " + + changeForFile.getBeforeRevision().getRevisionNumber() + + ", afterRevision: " + + (changeForFile.getAfterRevision() != null ? changeForFile.getAfterRevision().getRevisionNumber() : "null")); + } try { baseContent = changeForFile.getBeforeRevision().getContent(); } catch (VcsException e) { - LOG.warn("Error getting content for revision: " + filePath, e); + LOG.debug("Error getting content for revision: " + filePath, e); baseContent = null; } @@ -213,10 +229,13 @@ private UpdateInfo prepareUpdateForEditor(Editor editor, Map sco String normalizedBase = StringUtil.convertLineSeparators(baseContent); String normalizedCurrent = StringUtil.convertLineSeparators(currentContent); - LOG.debug("MyLineStatusTrackerImpl - File: " + filePath + - ", normalizedBase lines: " + normalizedBase.split("\n").length + - ", normalizedCurrent lines: " + normalizedCurrent.split("\n").length + - ", hasLocalChanges: " + hasLocalChanges); + if (LOG.isDebugEnabled()) { + // split() on whole file contents: only pay for it when the log is actually on + LOG.debug("MyLineStatusTrackerImpl - File: " + filePath + + ", normalizedBase lines: " + normalizedBase.split("\n").length + + ", normalizedCurrent lines: " + normalizedCurrent.split("\n").length + + ", hasLocalChanges: " + hasLocalChanges); + } String headContent = null; if (hasLocalChanges) { @@ -228,7 +247,7 @@ private UpdateInfo prepareUpdateForEditor(Editor editor, Map sco headContent = StringUtil.convertLineSeparators(headContent); } } catch (VcsException e) { - LOG.warn("MyLineStatusTrackerImpl - Error caching HEAD content: " + e.getMessage()); + LOG.debug("MyLineStatusTrackerImpl - Error caching HEAD content: " + e.getMessage()); } } } @@ -248,13 +267,15 @@ private UpdateInfo prepareUpdateForEditor(Editor editor, Map sco ranges = RangesBuilder.INSTANCE.createRanges(normalizedCurrent, normalizedBase); } - LOG.debug("MyLineStatusTrackerImpl - File: " + filePath + ", final ranges: " + ranges.size()); - for (Range range : ranges) { - LOG.debug("MyLineStatusTrackerImpl - Range: line1=" + range.getLine1() + ", line2=" + range.getLine2() + - ", vcsLine1=" + range.getVcsLine1() + ", vcsLine2=" + range.getVcsLine2() + ", type=" + range.getType()); + if (LOG.isDebugEnabled()) { + LOG.debug("MyLineStatusTrackerImpl - File: " + filePath + ", final ranges: " + ranges.size()); + for (Range range : ranges) { + LOG.debug("MyLineStatusTrackerImpl - Range: line1=" + range.getLine1() + ", line2=" + range.getLine2() + + ", vcsLine1=" + range.getVcsLine1() + ", vcsLine2=" + range.getVcsLine2() + ", type=" + range.getType()); + } } } catch (Exception e) { - LOG.error("Error precomputing ranges for: " + filePath, e); + LOG.warn("Error precomputing ranges for: " + filePath, e); ranges = Collections.emptyList(); } diff --git a/backend/src/main/java/listener/MyBulkFileListener.java b/backend/src/main/java/listener/MyBulkFileListener.java index a6c5df7..46c2b74 100644 --- a/backend/src/main/java/listener/MyBulkFileListener.java +++ b/backend/src/main/java/listener/MyBulkFileListener.java @@ -1,16 +1,26 @@ package listener; +import com.intellij.openapi.diagnostic.Logger; import com.intellij.openapi.project.Project; import com.intellij.openapi.project.ProjectManager; import com.intellij.openapi.vfs.newvfs.BulkFileListener; import com.intellij.openapi.vfs.newvfs.events.VFileEvent; +import git4idea.repo.GitRepository; import org.jetbrains.annotations.NotNull; import service.ViewService; +import system.Defs; import java.util.List; +/** + * Entry point of the refresh pipeline for filesystem changes. When the scope appears "stuck" this + * is the first thing to check: if no line is logged here the event never reached the plugin, and + * everything downstream is irrelevant. + */ public class MyBulkFileListener implements BulkFileListener { + private static final Logger LOG = Defs.getLogger(MyBulkFileListener.class); + @Override public void after(@NotNull List events) { if (events.isEmpty()) return; @@ -19,11 +29,47 @@ public void after(@NotNull List events) { for (Project project : openProjects) { if (project.isDisposed()) continue; + // Only refresh projects that one of the changed files actually belongs to. Without + // this, every VFS batch anywhere (build outputs, unrelated projects, IDE internals) + // re-collected every open project, and the resulting collection churn repeatedly + // cancelled in-flight collections during busy phases like a rebase. + if (!touchesRepository(project, events)) { + continue; + } + ViewService viewService = project.getService(ViewService.class); - if (viewService != null) { - // TODO: collectChanges: bulk file event (disabled) - viewService.collectChanges(true); + if (viewService == null) { + LOG.debug("VFS batch of " + events.size() + " event(s): no ViewService for project " + + project.getName() + ", nothing refreshed"); + continue; + } + if (LOG.isDebugEnabled()) { + LOG.debug("VFS batch of " + events.size() + " event(s) -> collectChanges for project " + + project.getName() + ", first=" + events.get(0).getPath()); + } + // TODO: collectChanges: bulk file event (disabled) + viewService.collectChanges(true); + } + } + + /** + * Whether any event path lies under one of the project's git repository roots. Repositories + * not being registered yet also means there is nothing to collect against — the VCS mapping + * listener triggers the initial collection when they arrive. + */ + private static boolean touchesRepository(@NotNull Project project, @NotNull List events) { + List repositories = git4idea.GitUtil.getRepositoryManager(project).getRepositories(); + if (repositories.isEmpty()) return false; + + for (VFileEvent event : events) { + String path = event.getPath(); + for (GitRepository repository : repositories) { + String root = repository.getRoot().getPath(); + if (path.startsWith(root) && (path.length() == root.length() || path.charAt(root.length()) == '/')) { + return true; + } } } + return false; } -} \ No newline at end of file +} diff --git a/backend/src/main/java/listener/MyDynamicPluginListener.java b/backend/src/main/java/listener/MyDynamicPluginListener.java index 43688fe..57f5a78 100644 --- a/backend/src/main/java/listener/MyDynamicPluginListener.java +++ b/backend/src/main/java/listener/MyDynamicPluginListener.java @@ -44,7 +44,7 @@ public void beforePluginUnload(@NotNull IdeaPluginDescriptor pluginDescriptor, b changesService.clearCache(); } } catch (Exception e) { - LOG.error("Error preparing project for plugin unload: " + project.getName(), e); + LOG.warn("Error preparing project for plugin unload: " + project.getName(), e); } } } diff --git a/backend/src/main/java/listener/MyFileEditorManagerListener.java b/backend/src/main/java/listener/MyFileEditorManagerListener.java index ecf74c2..84d55af 100644 --- a/backend/src/main/java/listener/MyFileEditorManagerListener.java +++ b/backend/src/main/java/listener/MyFileEditorManagerListener.java @@ -17,8 +17,11 @@ public MyFileEditorManagerListener(Project project) { @Override public void fileOpened(@NotNull FileEditorManager source, @NotNull VirtualFile vFile) { - // TODO: collectChanges: File Opened event - viewService.incrementUpdate(); + // Opening a file changes no scope input, so this deliberately does NOT bump the apply + // generation: doing so discarded the result of any in-flight fresh collection (e.g. one + // triggered by a just-finished rebase) and replaced it with this cache-served one -- + // the scope then showed pre-operation state, stuck until the next tab switch (issue #78). + // This call only warms the model when nothing has been collected yet. viewService.collectChanges(false); } } diff --git a/backend/src/main/java/listener/MyTabContentListener.java b/backend/src/main/java/listener/MyTabContentListener.java index 0424382..7fbfb41 100644 --- a/backend/src/main/java/listener/MyTabContentListener.java +++ b/backend/src/main/java/listener/MyTabContentListener.java @@ -2,6 +2,7 @@ import com.intellij.openapi.project.Project; import com.intellij.openapi.util.NlsContexts; +import com.intellij.ui.content.Content; import com.intellij.ui.content.ContentManagerEvent; import com.intellij.ui.content.ContentManagerListener; import org.jetbrains.annotations.NotNull; @@ -11,7 +12,10 @@ import toolwindow.elements.VcsTree; import javax.swing.*; +import java.util.Collections; import java.util.Objects; +import java.util.Set; +import java.util.WeakHashMap; import static service.ViewService.PLUS_TAB_LABEL; @@ -41,7 +45,28 @@ private ToolWindowServiceInterface getToolWindowService() { return toolWindowService; } + /** + * Contents that were removed while the plugin was not reordering them. A drag re-adds the very + * same Content object, so seeing one come back identifies a move; a genuinely closed Content is + * never added again, and the weak set lets it be collected without bookkeeping. + */ + private final Set removedContents = + Collections.newSetFromMap(new WeakHashMap<>()); + public void contentAdded(@NotNull ContentManagerEvent event) { + // Deliberately not time-based: the platform can complete a drag several EDT ticks after the + // removal, so anything that checks "is it back yet?" on a timer misses the slow cases and + // leaves the tab's model deleted. + if (!removedContents.remove(event.getContent())) { + return; + } + + ViewService viewService = getViewService(); + if (viewService == null || viewService.isDisposed()) return; + + LOG.debug("Tab '" + event.getContent().getTabName() + "' came back after removal - " + + "it was dragged, not closed; resyncing tab order"); + viewService.onTabsDragged(); } public void selectionChanged(@NotNull ContentManagerEvent event) { @@ -73,7 +98,7 @@ public void selectionChanged(@NotNull ContentManagerEvent event) { vcsTree.onTabSwitched(); } } catch (Exception e) { - LOG.error("MyTabContentListener: Error notifying VcsTree about tab switch: " + e.getMessage()); + LOG.warn("MyTabContentListener: Error notifying VcsTree about tab switch: " + e.getMessage()); } }); } @@ -87,8 +112,20 @@ public void contentRemoved(@NotNull ContentManagerEvent event) { // Don't remove the model if we're just reordering tabs ViewService viewService = getViewService(); - if (viewService != null && !viewService.isProcessingTabReorder()) { - viewService.removeTab(event.getIndex()); + if (viewService == null || viewService.isProcessingTabReorder()) { + return; } + + // Treat it as a close, exactly as before: this path also runs when tabs are torn down in + // bulk (project close, tool window re-init), and its index check is what keeps those from + // wiping the saved collection. + viewService.removeTab(event.getIndex()); + + // A tab dragged within the header is re-added at its new index, which arrives here as a + // removal indistinguishable from a close, and the platform has no "content moved" event. + // Remember the content: if it is added back, contentAdded recognises the move and rebuilds + // the collection from the tab order, which restores the model removed just above -- it is + // still reachable through the content, so nothing is lost. + removedContents.add(event.getContent()); } } \ No newline at end of file diff --git a/backend/src/main/java/listener/MyTreeSelectionListener.java b/backend/src/main/java/listener/MyTreeSelectionListener.java index b83fc63..50d316e 100644 --- a/backend/src/main/java/listener/MyTreeSelectionListener.java +++ b/backend/src/main/java/listener/MyTreeSelectionListener.java @@ -11,6 +11,7 @@ import state.State; import toolwindow.elements.BranchTreeEntry; import service.ViewService; +import utils.ScopeRefRange; public class MyTreeSelectionListener implements TreeSelectionListener { private final Tree tree; @@ -32,10 +33,10 @@ public void valueChanged(TreeSelectionEvent treeSelectionEvent) { Object object = node.getUserObject(); if (object instanceof BranchTreeEntry favLabel) { String branchName = favLabel.getName(); - if (this.state.getTwoDotsCheckbox()) { - String twoDots = ".."; - String head = "HEAD"; - branchName = branchName + twoDots + head; + // Skip refs that already carry a range, otherwise a manually entered "a..b" would + // become the unresolvable "a..b...HEAD". + if (this.state.getTwoDotsCheckbox() && !ScopeRefRange.isRange(branchName)) { + branchName = branchName + "..." + GitService.BRANCH_HEAD; } // Check if this is HEAD - if so, close current tab and switch to HEAD tab diff --git a/backend/src/main/java/model/MyModel.java b/backend/src/main/java/model/MyModel.java index 8e8a709..b2617bc 100644 --- a/backend/src/main/java/model/MyModel.java +++ b/backend/src/main/java/model/MyModel.java @@ -5,6 +5,7 @@ import git4idea.repo.GitRepository; import org.jetbrains.annotations.Nullable; import service.GitService; +import utils.ScopeRefRange; import java.util.Collection; import java.util.HashMap; @@ -79,15 +80,13 @@ public String getDisplayName() { } /** - * Returns the scope reference similar to getName(), but strips the optional "..HEAD" suffix if present. - * Example: "feature/foo..HEAD" -> "feature/foo" + * Returns the scope reference similar to getName(), but strips the optional range suffix if present. + * Example: "feature/foo...HEAD" -> "feature/foo" */ @Nullable public String getScopeRef() { String name = getName(); - if (name == null) return null; - String suffix = ".." + GitService.BRANCH_HEAD; - return name.endsWith(suffix) ? name.substring(0, name.length() - suffix.length()) : name; + return name == null ? null : ScopeRefRange.stripRange(name); } // Getter and setter for custom tab name diff --git a/backend/src/main/java/rpc/BackendGutterRpcImpl.kt b/backend/src/main/java/rpc/BackendGutterRpcImpl.kt index 381a2e1..8d9ca53 100644 --- a/backend/src/main/java/rpc/BackendGutterRpcImpl.kt +++ b/backend/src/main/java/rpc/BackendGutterRpcImpl.kt @@ -5,53 +5,134 @@ import com.intellij.platform.project.ProjectId import com.intellij.platform.project.findProjectOrNull import com.intellij.platform.rpc.backend.RemoteApiProvider import fleet.rpc.remoteApiDescriptor -import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flow import service.GutterDataService import settings.GitScopeSettings class BackendGutterRpcImpl : GutterRpcApi { + + /** + * Streams gutter updates with conflation by file. + * + *

The RPC transport applies credit-based backpressure: a slow frontend or network stops the + * stream from being drained. Listener callbacks therefore never push payloads into the stream — + * they only mark a file dirty and wake the emit loop, which reads the latest snapshot + * from [GutterDataService] at send time and emits with a suspending call. Under backpressure, + * intermediate states of a file collapse into the newest one instead of being dropped (the + * previous implementation lost events irrecoverably via {@code trySend} once its 64-element + * buffer filled), memory is bounded at one pending entry per file, and the final state always + * arrives. + * + *

The initial replay goes through the same dirty set, so a reconnecting consumer (the + * frontend re-subscribes via {@code durable} after every connection loss) never queues more + * than one snapshot per file no matter how often the link drops. + */ override suspend fun getGutterUpdates(projectId: ProjectId): Flow { - val project = projectId.findProjectOrNull() ?: return kotlinx.coroutines.flow.emptyFlow() + val project = projectId.findProjectOrNull() ?: return emptyFlow() val gds = project.service() - return callbackFlow { - // Register listener FIRST to avoid missing events during replay - val listener = object : GutterDataService.Listener { - override fun onDataUpdated(filePath: String, data: GutterDataService.GutterFileData) { - trySend(GutterUpdateEvent.DataUpdated(data.toDto(filePath, gds.scopeDisplayName))) - } + return flow { + // Contents last sent to THIS subscriber, per file. Contents dominate the message size, + // so they are resent only when they actually changed; a new subscription starts empty + // and therefore always sends contents on first contact with a file. + val lastSentContents = HashMap>() - override fun onDataCleared(filePath: String) { - trySend(GutterUpdateEvent.DataCleared(filePath)) - } + // Conflated: always accepts, coalesces repeated wake-ups; listeners never block or fail. + val signal = Channel(Channel.CONFLATED) + val lock = Any() + var allCleared = false + val dirty = LinkedHashSet() - override fun onAllCleared() { - trySend(GutterUpdateEvent.AllCleared) + fun markDirty(filePath: String?) { + synchronized(lock) { + if (filePath == null) { + allCleared = true + // Dirt older than the clear is obsolete; later updates re-add themselves. + dirty.clear() + } else { + dirty.add(filePath) + } } + signal.trySend(Unit) } - gds.addListener(listener) - // Then replay all current data (duplicates are harmless — frontend overwrites) - for (entry in gds.getAllData().entries) { - send(GutterUpdateEvent.DataUpdated(entry.value.toDto(entry.key, gds.scopeDisplayName))) + val listener = object : GutterDataService.Listener { + override fun onDataUpdated(filePath: String, data: GutterDataService.GutterFileData) = + markDirty(filePath) + + override fun onDataCleared(filePath: String) = markDirty(filePath) + + override fun onAllCleared() = markDirty(null) } + // Register the listener before the replay so no update between replay and registration + // is missed; a double-send of the same file is harmless (the frontend overwrites). + gds.addListener(listener) + try { + for (path in gds.getAllData().keys) { + markDirty(path) + } - awaitClose { gds.removeListener(listener) } + while (true) { + signal.receive() + while (true) { + val clearAll: Boolean + val paths: List + synchronized(lock) { + clearAll = allCleared + allCleared = false + paths = dirty.toList() + dirty.clear() + } + if (!clearAll && paths.isEmpty()) break + + if (clearAll) { + // The frontend drops its cache on AllCleared, so nothing previously + // sent may be referred to afterwards. + lastSentContents.clear() + emit(GutterUpdateEvent.AllCleared) + } + for (path in paths) { + // Read the freshest snapshot at send time, not at event time. A file + // cleared while queued yields null and becomes a DataCleared. + val data = gds.getData(path) + if (data == null) { + lastSentContents.remove(path) + emit(GutterUpdateEvent.DataCleared(path)) + } else { + val contents = data.baseContent to data.headContent + val includeContents = lastSentContents[path] != contents + if (includeContents) { + lastSentContents[path] = contents + } + emit(GutterUpdateEvent.DataUpdated( + data.toDto(path, gds.scopeDisplayName, includeContents))) + } + } + } + } + } finally { + gds.removeListener(listener) + } } } - private fun GutterDataService.GutterFileData.toDto(filePath: String, scopeDisplayName: String) = - GutterFileDataDto( - filePath = filePath, - ranges = ranges.map { GutterRangeDto(it.line1, it.line2, it.vcsLine1, it.vcsLine2) }, - baseContent = baseContent, - headContent = headContent, - scopeRanges = scopeRanges?.map { GutterRangeDto(it.line1, it.line2, it.vcsLine1, it.vcsLine2) }, - scopeDisplayName = scopeDisplayName, - separateGutterRendering = GitScopeSettings.getInstance().isSeparateGutterRendering - ) + private fun GutterDataService.GutterFileData.toDto( + filePath: String, + scopeDisplayName: String, + includeContents: Boolean, + ) = GutterFileDataDto( + filePath = filePath, + ranges = ranges.map { GutterRangeDto(it.line1, it.line2, it.vcsLine1, it.vcsLine2) }, + baseContent = if (includeContents) baseContent else null, + headContent = if (includeContents) headContent else null, + scopeRanges = scopeRanges?.map { GutterRangeDto(it.line1, it.line2, it.vcsLine1, it.vcsLine2) }, + scopeDisplayName = scopeDisplayName, + separateGutterRendering = GitScopeSettings.getInstance().isSeparateGutterRendering, + contentsIncluded = includeContents + ) } class BackendGutterRpcProvider : RemoteApiProvider { diff --git a/backend/src/main/java/rpc/BackendUtilRpcImpl.kt b/backend/src/main/java/rpc/BackendUtilRpcImpl.kt index 3fdc170..c53a86d 100644 --- a/backend/src/main/java/rpc/BackendUtilRpcImpl.kt +++ b/backend/src/main/java/rpc/BackendUtilRpcImpl.kt @@ -34,6 +34,29 @@ class BackendUtilRpcImpl : UtilRpcApi { val project = projectId.findProjectOrNull() ?: return project.service().showDiff(currentFilePath) } + + override suspend fun renameTab(projectId: ProjectId, tabIndex: Int, newName: String) { + val project = projectId.findProjectOrNull() ?: return + project.service().renameTab(tabIndex, newName) + } + + override suspend fun resetTabName(projectId: ProjectId, tabIndex: Int) { + val project = projectId.findProjectOrNull() ?: return + project.service().resetTabName(tabIndex) + } + + override suspend fun moveTab(projectId: ProjectId, tabIndex: Int, direction: TabMoveDirection) { + val project = projectId.findProjectOrNull() ?: return + project.service().moveTab(tabIndex, direction) + } + + override suspend fun getRenamedTabs(projectId: ProjectId): Flow> { + val project = projectId.findProjectOrNull() ?: return emptyFlow() + // Publish the current state before the subscriber starts collecting, so a frontend that + // connects after the tabs were restored still gets the truth rather than the initial empty. + project.service().publishRenamedTabs() + return project.service().renamedTabs + } } class BackendUtilRpcProvider : RemoteApiProvider { diff --git a/backend/src/main/java/rpc/UtilCommandService.kt b/backend/src/main/java/rpc/UtilCommandService.kt index 45faa74..c98af9c 100644 --- a/backend/src/main/java/rpc/UtilCommandService.kt +++ b/backend/src/main/java/rpc/UtilCommandService.kt @@ -2,7 +2,9 @@ package rpc import com.intellij.openapi.components.Service import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import java.util.concurrent.atomic.AtomicReference @Service(Service.Level.PROJECT) @@ -17,6 +19,18 @@ class UtilCommandService { */ private val previewTabEnabled = AtomicReference(null) + /** + * Renamed tabs: tab index -> the branch-based name it would revert to. A StateFlow rather than a + * command, so the frontend can read the current value at any time — including right after it + * subscribes — and so republishing an unchanged map costs subscribers nothing. + */ + private val _renamedTabs = MutableStateFlow>(emptyMap()) + val renamedTabs = _renamedTabs.asStateFlow() + + fun setRenamedTabs(tabs: Map) { + _renamedTabs.value = tabs + } + fun selectInProject(filePath: String) { _commands.tryEmit(UtilCommand.SelectInProject(filePath)) } diff --git a/backend/src/main/java/service/ChangeNavigationService.java b/backend/src/main/java/service/ChangeNavigationService.java index d067cc9..e0dd866 100644 --- a/backend/src/main/java/service/ChangeNavigationService.java +++ b/backend/src/main/java/service/ChangeNavigationService.java @@ -19,6 +19,7 @@ import rpc.ChangeNavDirection; import system.Defs; import utils.FileOpener; +import utils.FileTreeOrder; import java.util.ArrayList; import java.util.Collections; @@ -81,13 +82,15 @@ public void navigate(@Nullable String currentFilePath, int caretLine, @NotNull C return; } - // Ordered, stable list of changed files: the union of scope changes and local (working-tree - // vs HEAD) changes, so navigation visits every file that shows a gutter marker — both the + // Ordered list of changed files: the union of scope changes and local (working-tree vs + // HEAD) changes, so navigation visits every file that shows a gutter marker — both the // scope markers we paint and the local markers the IDE paints. - java.util.TreeSet fileSet = new java.util.TreeSet<>(); - fileSet.addAll(scopeChanges.keySet()); - fileSet.addAll(localChanges.keySet()); - List files = new ArrayList<>(fileSet); + List files = orderedFiles(scopeChanges, localChanges); + if (files.isEmpty()) { + // Everything in the scope is deleted, a directory, or otherwise not openable. + LOG.debug("ChangeNavigation: no navigable files in scope"); + return; + } // Fall back to our last navigated position when the frontend has no focused editor // (or the focused editor isn't one of the changed files). This keeps sequential @@ -270,17 +273,38 @@ private void open(String path, int line) { LOG.debug("ChangeNavigation: could not resolve file " + path); return; } + if (LOG.isDebugEnabled()) { + LOG.debug("ChangeNavigation: opening " + displayPath(path) + + " [" + file.getFileType().getName() + "] at line " + (line + 1)); + } lastNavigatedFile = path; lastNavigatedLine = line; - // Request focus so the opened editor becomes the focus owner and the frontend reports the - // correct caret position on the next navigation keystroke. - FileOpener.openAndGoToLine(project, file, line, true); - highlightInToolWindow(file); + + // Stepping through changes from the tool window should leave the focus there. Asking for the + // opened editor to be focused takes it away, and on Linux the Project View following the + // file ("Autoscroll from Source") can then keep it, so the next keystroke no longer reaches + // the tool window at all. Focusing the editor is only what we want when navigation was + // triggered from the editor in the first place. + // + // Dropping the focus request costs nothing: the frontend then reports no focused changed + // file, and navigate() continues from lastNavigatedFile/lastNavigatedLine instead. + ToolWindowServiceInterface toolWindowService = project.getService(ToolWindowServiceInterface.class); + boolean startedInToolWindow = toolWindowService != null && toolWindowService.isFocused(); + + FileOpener.openAndGoToLine(project, file, line, !startedInToolWindow); + highlightInToolWindow(toolWindowService, file); + if (startedInToolWindow) { + toolWindowService.restoreFocus(); + } } /** Selects/highlights the file's change in the Git Scope tool window tree, so the tree stays in sync. */ private void highlightInToolWindow(@NotNull VirtualFile file) { - ToolWindowServiceInterface toolWindowService = project.getService(ToolWindowServiceInterface.class); + highlightInToolWindow(project.getService(ToolWindowServiceInterface.class), file); + } + + private void highlightInToolWindow(@Nullable ToolWindowServiceInterface toolWindowService, + @NotNull VirtualFile file) { if (toolWindowService == null) return; ApplicationManager.getApplication().invokeLater(() -> { if (project.isDisposed()) return; @@ -341,7 +365,7 @@ private List computeRanges(String path, @Nullable Change change, String n try { baseContent = change.getBeforeRevision().getContent(); } catch (VcsException e) { - LOG.warn("ChangeNavigation: error getting base content for " + path, e); + LOG.debug("ChangeNavigation: error getting base content for " + path, e); return Collections.emptyList(); } if (baseContent == null) return Collections.emptyList(); @@ -350,11 +374,99 @@ private List computeRanges(String path, @Nullable Change change, String n try { return RangesBuilder.INSTANCE.createRanges(normalizedCurrent, normalizedBase); } catch (Exception e) { - LOG.warn("ChangeNavigation: error computing ranges for " + path, e); + LOG.debug("ChangeNavigation: error computing ranges for " + path, e); return Collections.emptyList(); } } + // --- file ordering --- + + /** + * The changed files in the order navigation should visit them: the order the Git Scope tree + * displays, when it has one. + * + *

The tree's order depends on its grouping — by module, repository or directory, switchable + * from the toolbar — so it cannot be derived from the paths alone. Grouping by module puts a + * repository-root file such as {@code .gitignore} before the files of a nested source module, + * even though on disk they are siblings; sorting paths produced the opposite, which is what made + * navigation appear to jump around when crossing a file boundary. + * + *

Falls back to plain file-tree ordering when the tool window has not built its tree (never + * opened this session), and appends any changed file the tree does not show, so navigation can + * never silently skip a file that has gutter markers. + * + *

Entries that cannot be opened are left out — see {@link #navigable}. + */ + private List orderedFiles(Map scopeChanges, Map localChanges) { + java.util.TreeSet changedFiles = new java.util.TreeSet<>(FileTreeOrder.INSTANCE); + changedFiles.addAll(scopeChanges.keySet()); + changedFiles.addAll(localChanges.keySet()); + + ToolWindowServiceInterface toolWindowService = project.getService(ToolWindowServiceInterface.class); + List displayed = toolWindowService == null + ? Collections.emptyList() + : toolWindowService.getDisplayOrderedPaths(); + if (displayed.isEmpty()) { + LOG.debug("ChangeNavigation: tree order unavailable, ordering by file tree"); + return navigable(changedFiles); + } + + java.util.LinkedHashSet ordered = new java.util.LinkedHashSet<>(); + for (String path : displayed) { + if (changedFiles.contains(path)) ordered.add(path); + } + ordered.addAll(changedFiles); + return navigable(ordered); + } + + /** + * Keeps only the entries navigation can actually put a caret in. + * + *

A scope contains things that are not text a caret can move through: + *

+ * + *

These used to stay in the list, and stepping onto one made navigation stop where it stood, + * because opening resolved nothing and returned without moving; pressing again from the + * unchanged position then jumped somewhere unrelated. + * + *

Dropping them here rather than when opening also keeps the index arithmetic honest: "the + * next file" and the wrap at either end are computed over files that can be reached. + */ + private List navigable(java.util.Collection paths) { + List result = new ArrayList<>(paths.size()); + for (String path : paths) { + VirtualFile file = LocalFileSystem.getInstance().findFileByPath(path); + if (file != null && file.isValid() && !file.isDirectory() && !isBinary(file)) { + result.add(path); + } + } + return result; + } + + /** File type resolution touches the VFS, so never let a failure here abort navigation. */ + private static boolean isBinary(@NotNull VirtualFile file) { + try { + return file.getFileType().isBinary(); + } catch (Exception e) { + LOG.debug("ChangeNavigation: could not determine file type of " + file.getPath(), e); + return false; + } + } + + /** Project-relative path when possible, so the log lines up with the tree. */ + private String displayPath(String path) { + String base = project.getBasePath(); + if (base != null && path.length() > base.length() + 1 && path.startsWith(base)) { + return path.substring(base.length() + 1); + } + return path; + } + // --- small utilities --- /** First line strictly greater than {@code line}, or null if none. */ diff --git a/backend/src/main/java/service/TabActionService.java b/backend/src/main/java/service/TabActionService.java new file mode 100644 index 0000000..0a01265 --- /dev/null +++ b/backend/src/main/java/service/TabActionService.java @@ -0,0 +1,247 @@ +package service; + +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.diagnostic.Logger; +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 org.jetbrains.annotations.Nullable; +import system.Defs; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Backend-side tab operations addressed by tab index: rename, reset name, move. + * + *

The tool window, its ContentManager and the scope models are backend state, while the context + * menu actions are registered on the frontend for both modes, so every tab change arrives here over + * {@code UtilRpcApi}. This is the only place the tab rules live. + * + *

All operations validate the index themselves and no-op when it does not address a tab the + * operation applies to: the frontend decides enablement from the tab strip alone and cannot see the + * scope models, so its requests are treated as advisory. + */ +public class TabActionService { + + private static final Logger LOG = Defs.getLogger(TabActionService.class); + + private final Project project; + + public TabActionService(Project project) { + this.project = project; + } + + public void renameTab(int tabIndex, @NotNull String newName) { + if (newName.isEmpty()) return; + runOnEdt(() -> { + ContentManager contentManager = getContentManager(); + if (contentManager == null) return; + + Content content = getRenameableContent(contentManager, tabIndex); + if (content == null) { + LOG.debug("renameTab: index " + tabIndex + " is not a renameable tab"); + return; + } + + content.setDisplayName(newName); + + ViewService viewService = project.getService(ViewService.class); + if (viewService == null) return; + viewService.onTabRenamed(tabIndex, newName); + + MyModel model = getModelForTab(viewService, tabIndex); + if (model != null) { + project.getService(ToolWindowServiceInterface.class).setupTabTooltip(model); + } + publishRenamedTabs(); + }); + } + + public void resetTabName(int tabIndex) { + runOnEdt(() -> { + ContentManager contentManager = getContentManager(); + if (contentManager == null) return; + + Content content = getRenameableContent(contentManager, tabIndex); + if (content == null) { + LOG.debug("resetTabName: index " + tabIndex + " is not a renameable tab"); + return; + } + + ViewService viewService = project.getService(ViewService.class); + if (viewService == null) return; + + MyModel model = getModelForTab(viewService, tabIndex); + if (model == null || model.getCustomTabName() == null || model.getCustomTabName().isEmpty()) { + // Expected: the frontend cannot see the models, so it offers the action for every + // renameable tab and relies on this check. + LOG.debug("resetTabName: tab " + tabIndex + " has no custom name"); + return; + } + + // Clearing the custom name restores the branch-based default name. + model.setCustomTabName(null); + viewService.save(); + + TargetBranchService targetBranchService = project.getService(TargetBranchService.class); + targetBranchService.getTargetBranchDisplayAsync(model.getTargetBranchMap(), branchName -> + ApplicationManager.getApplication().invokeLater(() -> { + if (project.isDisposed()) return; + content.setDisplayName(branchName); + content.setDescription(null); + })); + publishRenamedTabs(); + }); + } + + public void moveTab(int tabIndex, @NotNull rpc.TabMoveDirection direction) { + runOnEdt(() -> { + ContentManager contentManager = getContentManager(); + if (contentManager == null) return; + + int newIndex = direction == rpc.TabMoveDirection.LEFT ? tabIndex - 1 : tabIndex + 1; + Content content = getMovableContent(contentManager, tabIndex, newIndex); + if (content == null) { + LOG.debug("moveTab: " + tabIndex + " -> " + newIndex + " is not a valid move"); + return; + } + + LOG.debug("Moving tab from index " + tabIndex + " to " + newIndex); + ViewService viewService = project.getService(ViewService.class); + try { + // Set the flag BEFORE moving so the content listener does not treat the + // remove/add pair as a user-initiated tab change. + if (viewService != null) { + viewService.setProcessingTabReorder(true); + } + + contentManager.removeContent(content, false); + contentManager.addContent(content, newIndex); + contentManager.setSelectedContent(content, true); + + if (viewService != null) { + viewService.onTabReordered(tabIndex, newIndex); + } + } finally { + if (viewService != null) { + viewService.setProcessingTabReorder(false); + } + } + // Indices shifted, so the previously published set no longer addresses the same tabs. + publishRenamedTabs(); + }); + } + + /** + * Republishes the renamed tabs as index -> branch-based name, which the frontend uses both to + * decide whether "Reset Tab Name" has anything to reset and to show the original name as the + * tab's tooltip. + * + *

Called after every operation that can change the answer, when a frontend subscribes, and + * after a change collection — the branch names cannot be resolved until repositories are + * registered, which is why a tab renamed in a previous session had no tooltip right after boot. + * Publishing an unchanged map is free: the state flow conflates equal values. + */ + public void publishRenamedTabs() { + ApplicationManager.getApplication().invokeLater(() -> { + if (project.isDisposed()) return; + + ContentManager contentManager = getContentManager(); + ViewService viewService = project.getService(ViewService.class); + if (contentManager == null || viewService == null) return; + + Map renamed = new LinkedHashMap<>(); + for (int index = 1; index < contentManager.getContentCount(); index++) { + if (!isRenameableTab(contentManager, index)) continue; + MyModel model = getModelForTab(viewService, index); + if (model != null && model.getCustomTabName() != null && !model.getCustomTabName().isEmpty()) { + renamed.put(index, model); + } + } + + if (renamed.isEmpty()) { + publish(Map.of()); + return; + } + + // One async branch-name resolution per renamed tab; publish once they have all answered. + TargetBranchService targetBranchService = project.getService(TargetBranchService.class); + Map resolved = new ConcurrentHashMap<>(); + AtomicInteger pending = new AtomicInteger(renamed.size()); + for (Map.Entry entry : renamed.entrySet()) { + targetBranchService.getTargetBranchDisplayAsync(entry.getValue().getTargetBranchMap(), branchName -> { + // Empty means repositories are not resolvable yet; leave the tab out rather than + // publish a blank tooltip, and a later collection will republish with the name. + if (branchName != null && !branchName.isEmpty()) { + resolved.put(entry.getKey(), branchName); + } + if (pending.decrementAndGet() == 0) { + publish(Map.copyOf(resolved)); + } + }); + } + }); + } + + private void publish(@NotNull Map renamedTabs) { + if (project.isDisposed()) return; + LOG.debug("publishRenamedTabs: " + renamedTabs); + project.getService(rpc.UtilCommandService.class).setRenamedTabs(renamedTabs); + } + + // --- rules, shared by every caller --- + + /** Whether the tab at {@code index} may be renamed: not HEAD (index 0) and not the "+" tab. */ + public static boolean isRenameableTab(@NotNull ContentManager contentManager, int index) { + if (index <= 0 || index >= contentManager.getContentCount()) return false; + Content content = contentManager.getContent(index); + return content != null && !Defs.PLUS_TAB_LABEL.equals(content.getTabName()); + } + + /** Whether the tab at {@code index} may move to {@code newIndex} (never onto HEAD or "+"). */ + public static boolean isMovableTab(@NotNull ContentManager contentManager, int index, int newIndex) { + if (!isRenameableTab(contentManager, index)) return false; + int lastIndex = contentManager.getContentCount() - 1; + return newIndex >= 1 && newIndex < lastIndex; + } + + private static @Nullable Content getRenameableContent(@NotNull ContentManager contentManager, int index) { + return isRenameableTab(contentManager, index) ? contentManager.getContent(index) : null; + } + + private static @Nullable Content getMovableContent(@NotNull ContentManager contentManager, int index, int newIndex) { + return isMovableTab(contentManager, index, newIndex) ? contentManager.getContent(index) : null; + } + + // --- helpers --- + + private @Nullable ContentManager getContentManager() { + ToolWindowServiceInterface toolWindowService = project.getService(ToolWindowServiceInterface.class); + if (toolWindowService == null) return null; + ToolWindow toolWindow = toolWindowService.getToolWindow(); + return toolWindow == null ? null : toolWindow.getContentManager(); + } + + private static @Nullable MyModel getModelForTab(@NotNull ViewService viewService, int tabIndex) { + int modelIndex = viewService.getModelIndex(tabIndex); + if (modelIndex < 0 || modelIndex >= viewService.getCollection().size()) return null; + return viewService.getCollection().get(modelIndex); + } + + private void runOnEdt(@NotNull Runnable action) { + ApplicationManager.getApplication().invokeLater(() -> { + if (project.isDisposed()) return; + try { + action.run(); + } catch (Exception e) { + LOG.warn("Tab operation failed", e); + } + }); + } +} diff --git a/backend/src/main/java/service/ToolWindowService.java b/backend/src/main/java/service/ToolWindowService.java index f7de2b9..95f0d6a 100644 --- a/backend/src/main/java/service/ToolWindowService.java +++ b/backend/src/main/java/service/ToolWindowService.java @@ -1,6 +1,8 @@ package service; import com.intellij.openapi.Disposable; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.application.ModalityState; import com.intellij.openapi.components.Service; import com.intellij.openapi.project.Project; import com.intellij.openapi.vfs.VirtualFile; @@ -23,6 +25,8 @@ @Service(Service.Level.PROJECT) public final class ToolWindowService implements ToolWindowServiceInterface, Disposable { + private static final com.intellij.openapi.diagnostic.Logger LOG = Defs.getLogger(ToolWindowService.class); + private final Project project; private final Map contentToViewMap = new HashMap<>(); private final TabOperations tabOperations; @@ -191,6 +195,54 @@ public void selectFile(VirtualFile file) { } } + @Override + public java.util.List getDisplayOrderedPaths() { + // The tree is a Swing component, so reading its model belongs on the EDT; callers (change + // navigation) run on background threads and should not have to know that. + java.util.List paths = new java.util.ArrayList<>(); + ApplicationManager.getApplication().invokeAndWait(() -> { + if (project.isDisposed()) return; + try { + VcsTree vcsTree = getVcsTree(); + if (vcsTree != null) paths.addAll(vcsTree.getDisplayOrderedPaths()); + } catch (Exception e) { + LOG.debug("Could not read the tool window's display order", e); + } + }, ModalityState.any()); + return paths; + } + + @Override + public boolean isFocused() { + // isActive() reads window-manager state that is only consistent on the EDT; callers (change + // navigation) run on background threads and should not have to know that. + boolean[] focused = {false}; + ApplicationManager.getApplication().invokeAndWait(() -> { + if (project.isDisposed()) return; + ToolWindow toolWindow = getToolWindow(); + focused[0] = toolWindow != null && toolWindow.isVisible() && toolWindow.isActive(); + }, ModalityState.any()); + return focused[0]; + } + + @Override + public void restoreFocus() { + // Queued behind the file open the caller scheduled, so the check below sees the focus as it + // ends up rather than as it was. Focus transfers the window manager owns -- Project View + // being shown next to us on Linux, say -- are asynchronous and can land later still; there + // is no way to wait for those, which is why callers avoid moving the focus in the first + // place instead of relying on this. + ApplicationManager.getApplication().invokeLater(() -> { + if (project.isDisposed()) return; + ToolWindow toolWindow = getToolWindow(); + if (toolWindow == null || !toolWindow.isVisible() || toolWindow.isActive()) return; + LOG.debug("Returning focus to the Git Scope tool window"); + // forced=false, so a real user action that moved the focus elsewhere in the meantime + // keeps it instead of being overridden. + toolWindow.activate(null, true, false); + }, ModalityState.any()); + } + @Override public MyModel getModelForContent(Content content) { ToolWindowView toolWindowView = contentToViewMap.get(content); diff --git a/backend/src/main/java/service/ToolWindowServiceInterface.java b/backend/src/main/java/service/ToolWindowServiceInterface.java index 6c9669e..181646a 100644 --- a/backend/src/main/java/service/ToolWindowServiceInterface.java +++ b/backend/src/main/java/service/ToolWindowServiceInterface.java @@ -30,6 +30,24 @@ public interface ToolWindowServiceInterface { void selectFile(VirtualFile file); + /** + * Paths of the displayed changes in the order the tool window shows them, or an empty list when + * it has no tree yet. Safe to call from any thread. + */ + java.util.List getDisplayOrderedPaths(); + + /** + * Whether the Git Scope tool window is the one currently holding the focus. Safe to call from + * any thread. + */ + boolean isFocused(); + + /** + * Returns the focus to the Git Scope tool window if something took it in the meantime. Does + * nothing when the tool window is hidden or still focused. Safe to call from any thread. + */ + void restoreFocus(); + ToolWindow getToolWindow(); MyModel getModelForContent(com.intellij.ui.content.Content content); diff --git a/backend/src/main/java/service/ViewService.java b/backend/src/main/java/service/ViewService.java index a866ac7..4797f81 100644 --- a/backend/src/main/java/service/ViewService.java +++ b/backend/src/main/java/service/ViewService.java @@ -11,6 +11,7 @@ import com.intellij.openapi.vcs.FileStatusManager; import com.intellij.openapi.vcs.VcsApplicationSettings; import com.intellij.openapi.vcs.changes.Change; +import com.intellij.openapi.vcs.changes.VcsDirtyScopeManager; import com.intellij.openapi.vcs.impl.LineStatusTrackerManagerI; import com.intellij.openapi.wm.ToolWindow; import com.intellij.ui.content.Content; @@ -80,6 +81,7 @@ private static class DisposalToken { private Integer savedTabIndex; private final AtomicBoolean tabInitializationInProgress = new AtomicBoolean(false); private final AtomicBoolean initialFileColorsRefreshed = new AtomicBoolean(false); + private final AtomicBoolean initialChangelistRefreshed = new AtomicBoolean(false); private final Map> modelListeners = new HashMap<>(); public ViewService(Project project) { @@ -342,9 +344,29 @@ public void save() { public void eventVcsReady() { this.vcsReady = true; + forceInitialChangelistRefresh(); init(); } + /** + * Forces one full changelist rescan per project, as soon as VCS is usable. + * + *

ChangeListManager restores the changelist persisted in workspace.xml and from then on only + * updates incrementally, over whatever VcsDirtyScopeManager reports as dirty. An entry whose + * file no longer exists can never enter that dirty scope — no VFS event can fire for a file that + * is not there — so it survives every update, is re-persisted on close, and returns on the next + * open. Restarting the IDE does not clear it; only a full rescan does, which is why such an + * entry otherwise lingers until something unrelated (a commit touching .git/index) forces one. + */ + private void forceInitialChangelistRefresh() { + if (project.isDisposed() || !initialChangelistRefreshed.compareAndSet(false, true)) { + // directoryMappingChanged() fires again whenever mappings change; one rescan is enough. + return; + } + LOG.debug("Forcing a full changelist rescan to discard entries restored from workspace.xml"); + VcsDirtyScopeManager.getInstance(project).markEverythingDirty(); + } + public void eventToolWindowReady() { this.toolWindowReady = true; init(); @@ -412,6 +434,9 @@ public void initTabsSequentially() { } } + // Publish tooltips for tabs restored with a custom name. + project.getService(TabActionService.class).publishRenamedTabs(); + // Step 4: Add the listener after all tabs are initialized toolWindowService.addListener(); @@ -656,6 +681,7 @@ private void ensureHeadTabInitializedAsync(MyModel model, Runnable onComplete) { public CompletableFuture collectChanges(MyModel model, boolean checkFs) { CompletableFuture done = new CompletableFuture<>(); if (model == null) { + LOG.debug("collectChanges skipped: no current model"); done.complete(null); return done; } @@ -664,6 +690,9 @@ public CompletableFuture collectChanges(MyModel model, boolean checkFs) { ensureHeadTabInitializedAsync(model, () -> { TargetBranchMap targetBranchMap = model.getTargetBranchMap(); if (targetBranchMap == null) { + // Repositories not registered yet, or the tab has no target branch: nothing can be + // collected and no later event necessarily retries, so the scope stays as it was. + LOG.debug("collectChanges skipped for tab '" + model.getDisplayName() + "': no target branch map"); done.complete(null); return; } @@ -685,6 +714,15 @@ private void collectChangesInternal(MyModel model, TargetBranchMap targetBranchM final DisposalToken token = this.disposalToken; changesExecutor.execute(() -> { changesService.collectChangesWithCallback(finalTargetBranchMap, result -> { + if (result == null) { + // Superseded or cancelled by a newer collection, which owns the model now. + // Complete the future anyway so chained UI work (e.g. the file-colors refresh + // after a tab switch) is never silently dropped. + LOG.debug("Collection for generation " + gen + " superseded, completing without apply"); + done.complete(null); + return; + } + // Build maps on background thread to avoid slow file system operations on EDT Map mergedChangesMap = MyModel.buildChangesByPathMap(result.mergedChanges()); Map scopeChangesMap = MyModel.buildChangesByPathMap(result.scopeChanges()); @@ -695,9 +733,25 @@ private void collectChangesInternal(MyModel model, TargetBranchMap targetBranchM long currentGen = applyGeneration.get(); if (!project.isDisposed() && !token.disposed && currentGen == gen) { LOG.debug("Applying changes for generation " + gen); + Map previousScopeMap = model.getScopeChangesMap(); model.setChangesWithMap(result.mergedChanges(), mergedChangesMap); model.setScopeChangesWithMap(result.scopeChanges(), scopeChangesMap); model.setLocalChangesWithMap(result.localChanges(), localChangesMap); + + // GitScopeFileStatusProvider answers from the scope map, but the + // platform caches its answers until fileStatusesChanged() -- which + // previously only tab switches triggered, so Project-view colors kept + // showing the pre-collection scope (e.g. conflict-era statuses after a + // rebase). Refresh when the statuses materially changed; the guard + // avoids the LST-disturbing refresh on the common no-change apply. + if (model.isActive() && !sameScopeStatuses(previousScopeMap, scopeChangesMap)) { + LOG.debug("Scope statuses changed for generation " + gen + ", refreshing file colors"); + refreshFileColors(); + } + + // Repositories are resolvable by now, so tab tooltips that could not be + // built at boot (empty branch name) can finally be published. + project.getService(TabActionService.class).publishRenamedTabs(); } else { LOG.debug("Discarding changes for generation " + gen + " (current generation is " + currentGen + ")"); } @@ -709,6 +763,23 @@ private void collectChangesInternal(MyModel model, TargetBranchMap targetBranchM }); } + /** + * Whether two scope maps would produce the same file colors: same files, same statuses. + * Used to skip the file-status refresh on the common apply where nothing changed. + */ + private static boolean sameScopeStatuses(Map previous, Map current) { + if (previous == current) return true; + if (previous == null || current == null) return false; + if (previous.size() != current.size()) return false; + for (Map.Entry entry : previous.entrySet()) { + Change other = current.get(entry.getKey()); + if (other == null || !entry.getValue().getFileStatus().equals(other.getFileStatus())) { + return false; + } + } + return true; + } + // helper to enqueue UI work strictly after the currently queued collections public void runAfterCurrentChangeCollection(Runnable uiTask) { if (isDisposed) return; @@ -833,6 +904,7 @@ public void removeTab(int tabIndex) { } } + public void onTabReordered(int oldIndex, int newIndex) { // Note: The isProcessingTabReorder flag should already be set by the caller // before any UI changes are made, to prevent listener interference @@ -845,6 +917,67 @@ public void onTabReordered(int oldIndex, int newIndex) { save(); } + /** + * Handles a tab reorder the plugin did not perform — the platform lets tabs be dragged in the + * tool window header (monolith only; split mode disables tab drag entirely). The drag ends by + * re-adding the content at a new index, which reaches us as a removal, so without this the + * model would be deleted while its tab stayed on screen. + * + *

HEAD and "+" are restored to the ends first: the drag helper cannot be told to leave them + * alone, so the invariant the popup actions enforce up front is enforced here after the fact. + */ + public void onTabsDragged() { + if (isDisposed || toolWindowService == null) return; + + ToolWindow toolWindow = toolWindowService.getToolWindow(); + if (toolWindow == null) return; + ContentManager contentManager = toolWindow.getContentManager(); + + isProcessingTabReorder = true; + try { + restoreSpecialTabPositions(contentManager); + rebuildCollectionFromTabOrder(); + save(); + } finally { + isProcessingTabReorder = false; + } + + // Indices changed, so tooltips and the "can be reset" set no longer address the same tabs. + project.getService(TabActionService.class).publishRenamedTabs(); + } + + /** Moves the HEAD tab back to the front and the "+" tab back to the end if a drag moved them. */ + private void restoreSpecialTabPositions(@NotNull ContentManager contentManager) { + Content headContent = null; + Content plusContent = null; + for (int index = 0; index < contentManager.getContentCount(); index++) { + Content content = contentManager.getContent(index); + if (content == null) continue; + if (PLUS_TAB_LABEL.equals(content.getTabName())) { + plusContent = content; + continue; + } + MyModel model = toolWindowService.getModelForContent(content); + if (model != null && model.isHeadTab()) { + headContent = content; + } + } + + moveContentTo(contentManager, headContent, 0); + moveContentTo(contentManager, plusContent, contentManager.getContentCount() - 1); + } + + private void moveContentTo(@NotNull ContentManager contentManager, Content content, int targetIndex) { + if (content == null) return; + int currentIndex = contentManager.getIndexOfContent(content); + if (currentIndex < 0 || currentIndex == targetIndex) return; + + LOG.debug("Restoring special tab '" + content.getTabName() + "' from index " + currentIndex + + " to " + targetIndex); + contentManager.removeContent(content, false); + contentManager.addContent(content, targetIndex); + } + /** * Rebuilds the collection based on the current tab order in ContentManager. * This ensures that collection[i] corresponds to tab[i+1] (accounting for HEAD at index 0). @@ -863,7 +996,7 @@ private void rebuildCollectionFromTabOrder() { if (model != null && !model.isHeadTab()) { newCollection.add(model); } else { - LOG.warn("Model not found for tab at index " + i + ": " + content.getTabName()); + LOG.debug("Model not found for tab at index " + i + ": " + content.getTabName()); } } } diff --git a/backend/src/main/java/state/WindowPositionTracker.java b/backend/src/main/java/state/WindowPositionTracker.java index 42f77a6..c2f12c2 100644 --- a/backend/src/main/java/state/WindowPositionTracker.java +++ b/backend/src/main/java/state/WindowPositionTracker.java @@ -156,7 +156,7 @@ public void attachScrollListeners(Component component) { if (LOG.isDebugEnabled()) { LOG.debug("Error attaching scroll listeners for tab " + currentTabId + ": " + e.getMessage()); } - LOG.warn("Error attaching scroll listeners", e); + LOG.debug("Error attaching scroll listeners", e); } }); } @@ -237,7 +237,7 @@ else if (hasUserActivity) { if (LOG.isDebugEnabled()) { LOG.debug("Error attaching scroll listeners" + logPrefix + " for tab " + currentTabId + ": " + e.getMessage()); } - LOG.warn("Error attaching scroll listeners", e); + LOG.debug("Error attaching scroll listeners", e); } } diff --git a/backend/src/main/java/toolwindow/BranchSelectView.java b/backend/src/main/java/toolwindow/BranchSelectView.java index bb9a6f4..0526c46 100644 --- a/backend/src/main/java/toolwindow/BranchSelectView.java +++ b/backend/src/main/java/toolwindow/BranchSelectView.java @@ -108,7 +108,15 @@ public BranchSelectView(Project project) { JPanel help = new JPanel(); help.setLayout(new FlowLayout(FlowLayout.LEFT)); - JCheckBox checkBox = new JCheckBox("Only Changes Since Common Ancestor (git diff ..HEAD)"); + JCheckBox checkBox = new JCheckBox("Only Changes Since Common Ancestor (git diff ...HEAD)"); + checkBox.setToolTipText( + "" + + "Compares HEAD against its common ancestor with the selection,
" + + "so changes made on the selected branch since you branched off are excluded.
" + + "This is the diff a pull request shows.
" + + "Unchecked, the selection is compared directly to HEAD." + + "" + ); checkBox.setSelected(this.state.getTwoDotsCheckbox()); checkBox.setBorder(JBUI.Borders.empty(1)); // top, left, bottom, right padding checkBox.addActionListener(e -> this.state.setTwoDotsCheckbox(checkBox.isSelected())); diff --git a/backend/src/main/java/toolwindow/actions/RenameTabAction.java b/backend/src/main/java/toolwindow/actions/RenameTabAction.java deleted file mode 100644 index 173a67d..0000000 --- a/backend/src/main/java/toolwindow/actions/RenameTabAction.java +++ /dev/null @@ -1,125 +0,0 @@ -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/backend/src/main/java/toolwindow/actions/ResetTabNameAction.java b/backend/src/main/java/toolwindow/actions/ResetTabNameAction.java deleted file mode 100644 index 1d3267c..0000000 --- a/backend/src/main/java/toolwindow/actions/ResetTabNameAction.java +++ /dev/null @@ -1,133 +0,0 @@ -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/backend/src/main/java/toolwindow/actions/TabMoveActions.java b/backend/src/main/java/toolwindow/actions/TabMoveActions.java deleted file mode 100644 index e501484..0000000 --- a/backend/src/main/java/toolwindow/actions/TabMoveActions.java +++ /dev/null @@ -1,295 +0,0 @@ -package toolwindow.actions; - -import com.intellij.openapi.actionSystem.ActionUpdateThread; -import com.intellij.openapi.actionSystem.AnAction; -import com.intellij.openapi.actionSystem.AnActionEvent; -import com.intellij.openapi.actionSystem.PlatformDataKeys; -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 org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import service.ToolWindowServiceInterface; -import service.ViewService; -import system.Defs; - -import java.awt.*; -import java.lang.reflect.Field; - -import static service.ViewService.PLUS_TAB_LABEL; - -/** - * Actions to move tabs left and right in the Git Scope tool window. - */ -public class TabMoveActions { - private static final com.intellij.openapi.diagnostic.Logger LOG = Defs.getLogger(TabMoveActions.class); - - /** - * Gets the Content that was right-clicked in a context menu event - */ - @Nullable - private static 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; - } - - /** - * Helper class to hold validation result for update() and actionPerformed() methods - */ - 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; - } - } - - /** - * 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(); - - int currentIndex = contentManager.getIndexOfContent(targetContent); - - 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; - } - - // 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 (ctx.currentIndex <= 1 || newIndex < 1) { - return; - } - - // Cannot move + tab - if (PLUS_TAB_LABEL.equals(ctx.targetContent.getTabName())) { - return; - } - - moveTab(ctx.project, ctx.contentManager, ctx.targetContent, ctx.currentIndex, newIndex); - } - - @Override - public void update(@NotNull AnActionEvent e) { - // By default, hide the action - e.getPresentation().setEnabledAndVisible(false); - - UpdateContext ctx = validateUpdateContext(e); - if (ctx == null) { - return; - } - - // Enable only if not HEAD tab (index 0), not + tab, and can move left (index > 1) - boolean enabled = ctx.currentIndex > 1 && !PLUS_TAB_LABEL.equals(ctx.tabName); - e.getPresentation().setEnabledAndVisible(enabled); - } - - @Override - public @NotNull ActionUpdateThread getActionUpdateThread() { - return ActionUpdateThread.EDT; - } - } - - /** - * Action to move the current tab to the right. - * Registered in plugin.xml and works across all projects. - */ - public static class MoveTabRight extends AnAction { - @Override - public void actionPerformed(@NotNull AnActionEvent e) { - ActionContext ctx = validateActionContext(e); - if (ctx == null) return; - - int newIndex = ctx.currentIndex + 1; - int lastIndex = ctx.contentManager.getContentCount() - 1; - - // Cannot move HEAD tab (index 0) or move past + tab - if (ctx.currentIndex == 0 || newIndex >= lastIndex) { - return; - } - - // Cannot move + tab - if (PLUS_TAB_LABEL.equals(ctx.targetContent.getTabName())) { - return; - } - - moveTab(ctx.project, ctx.contentManager, ctx.targetContent, ctx.currentIndex, newIndex); - } - - @Override - public void update(@NotNull AnActionEvent e) { - // By default, hide the action - e.getPresentation().setEnabledAndVisible(false); - - UpdateContext ctx = validateUpdateContext(e); - if (ctx == null) { - return; - } - - 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 = ctx.currentIndex > 0 && ctx.currentIndex < lastIndex - 1 && !PLUS_TAB_LABEL.equals(ctx.tabName); - e.getPresentation().setEnabledAndVisible(enabled); - } - - @Override - public @NotNull ActionUpdateThread getActionUpdateThread() { - return ActionUpdateThread.EDT; - } - } - - /** - * Helper method to move a tab from one position to another - */ - private static void moveTab(Project project, ContentManager contentManager, Content content, int oldIndex, int newIndex) { - ViewService viewService = null; - try { - LOG.info("Moving tab from index " + oldIndex + " to " + newIndex); - - // Additional validation to prevent moving + tab or moving to + tab position - int lastIndex = contentManager.getContentCount() - 1; - - // Cannot move HEAD tab (index 0) - if (oldIndex == 0) { - LOG.warn("Cannot move HEAD tab"); - return; - } - - // Cannot move + tab (last index) - if (oldIndex == lastIndex || PLUS_TAB_LABEL.equals(content.getTabName())) { - LOG.warn("Cannot move + tab"); - return; - } - - // Cannot move to position 0 (before HEAD) or to last position (where + tab is) - if (newIndex < 1 || newIndex >= lastIndex) { - LOG.warn("Invalid target index: " + newIndex); - return; - } - - // Verify the + tab is still at the last position - Content lastContent = contentManager.getContent(lastIndex); - if (lastContent == null || !PLUS_TAB_LABEL.equals(lastContent.getTabName())) { - LOG.error("+ tab is not at expected position!"); - return; - } - - // IMPORTANT: Set the flag BEFORE moving tabs to prevent listener interference - viewService = project.getService(ViewService.class); - if (viewService != null) { - viewService.setProcessingTabReorder(true); - } - - // Remove content from old position - contentManager.removeContent(content, false); - - // Add content at new position - contentManager.addContent(content, newIndex); - - // Select the moved tab - contentManager.setSelectedContent(content, true); - - // Now rebuild collection and save - if (viewService != null) { - viewService.onTabReordered(oldIndex, newIndex); - } - - LOG.info("Tab moved successfully"); - } catch (Exception e) { - LOG.error("Error moving tab: " + e.getMessage(), e); - } finally { - // Always clear the flag - if (viewService != null) { - viewService.setProcessingTabReorder(false); - } - } - } -} diff --git a/backend/src/main/java/toolwindow/elements/MySimpleChangesBrowser.java b/backend/src/main/java/toolwindow/elements/MySimpleChangesBrowser.java index 11d040e..59ccbe0 100644 --- a/backend/src/main/java/toolwindow/elements/MySimpleChangesBrowser.java +++ b/backend/src/main/java/toolwindow/elements/MySimpleChangesBrowser.java @@ -21,6 +21,7 @@ import com.intellij.openapi.vcs.changes.ui.ChangeDiffRequestChain; import com.intellij.openapi.vcs.changes.ui.SimpleAsyncChangesBrowser; import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.ui.render.RenderingUtil; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import system.Defs; @@ -67,6 +68,12 @@ private MySimpleChangesBrowser(@NotNull Project project, @NotNull CollectionChange navigation follows this rather than sorting paths itself, because the order depends + * on the tree's grouping — by module, repository or directory, switchable from the toolbar — and + * only the tree knows which is active. Grouping by module, for example, puts a repository-root + * file after the files of a nested source module, which no path comparison would reproduce. + * + *

Returns an empty list when the tool window has not built its tree yet; navigation then + * falls back to plain file-tree ordering. + */ + public List getDisplayOrderedPaths() { + if (currentBrowser == null) return Collections.emptyList(); + + List paths = new ArrayList<>(); + // traverse() is a pre-order DFS of the tree model, i.e. exactly the displayed order. + for (Change change : com.intellij.openapi.vcs.changes.ui.VcsTreeModelData + .all(currentBrowser.getViewer()) + .userObjects(Change.class)) { + paths.add(ChangesUtil.getFilePath(change).getPath()); + } + return paths; + } + public void selectFile(VirtualFile file) { if (currentBrowser == null) { return; } List changes = currentBrowser.getAllChanges(); @@ -124,7 +148,7 @@ private String getCurrentTabId() { return project.getName() + "_tab_" + tabIndex; } } catch (Exception e) { - LOG.warn("Failed to get current tab ID", e); + LOG.debug("Failed to get current tab ID", e); } return project.getName() + "_default_tab"; } @@ -388,7 +412,7 @@ private void setComponent(Component component) { }); } catch (Exception e) { - LOG.error("Error updating VcsTree component", e); + LOG.warn("Error updating VcsTree component", e); try { this.removeAll(); this.add(component, BorderLayout.CENTER); diff --git a/backend/src/main/java/utils/FileTreeOrder.java b/backend/src/main/java/utils/FileTreeOrder.java new file mode 100644 index 0000000..9f960bf --- /dev/null +++ b/backend/src/main/java/utils/FileTreeOrder.java @@ -0,0 +1,64 @@ +package utils; + +import com.intellij.ide.util.treeView.FileNameComparator; +import org.jetbrains.annotations.NotNull; + +import java.util.Comparator; + +/** + * Orders file paths the way a file tree shows them: walking down into a directory before moving on + * to the next entry of the level above, subdirectories before files within each directory, and + * names compared naturally — case-insensitively, with digit runs read as numbers. + * + *

Change navigation uses this so stepping past the last change of a file lands on the file the + * Git Scope tree shows next. Sorting the absolute paths as plain strings instead — which is what + * navigation used to do — produces an order that has little to do with the tree: uppercase names + * sort before all lowercase ones, and a file sorts among the contents of a sibling directory + * whenever their names share a prefix. + * + *

This mirrors the platform's own {@code HierarchicalFilePathComparator.NATURAL}, which the + * changes tree sorts with. It is reimplemented here rather than called because that class is + * marked internal, while the name comparison it delegates to is not; comparing paths as strings + * also keeps this testable without an IDE. + */ +public final class FileTreeOrder implements Comparator { + + public static final FileTreeOrder INSTANCE = new FileTreeOrder(); + + private FileTreeOrder() {} + + @Override + public int compare(@NotNull String path1, @NotNull String path2) { + int start = 0; + while (true) { + int end1 = path1.indexOf('/', start); + int end2 = path2.indexOf('/', start); + + // A segment with more path after it is a directory; the last segment is the file. + boolean isDirectory1 = end1 != -1; + boolean isDirectory2 = end2 != -1; + if (isDirectory1 != isDirectory2) { + // Same level, one descends and one does not: directories come first, whatever the + // names are. This is what makes navigation walk a directory to its end before + // continuing with the files beside it. + return isDirectory1 ? -1 : 1; + } + + String name1 = isDirectory1 ? path1.substring(start, end1) : path1.substring(start); + String name2 = isDirectory2 ? path2.substring(start, end2) : path2.substring(start); + + int byName = FileNameComparator.getInstance().compare(name1, name2); + if (byName != 0) return byName; + + if (!isDirectory1) { + // Same name and both are the final segment: identical paths apart from case, which + // the natural comparison ignores. Fall back to an exact comparison so the order + // stays stable rather than reporting two distinct files as equal. + return path1.compareTo(path2); + } + + // Same directory name: continue with the next level. + start = end1 + 1; + } + } +} diff --git a/backend/src/main/java/utils/GitUtil.java b/backend/src/main/java/utils/GitUtil.java index aa993da..5a00e88 100644 --- a/backend/src/main/java/utils/GitUtil.java +++ b/backend/src/main/java/utils/GitUtil.java @@ -69,6 +69,41 @@ public static GitRevisionNumber resolveGitReference(@NotNull GitRepository repos return null; } + /** + * Resolves the common ancestor of the given ref and the repository's current HEAD. + * This is the base Git hosting providers use for pull-request diffs, so diffing it against + * HEAD yields {@code git diff ...HEAD}. + * + * @param repository the target GitRepository + * @param selectedRef the ref the scope compares against (branch, tag, hash, ...) + * @return the merge base, or null when it cannot be resolved (unknown ref, unrelated histories) + */ + @Nullable + public static GitRevisionNumber resolveMergeBase(@NotNull GitRepository repository, + @NotNull String selectedRef) { + + Project project = repository.getProject(); + + GitLineHandler handler = new GitLineHandler(project, repository.getRoot(), GitCommand.MERGE_BASE); + handler.addParameters(selectedRef, "HEAD"); + try { + String hash = Git.getInstance().runCommand(handler) + .getOutputOrThrow() + .trim(); + if (!hash.isEmpty()) { + return new GitRevisionNumber(hash); + } + } catch (VcsException e) { + // Expected when the ref is unknown in this repository or histories are unrelated + LOG.debug("Failed to resolve merge base of '" + selectedRef + "' and HEAD: " + e.getMessage()); + } catch (Exception e) { + LOG.warn("Unexpected error resolving merge base of '" + selectedRef + "' and HEAD in repository " + + repository.getRoot().getPath(), e); + } + + return null; + } + public static @NotNull Collection getDiffChanges(@NotNull GitRepository repository, @NotNull VirtualFile file, @NotNull GitRevisionNumber revisionNumber) throws VcsException { diff --git a/backend/src/main/java/utils/PlatformApiReflection.java b/backend/src/main/java/utils/PlatformApiReflection.java index 339e327..84303e7 100644 --- a/backend/src/main/java/utils/PlatformApiReflection.java +++ b/backend/src/main/java/utils/PlatformApiReflection.java @@ -1,8 +1,6 @@ package utils; import com.intellij.openapi.diagnostic.Logger; -import com.intellij.openapi.vcs.changes.Change; -import git4idea.GitCommit; import git4idea.GitReference; import git4idea.repo.GitRepository; import org.jetbrains.annotations.NotNull; @@ -13,8 +11,6 @@ import java.lang.invoke.MethodHandles; import java.lang.invoke.MethodType; import java.lang.reflect.Method; -import java.util.Collection; -import java.util.Collections; import java.util.Map; /** @@ -30,8 +26,6 @@ * *

Bridges

* @@ -43,10 +37,6 @@ public final class PlatformApiReflection { private static final Logger LOG = Defs.getLogger(PlatformApiReflection.class); - // ── GitCommit.getChanges() (@ApiStatus.Experimental) ──────────────────── - // type after adaptation: (Object receiver) -> Object - private static final @Nullable MethodHandle COMMIT_GET_CHANGES; - // ── Tag lookup (IDE 2026.1+): getTagsHolder() API ──────────────────────── // Each handle: (Object receiver [, args]) -> Object private static final @Nullable MethodHandle REPO_GET_TAGS_HOLDER; @@ -59,9 +49,6 @@ public final class PlatformApiReflection { private static final @Nullable MethodHandle TAG_HOLDER_GET_TAG; // (Object, Object name) -> Object static { - // ── GitCommit.getChanges() ───────────────────────────────────────── - COMMIT_GET_CHANGES = resolvePublicVirtual(GitCommit.class, "getChanges"); - // ── New tag path ─────────────────────────────────────────────────── REPO_GET_TAGS_HOLDER = resolvePublicVirtual(GitRepository.class, "getTagsHolder"); TAGS_HOLDER_GET_STATE = @@ -116,33 +103,6 @@ private PlatformApiReflection() {} // ── Public API ──────────────────────────────────────────────────────────── - /** - * Calls {@code commit.getChanges()} via a cached {@link MethodHandle}. - * The method is annotated {@code @ApiStatus.Experimental} and therefore accessed - * reflectively to avoid verifyPlugin warnings. - * - * @return the commit's changes, or an empty list when the handle is unavailable - */ - @NotNull - @SuppressWarnings("unchecked") - public static Collection getCommitChanges(@NotNull GitCommit commit) { - if (COMMIT_GET_CHANGES == null) { - LOG.warn("PlatformApiReflection: getChanges handle unavailable"); - return Collections.emptyList(); - } - try { - Object result = COMMIT_GET_CHANGES.invoke(commit); - if (result instanceof Collection c) { - return (Collection) c; - } - LOG.warn("PlatformApiReflection: getChanges returned unexpected type: " - + (result == null ? "null" : result.getClass().getName())); - } catch (Throwable t) { - LOG.error("PlatformApiReflection: getCommitChanges invocation failed", t); - } - return Collections.emptyList(); - } - /** * Finds a Git tag by name on the given repository. * diff --git a/backend/src/main/java/utils/ScopeRefRange.java b/backend/src/main/java/utils/ScopeRefRange.java new file mode 100644 index 0000000..dbfa5db --- /dev/null +++ b/backend/src/main/java/utils/ScopeRefRange.java @@ -0,0 +1,84 @@ +package utils; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Parses the Git range syntax a scope can carry: the {@code ..HEAD} produced by the + * "Only Changes Since Common Ancestor" checkbox, and anything a user types manually. + * + *

A scope has exactly two meanings, and a range expresses the second one: + *

    + *
  • a bare ref ({@code main}) — compare that ref directly to HEAD, i.e. {@code git diff main HEAD}
  • + *
  • a range against HEAD ({@code main..HEAD}, {@code main...HEAD}) — everything on HEAD since it + * diverged from the ref, i.e. {@code git diff main...HEAD}, the diff a pull request shows
  • + *
+ * + *

Both dot forms are accepted for the second meaning: a strict two-dot reading would be identical + * to selecting the bare ref, which the UI already offers, and older saved scopes use two dots. The + * right-hand side must be HEAD (or empty, which Git itself defaults to HEAD) — any other target is a + * comparison this plugin cannot express, and is reported rather than silently misread as + * "since the common ancestor with HEAD". + * + *

Git ref names may not contain two consecutive dots (see {@code git check-ref-format}), so the + * first {@code ".."} is an unambiguous separator. + * + *

Deliberately free of IntelliJ Platform types so it can be unit tested without an IDE. + */ +public final class ScopeRefRange { + + private static final String SEPARATOR = ".."; + private static final String HEAD = "HEAD"; + + private ScopeRefRange() {} + + /** + * Returns whether the scope uses Git range syntax at all, supported or not. + */ + public static boolean isRange(@Nullable String scopeRef) { + return scopeRef != null && scopeRef.contains(SEPARATOR); + } + + /** + * Returns the ref on the left of a supported range, whose merge base with HEAD is the diff base. + * + * @return the selected ref, or {@code null} when the scope is not a range, has no left side, or + * targets something other than HEAD + */ + @Nullable + public static String selectedRef(@Nullable String scopeRef) { + if (scopeRef == null) { + return null; + } + + int separator = scopeRef.indexOf(SEPARATOR); + if (separator < 0) { + return null; + } + + String selectedRef = scopeRef.substring(0, separator).trim(); + if (selectedRef.isEmpty()) { + return null; + } + + String target = scopeRef.substring(separator + SEPARATOR.length()); + if (target.startsWith(".")) { + // Three-dot form: drop the extra dot to expose the target ref. + target = target.substring(1); + } + target = target.trim(); + + // An omitted target means HEAD in Git, so "main.." is the same request as "main..HEAD". + return target.isEmpty() || HEAD.equals(target) ? selectedRef : null; + } + + /** + * Strips a supported {@code ..HEAD}/{@code ...HEAD} suffix so the scope can be shown or used as a + * plain ref. Refs without a range, and ranges this plugin does not support, are returned unchanged. + */ + @NotNull + public static String stripRange(@NotNull String scopeRef) { + String selectedRef = selectedRef(scopeRef); + return selectedRef == null ? scopeRef : selectedRef; + } +} diff --git a/backend/src/main/resources/gitscope.backend.xml b/backend/src/main/resources/gitscope.backend.xml index 8b611ad..0efd928 100644 --- a/backend/src/main/resources/gitscope.backend.xml +++ b/backend/src/main/resources/gitscope.backend.xml @@ -47,6 +47,7 @@ + @@ -68,17 +69,8 @@ relative-to-action="Vcs.CopyRevisionNumberAction"/> - - - - - - - - - - - - + diff --git a/backend/src/test/java/utils/FileTreeOrderTest.java b/backend/src/test/java/utils/FileTreeOrderTest.java new file mode 100644 index 0000000..d3678e2 --- /dev/null +++ b/backend/src/test/java/utils/FileTreeOrderTest.java @@ -0,0 +1,99 @@ +package utils; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class FileTreeOrderTest { + + private static List sorted(String... paths) { + List list = new ArrayList<>(Arrays.asList(paths)); + list.sort(FileTreeOrder.INSTANCE); + return list; + } + + @Test + public void walksSubdirectoriesBeforeFilesBesideThem() { + assertEquals( + Arrays.asList( + "/p/src/sub/a.java", + "/p/src/sub/b.java", + "/p/src/aaa.java", + "/p/src/zzz.java"), + sorted( + "/p/src/zzz.java", + "/p/src/sub/b.java", + "/p/src/aaa.java", + "/p/src/sub/a.java")); + } + + @Test + public void ordersNamesCaseInsensitively() { + // Plain string ordering puts every capitalised name first, which is what made navigation + // look erratic: Zebra.java would come before apple.java. + assertEquals( + Arrays.asList("/p/apple.java", "/p/Banana.java", "/p/Zebra.java"), + sorted("/p/Zebra.java", "/p/apple.java", "/p/Banana.java")); + } + + @Test + public void ordersDigitRunsNumerically() { + assertEquals( + Arrays.asList("/p/file2.java", "/p/file10.java"), + sorted("/p/file10.java", "/p/file2.java")); + } + + @Test + public void keepsSiblingDirectoriesSeparate() { + // "/p/a.java" must not land between the contents of "/p/a/", which is what happens when + // full paths are compared as strings ('.' sorts before '/'). + assertEquals( + Arrays.asList( + "/p/a/one.java", + "/p/a/two.java", + "/p/b/one.java", + "/p/a.java"), + sorted( + "/p/a.java", + "/p/b/one.java", + "/p/a/two.java", + "/p/a/one.java")); + } + + @Test + public void ordersDeepAndShallowConsistently() { + assertEquals( + Arrays.asList( + "/p/src/main/java/App.java", + "/p/src/main/resources/app.xml", + "/p/src/test/AppTest.java", + "/p/src/build.gradle", + "/p/README.md"), + sorted( + "/p/README.md", + "/p/src/build.gradle", + "/p/src/test/AppTest.java", + "/p/src/main/resources/app.xml", + "/p/src/main/java/App.java")); + } + + @Test + public void isAntisymmetricAndStable() { + String a = "/p/src/sub/a.java"; + String b = "/p/src/b.java"; + assertTrue(FileTreeOrder.INSTANCE.compare(a, b) < 0); + assertTrue(FileTreeOrder.INSTANCE.compare(b, a) > 0); + assertEquals(0, FileTreeOrder.INSTANCE.compare(a, a)); + } + + @Test + public void distinguishesPathsDifferingOnlyByCase() { + // Natural comparison reports these equal; a TreeSet would then drop one of the two files. + assertTrue(FileTreeOrder.INSTANCE.compare("/p/File.java", "/p/file.java") != 0); + } +} diff --git a/backend/src/test/java/utils/ScopeRefRangeTest.java b/backend/src/test/java/utils/ScopeRefRangeTest.java new file mode 100644 index 0000000..f69777a --- /dev/null +++ b/backend/src/test/java/utils/ScopeRefRangeTest.java @@ -0,0 +1,60 @@ +package utils; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class ScopeRefRangeTest { + + @Test + public void detectsRangeSyntax() { + assertTrue(ScopeRefRange.isRange("main..HEAD")); + assertTrue(ScopeRefRange.isRange("main...HEAD")); + assertTrue(ScopeRefRange.isRange("v1.0..v2.0")); + assertFalse(ScopeRefRange.isRange("main")); + assertFalse(ScopeRefRange.isRange("HEAD~3")); + assertFalse(ScopeRefRange.isRange("release-1.2")); + assertFalse(ScopeRefRange.isRange(null)); + } + + @Test + public void extractsSelectedRefFromSupportedRanges() { + assertEquals("main", ScopeRefRange.selectedRef("main..HEAD")); + assertEquals("main", ScopeRefRange.selectedRef("main...HEAD")); + assertEquals("origin/release", ScopeRefRange.selectedRef("origin/release...HEAD")); + assertEquals("HEAD~3", ScopeRefRange.selectedRef("HEAD~3..HEAD")); + assertEquals("abc123", ScopeRefRange.selectedRef("abc123...HEAD")); + assertEquals("feature/a.b", ScopeRefRange.selectedRef("feature/a.b...HEAD")); + // Git defaults an omitted side to HEAD + assertEquals("main", ScopeRefRange.selectedRef("main..")); + assertEquals("main", ScopeRefRange.selectedRef("main...")); + } + + @Test + public void rejectsRangesThatDoNotTargetHead() { + assertNull(ScopeRefRange.selectedRef("v1.0..v2.0")); + assertNull(ScopeRefRange.selectedRef("main...feature")); + assertNull(ScopeRefRange.selectedRef("a..b..HEAD")); + assertNull(ScopeRefRange.selectedRef("main....HEAD")); + } + + @Test + public void rejectsScopesWithoutSelectedRef() { + assertNull(ScopeRefRange.selectedRef("..HEAD")); + assertNull(ScopeRefRange.selectedRef("...HEAD")); + assertNull(ScopeRefRange.selectedRef("HEAD")); + assertNull(ScopeRefRange.selectedRef("main")); + assertNull(ScopeRefRange.selectedRef(null)); + } + + @Test + public void stripsSupportedRangesOnly() { + assertEquals("main", ScopeRefRange.stripRange("main...HEAD")); + assertEquals("main", ScopeRefRange.stripRange("main..HEAD")); + assertEquals("main", ScopeRefRange.stripRange("main")); + assertEquals("v1.0..v2.0", ScopeRefRange.stripRange("v1.0..v2.0")); + } +} diff --git a/frontend/src/main/java/gitscope/frontend/GutterRenderingService.java b/frontend/src/main/java/gitscope/frontend/GutterRenderingService.java index 14717e0..aa96df6 100644 --- a/frontend/src/main/java/gitscope/frontend/GutterRenderingService.java +++ b/frontend/src/main/java/gitscope/frontend/GutterRenderingService.java @@ -61,7 +61,7 @@ private static final class RendererInfo { public GutterRenderingService(Project project) { this.project = project; this.gutterDataService = project.getService(GutterDataService.class); - LOG.info("GutterRenderingService created, registering as listener on GutterDataService"); + LOG.debug("GutterRenderingService created, registering as listener on GutterDataService"); this.gutterDataService.addListener(this); this.messageBusConnection = project.getMessageBus().connect(); @@ -102,7 +102,9 @@ public void fileClosed(@NotNull FileEditorManager source, @NotNull VirtualFile f @Override public void onDataUpdated(@NotNull String filePath, @NotNull GutterDataService.GutterFileData data) { if (disposed.get()) return; - LOG.info("GutterRenderingService.onDataUpdated: file=" + filePath + ", ranges=" + data.ranges.size()); + if (LOG.isDebugEnabled()) { + LOG.debug("GutterRenderingService.onDataUpdated: file=" + filePath + ", ranges=" + data.ranges.size()); + } ApplicationManager.getApplication().invokeLater(() -> { if (disposed.get()) return; @@ -114,14 +116,15 @@ public void onDataUpdated(@NotNull String filePath, @NotNull GutterDataService.G Document doc = editor.getDocument(); VirtualFile file = FileDocumentManager.getInstance().getFile(doc); if (file != null && file.getPath().equals(filePath)) { - LOG.info("GutterRenderingService: found editor for " + filePath + ", updating renderer"); + LOG.debug("GutterRenderingService: found editor for " + filePath + ", updating renderer"); updateRenderer(doc, file, data); found = true; break; } } if (!found) { - LOG.info("GutterRenderingService: NO editor found for " + filePath); + // Routine: data is published for every changed file, most of which are not open. + LOG.debug("GutterRenderingService: NO editor found for " + filePath); } }, ModalityState.defaultModalityState(), __ -> disposed.get()); } @@ -160,7 +163,7 @@ private synchronized void updateRenderer(@NotNull Document document, @NotNull Vi RendererInfo info = renderers.get(document); if (info == null) { - LOG.info("GutterRenderingService.updateRenderer: CREATING new renderer for " + file.getPath()); + LOG.debug("GutterRenderingService.updateRenderer: CREATING new renderer for " + file.getPath()); ScopeLineStatusMarkerRenderer renderer = new ScopeLineStatusMarkerRenderer( project, document, file, this); info = new RendererInfo(renderer, data.baseContent); @@ -177,7 +180,9 @@ private synchronized void updateRenderer(@NotNull Document document, @NotNull Vi info.scopeRanges = data.scopeRanges; info.renderer.setVcsBaseContent(data.baseContent); info.renderer.updateRanges(data.ranges); - LOG.info("GutterRenderingService.updateRenderer: applied " + data.ranges.size() + " ranges to " + file.getPath()); + if (LOG.isDebugEnabled()) { + LOG.debug("GutterRenderingService.updateRenderer: applied " + data.ranges.size() + " ranges to " + file.getPath()); + } } private DocumentListener createDocumentListener(@NotNull Document document, @NotNull RendererInfo info) { @@ -229,7 +234,9 @@ private void recalculateRangesAsync(@NotNull Document document, @NotNull Rendere } }, ModalityState.defaultModalityState()); } catch (Exception e) { - LOG.error("Error recalculating ranges", e); + // Runs on every document change; a failure here costs one stale repaint, and the next + // keystroke retries. Not worth an error report. + LOG.debug("Error recalculating ranges", e); } } @@ -342,16 +349,18 @@ private void emitScopeSegment(List result, } } + // Teardown races with the platform disposing editors and documents underneath us, so failures + // here are expected and already recovered from by dropping the renderer. private synchronized void releaseRenderer(@NotNull Document document) { RendererInfo info = renderers.remove(document); if (info != null) { if (info.documentListener != null) { try { document.removeDocumentListener(info.documentListener); } - catch (Exception e) { LOG.warn("Error removing document listener", e); } + catch (Exception e) { LOG.debug("Error removing document listener", e); } } if (info.renderer != null) { try { info.renderer.dispose(); } - catch (Exception e) { LOG.warn("Error disposing renderer", e); } + catch (Exception e) { LOG.debug("Error disposing renderer", e); } } } } @@ -361,7 +370,7 @@ private void releaseAllRenderers() { RendererInfo info = entry.getValue(); if (info != null && info.renderer != null) { try { info.renderer.dispose(); } - catch (Exception e) { LOG.warn("Error disposing renderer", e); } + catch (Exception e) { LOG.debug("Error disposing renderer", e); } } } renderers.clear(); diff --git a/frontend/src/main/java/gitscope/frontend/actions/TabContextActions.kt b/frontend/src/main/java/gitscope/frontend/actions/TabContextActions.kt new file mode 100644 index 0000000..4f6a6f4 --- /dev/null +++ b/frontend/src/main/java/gitscope/frontend/actions/TabContextActions.kt @@ -0,0 +1,132 @@ +package gitscope.frontend.actions + +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.Messages +import com.intellij.openapi.wm.ToolWindow +import com.intellij.openapi.wm.ToolWindowContextMenuActionBase +import com.intellij.platform.project.ProjectId +import com.intellij.platform.project.projectId +import com.intellij.ui.content.Content +import com.intellij.ui.content.ContentManager +import kotlinx.coroutines.launch +import rpc.FrontendTabStateService +import rpc.TabMoveDirection +import rpc.UtilRpcApi +import system.Defs + +/** + * Tab context-menu actions for the Git Scope tool window, registered on the frontend. + * + *

Registered here for both modes. In split mode the tab strip is rendered by the frontend, so + * its context menu is built from the frontend's action registry and backend-registered actions + * never appear in it — which is why renaming, resetting and moving tabs were all missing over + * Remote Development. Monolith loads this module too, so one registration serves both and there is + * no second implementation to keep in step. + * + *

The tool window, its ContentManager and the scope models are backend state, so these actions + * decide enablement from what is visible here (tab index and label) and delegate the operation over + * [UtilRpcApi] to TabActionService, which owns the rules and re-validates every request. + */ +sealed class TabContextAction : ToolWindowContextMenuActionBase() { + + final override fun update(e: AnActionEvent, toolWindow: ToolWindow, content: Content?) { + e.presentation.isEnabledAndVisible = false + + if (e.project == null || toolWindow.id != Defs.TOOL_WINDOW_NAME || content == null) return + + val contentManager = toolWindow.contentManager + val index = contentManager.getIndexOfContent(content) + if (index < 0) return + + e.presentation.isEnabledAndVisible = isEnabled(e.project!!, contentManager, index, content) + } + + final override fun actionPerformed(e: AnActionEvent, toolWindow: ToolWindow, content: Content?) { + val project = e.project ?: return + if (content == null) return + + val index = toolWindow.contentManager.getIndexOfContent(content) + if (index < 0) return + + perform(project, index, content) + } + + protected abstract fun isEnabled(project: Project, contentManager: ContentManager, index: Int, content: Content): Boolean + + protected abstract fun perform(project: Project, index: Int, content: Content) + + /** Tab is neither the HEAD tab (index 0) nor the trailing "+" tab. */ + protected fun isRegularTab(index: Int, content: Content): Boolean = + index > 0 && content.tabName != Defs.PLUS_TAB_LABEL + + protected fun sendToBackend(project: Project, description: String, call: suspend (UtilRpcApi, ProjectId) -> Unit) { + // Resolve the project id on the caller's (EDT) side, like the navigation actions do. + val projectId = project.projectId() + project.service().scope.launch { + try { + call(UtilRpcApi.getInstance(), projectId) + } catch (t: Throwable) { + LOG.warn("Tab action '$description' failed", t) + } + } + } + + companion object { + @JvmStatic + protected val LOG: Logger = Defs.getLogger(TabContextAction::class.java) + } +} + +class FrontendRenameTabAction : TabContextAction() { + override fun isEnabled(project: Project, contentManager: ContentManager, index: Int, content: Content) = + isRegularTab(index, content) + + override fun perform(project: Project, index: Int, content: Content) { + // Prompt on the frontend, where the UI is, then send only the result over RPC. + val newName = Messages.showInputDialog( + project, + "Enter new tab name:", + "Rename Tab", + Messages.getQuestionIcon(), + content.displayName, + null + ) + if (newName.isNullOrEmpty()) return + + sendToBackend(project, "rename") { api, pid -> api.renameTab(pid, index, newName) } + } +} + +class FrontendResetTabNameAction : TabContextAction() { + // Whether a tab carries a custom name is backend model state, mirrored by + // FrontendTabStateService. While that state is still unknown the action stays enabled and the + // backend no-ops if there is nothing to reset. + override fun isEnabled(project: Project, contentManager: ContentManager, index: Int, content: Content) = + isRegularTab(index, content) && + project.service().hasCustomNameOrUnknown(index) + + override fun perform(project: Project, index: Int, content: Content) { + sendToBackend(project, "reset name") { api, pid -> api.resetTabName(pid, index) } + } +} + +class FrontendMoveTabLeftAction : TabContextAction() { + override fun isEnabled(project: Project, contentManager: ContentManager, index: Int, content: Content) = + isRegularTab(index, content) && index > 1 + + override fun perform(project: Project, index: Int, content: Content) { + sendToBackend(project, "move left") { api, pid -> api.moveTab(pid, index, TabMoveDirection.LEFT) } + } +} + +class FrontendMoveTabRightAction : TabContextAction() { + override fun isEnabled(project: Project, contentManager: ContentManager, index: Int, content: Content) = + isRegularTab(index, content) && index < contentManager.contentCount - 2 + + override fun perform(project: Project, index: Int, content: Content) { + sendToBackend(project, "move right") { api, pid -> api.moveTab(pid, index, TabMoveDirection.RIGHT) } + } +} diff --git a/frontend/src/main/java/implementation/gutter/ScopeDiffViewer.kt b/frontend/src/main/java/implementation/gutter/ScopeDiffViewer.kt index f8f15cf..856ff50 100644 --- a/frontend/src/main/java/implementation/gutter/ScopeDiffViewer.kt +++ b/frontend/src/main/java/implementation/gutter/ScopeDiffViewer.kt @@ -64,7 +64,7 @@ class ScopeDiffViewer( // Show diff in a dialog DiffManager.getInstance().showDiff(project, request) } catch (e: Exception) { - LOG.error("Error showing diff for range", e) + LOG.warn("Error showing diff for range", e) } } @@ -83,7 +83,7 @@ class ScopeDiffViewer( fun getVcsContentForRange(range: Range, includeContext: Boolean = true): String { val baseContent = vcsBaseContent if (baseContent == null) { - if (range.type == Range.DELETED || range.type == Range.MODIFIED) LOG.warn("VCS base content not available") + if (range.type == Range.DELETED || range.type == Range.MODIFIED) LOG.debug("VCS base content not available") return "" } val lines = baseContent.split("\n") diff --git a/frontend/src/main/java/implementation/gutter/ScopeGutterHighlighterManager.kt b/frontend/src/main/java/implementation/gutter/ScopeGutterHighlighterManager.kt index af94e30..518693b 100644 --- a/frontend/src/main/java/implementation/gutter/ScopeGutterHighlighterManager.kt +++ b/frontend/src/main/java/implementation/gutter/ScopeGutterHighlighterManager.kt @@ -60,7 +60,7 @@ internal class ScopeGutterHighlighterManager( try { if (highlighter.isValid) markupModel?.removeHighlighter(highlighter) } catch (e: Exception) { - LOG.warn("Error removing highlighter", e) + LOG.debug("Error removing highlighter", e) } } gutterHighlighter = null @@ -68,7 +68,7 @@ internal class ScopeGutterHighlighterManager( try { if (highlighter.isValid) markupModel?.removeHighlighter(highlighter) } catch (e: Exception) { - LOG.warn("Error removing error stripe highlighter", e) + LOG.debug("Error removing error stripe highlighter", e) } } errorStripeHighlighters.clear() @@ -81,7 +81,7 @@ internal class ScopeGutterHighlighterManager( if (editor is EditorEx) editor.gutterComponentEx.repaint() } } catch (e: Exception) { - LOG.warn("Error repainting gutter", e) + LOG.debug("Error repainting gutter", e) } } @@ -94,7 +94,7 @@ internal class ScopeGutterHighlighterManager( ?.removeHighlighter(old) } } catch (e: Exception) { - LOG.warn("Error removing old highlighter", e) + LOG.debug("Error removing old highlighter", e) } } try { @@ -109,7 +109,7 @@ internal class ScopeGutterHighlighterManager( rh.editorFilter = MarkupEditorFilterFactory.createIsNotDiffFilter() } } catch (e: Exception) { - LOG.error("Error creating gutter highlighter", e) + LOG.warn("Error creating gutter highlighter", e) } } @@ -160,7 +160,7 @@ internal class ScopeGutterHighlighterManager( try { if (highlighter.isValid) markupModel.removeHighlighter(highlighter) } catch (e: Exception) { - LOG.warn("Error removing error stripe highlighter", e) + LOG.debug("Error removing error stripe highlighter", e) } } errorStripeHighlighters.clear() @@ -190,7 +190,7 @@ internal class ScopeGutterHighlighterManager( } errorStripeHighlighters.add(highlighter) } catch (e: Exception) { - LOG.warn("Error creating error stripe highlighter for range $range", e) + LOG.debug("Error creating error stripe highlighter for range $range", e) } } } diff --git a/frontend/src/main/java/implementation/gutter/ScopeGutterPopupPanel.kt b/frontend/src/main/java/implementation/gutter/ScopeGutterPopupPanel.kt index b718555..010b08d 100644 --- a/frontend/src/main/java/implementation/gutter/ScopeGutterPopupPanel.kt +++ b/frontend/src/main/java/implementation/gutter/ScopeGutterPopupPanel.kt @@ -341,7 +341,7 @@ internal class ScopeGutterPopupPanel( } catch (e: com.intellij.openapi.progress.ProcessCanceledException) { throw e } catch (e: Exception) { - LOG.warn("Error computing word diff for editor highlights", e) + LOG.debug("Error computing word diff for editor highlights", e) } } @@ -445,7 +445,7 @@ internal class ScopeGutterPopupPanel( } catch (e: com.intellij.openapi.progress.ProcessCanceledException) { throw e } catch (e: Exception) { - LOG.warn("Error applying word diff highlighting", e) + LOG.debug("Error applying word diff highlighting", e) } } } @@ -546,7 +546,7 @@ internal class ScopeGutterPopupPanel( } LOG.debug("Successfully rolled back range at line ${range.line1}") } catch (e: Exception) { - LOG.error("Error rolling back range", e) + LOG.warn("Error rolling back range", e) } } } diff --git a/frontend/src/main/java/rpc/FrontendGutterListeners.kt b/frontend/src/main/java/rpc/FrontendGutterListeners.kt index c490d20..0f5fe9b 100644 --- a/frontend/src/main/java/rpc/FrontendGutterListeners.kt +++ b/frontend/src/main/java/rpc/FrontendGutterListeners.kt @@ -7,26 +7,65 @@ import com.intellij.openapi.startup.ProjectActivity import com.intellij.platform.project.projectId import fleet.rpc.client.durable import implementation.gutter.Range +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import service.GutterDataService import settings.GitScopeSettings +import system.Defs @Service(Service.Level.PROJECT) class FrontendGutterSubscriptions( private val project: Project, private val coroutineScope: CoroutineScope ) { + companion object { + private val LOG = Defs.getLogger(FrontendGutterSubscriptions::class.java) + + /** + * Delay before re-subscribing after the stream ended without an RPC failure. Two ways here: + * the backend answered with an empty flow because it does not know the project yet (a race + * won by slow backends — the subscription used to die silently for good in that case), or a + * non-RPC error escaped, which `durable` deliberately does not retry. + */ + private const val RESUBSCRIBE_DELAY_MS = 5_000L + } + init { // Only subscribe to gutter updates via RPC in split mode — in monolith, // the frontend GutterDataService IS the backend GutterDataService (same instance), // so re-publishing would cause an infinite loop. if (!com.intellij.platform.ide.productMode.IdeProductMode.isMonolith) { coroutineScope.launch { - durable { - GutterRpcApi.getInstance() - .getGutterUpdates(project.projectId()) - .collect { event -> handleEvent(event) } + while (isActive) { + try { + durable { + GutterRpcApi.getInstance() + .getGutterUpdates(project.projectId()) + .collect { event -> + // One bad event must not kill the subscription: the stream is + // the only source of gutter data, so letting an exception + // propagate here would leave the gutter permanently empty. + try { + handleEvent(event) + } catch (e: CancellationException) { + throw e + } catch (t: Throwable) { + LOG.warn("Failed to apply gutter update, skipping event", t) + } + } + } + LOG.debug("Gutter update stream completed, re-subscribing") + } catch (e: CancellationException) { + throw e + } catch (t: Throwable) { + // durable already retries RPC-level failures with its own backoff; anything + // arriving here is unexpected, so log it visibly and keep the subscription alive. + LOG.warn("Gutter update subscription failed, re-subscribing", t) + } + delay(RESUBSCRIBE_DELAY_MS) } } } @@ -39,10 +78,34 @@ class FrontendGutterSubscriptions( val dto = event.data gds.scopeDisplayName = dto.scopeDisplayName GitScopeSettings.getInstance().isSeparateGutterRendering = dto.separateGutterRendering + + // Contents are omitted when unchanged since the backend's last message for this + // file; the previously published data then carries them. + val baseContent: String + val headContent: String? + if (dto.contentsIncluded) { + baseContent = dto.baseContent + ?: run { + LOG.warn("Gutter update for ${dto.filePath} claims contents but carries none, skipping") + return + } + headContent = dto.headContent + } else { + val cached = gds.getData(dto.filePath) + if (cached == null) { + // Should not happen: the backend only omits contents it already sent within + // this subscription, and clears that memory together with AllCleared. + LOG.warn("Gutter update for ${dto.filePath} references cached contents that are missing, skipping") + return + } + baseContent = cached.baseContent + headContent = cached.headContent + } + val data = GutterDataService.GutterFileData( dto.ranges.map { Range(it.line1, it.line2, it.vcsLine1, it.vcsLine2) }, - dto.baseContent, - dto.headContent, + baseContent, + headContent, dto.scopeRanges?.map { Range(it.line1, it.line2, it.vcsLine1, it.vcsLine2) } ) gds.publish(dto.filePath, data) diff --git a/frontend/src/main/java/rpc/FrontendTabStateService.kt b/frontend/src/main/java/rpc/FrontendTabStateService.kt new file mode 100644 index 0000000..7f7d253 --- /dev/null +++ b/frontend/src/main/java/rpc/FrontendTabStateService.kt @@ -0,0 +1,107 @@ +package rpc + +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.project.Project +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.startup.ProjectActivity +import com.intellij.openapi.wm.ToolWindowManager +import com.intellij.platform.project.projectId +import fleet.rpc.client.durable +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import system.Defs + +/** + * Mirrors the backend's renamed tabs — tab index -> the branch-based name the tab would revert to. + * + *

Serves two purposes. "Reset Tab Name" is disabled for tabs with nothing to reset, and action + * update() cannot suspend, so the answer has to be available synchronously — hence a pushed + * StateFlow cached here rather than a request per menu build, which would also put a network + * round-trip in the way of opening a context menu. + * + *

And the original name is applied as the tab's tooltip here, on the frontend's own Content + * objects. The backend sets it too, but in split mode the tab strip is rendered against frontend + * contents, and the description written on the backend copy never reaches the label. In monolith + * both sides share the same Content, so this is simply an idempotent re-set. + * + *

Until the first value arrives the answer is "unknown", and callers treat unknown as enabled: + * the worst case is then an action that runs and finds nothing to reset, never one that is missing. + */ +@Service(Service.Level.PROJECT) +class FrontendTabStateService( + private val project: Project, + private val coroutineScope: CoroutineScope +) { + @Volatile + private var renamedTabs: Map? = null + + init { + coroutineScope.launch { + while (isActive) { + try { + durable { + UtilRpcApi.getInstance() + .getRenamedTabs(project.projectId()) + .collect { tabs -> + LOG.debug("Renamed tabs: $tabs") + renamedTabs = tabs + applyTooltips(tabs) + } + } + } catch (e: CancellationException) { + throw e + } catch (t: Throwable) { + LOG.warn("Tab state subscription failed, re-subscribing", t) + } + // Fall back to "unknown" so the menu stays usable while disconnected. + renamedTabs = null + delay(RESUBSCRIBE_DELAY_MS) + } + } + } + + /** True when the tab is known to have a custom name, or when the state is not known yet. */ + fun hasCustomNameOrUnknown(tabIndex: Int): Boolean { + val known = renamedTabs ?: return true + return tabIndex in known + } + + /** + * Shows the original name as the tooltip of each renamed tab, and clears it elsewhere. The tab + * label reads Content.getDescription(), and the tool window content UI refreshes it on the + * resulting property change. + */ + private fun applyTooltips(tabs: Map) { + ApplicationManager.getApplication().invokeLater { + if (project.isDisposed) return@invokeLater + + val toolWindow = ToolWindowManager.getInstance(project).getToolWindow(Defs.TOOL_WINDOW_NAME) + ?: return@invokeLater // not created yet; the next publish re-applies + val contentManager = toolWindow.contentManager + + for (index in 0 until contentManager.contentCount) { + val content = contentManager.getContent(index) ?: continue + val tooltip = tabs[index] + if (content.description != tooltip) { + content.description = tooltip + } + } + } + } + + companion object { + private val LOG: Logger = Defs.getLogger(FrontendTabStateService::class.java) + private const val RESUBSCRIBE_DELAY_MS = 5_000L + } +} + +class FrontendTabStateStartup : ProjectActivity { + override suspend fun execute(project: Project) { + project.service() + } +} diff --git a/frontend/src/main/resources/gitscope.frontend.xml b/frontend/src/main/resources/gitscope.frontend.xml index ee3ef56..f776e98 100644 --- a/frontend/src/main/resources/gitscope.frontend.xml +++ b/frontend/src/main/resources/gitscope.frontend.xml @@ -8,10 +8,12 @@ + + @@ -35,5 +37,28 @@ class="gitscope.frontend.actions.ShowDiffAction" text="Show Diff" description="Show the Git Scope diff for the current file in an editor tab"/> + + + + + + + + + + + + + + diff --git a/gradle.properties b/gradle.properties index 421bd6f..eb84dd0 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=2026.2 +pluginVersion=2026.2.1 pluginSinceBuild=261 platformType=IU diff --git a/shared/src/main/java/rpc/GutterTopics.kt b/shared/src/main/java/rpc/GutterTopics.kt index 1dd9ebc..4b7cf58 100644 --- a/shared/src/main/java/rpc/GutterTopics.kt +++ b/shared/src/main/java/rpc/GutterTopics.kt @@ -14,9 +14,17 @@ data class GutterRangeDto( data class GutterFileDataDto( val filePath: String, val ranges: List, - val baseContent: String, + /** + * Full contents of the diff base / HEAD revision. Contents dominate the message size (the + * ranges are a handful of ints), so the backend includes them only when they changed since the + * last message for this file within the same subscription; the frontend caches the previous + * values and reuses them. [contentsIncluded] distinguishes "unchanged, reuse cache" from a + * genuinely null [headContent]. + */ + val baseContent: String? = null, val headContent: String? = null, val scopeRanges: List? = null, val scopeDisplayName: String = "", - val separateGutterRendering: Boolean = false + val separateGutterRendering: Boolean = false, + val contentsIncluded: Boolean = true ) diff --git a/shared/src/main/java/rpc/UtilRpcApi.kt b/shared/src/main/java/rpc/UtilRpcApi.kt index 0e0a307..5c5fc2c 100644 --- a/shared/src/main/java/rpc/UtilRpcApi.kt +++ b/shared/src/main/java/rpc/UtilRpcApi.kt @@ -23,6 +23,13 @@ enum class ChangeNavDirection { PREVIOUS_FILE } +/** Direction for moving a scope tab within the tool window. */ +@Serializable +enum class TabMoveDirection { + LEFT, + RIGHT +} + @Rpc interface UtilRpcApi : RemoteApi { suspend fun getCommands(projectId: ProjectId): Flow @@ -54,6 +61,34 @@ interface UtilRpcApi : RemoteApi { */ suspend fun showDiff(projectId: ProjectId, currentFilePath: String?) + /** + * Tab operations for the tool window's tab context menu, addressed by tab index. + * + *

In split mode the tab strip is rendered by the frontend, so the context menu is built from + * the frontend's action registry — backend-registered actions never appear in it. The frontend + * actions therefore delegate here, because the tool window, its ContentManager and the scope + * models all live on the backend. All three are no-ops when the index does not address a + * renameable/movable tab, so the backend stays authoritative over the rules. + */ + suspend fun renameTab(projectId: ProjectId, tabIndex: Int, newName: String) + + suspend fun resetTabName(projectId: ProjectId, tabIndex: Int) + + suspend fun moveTab(projectId: ProjectId, tabIndex: Int, direction: TabMoveDirection) + + /** + * Renamed tabs, as tab index -> the branch-based name the tab would revert to. Only tabs with a + * custom name appear, so presence answers "can this be reset?" and the value is the tooltip to + * show on the renamed tab. + * + *

Both are backend model state the frontend cannot reach, and action update() cannot suspend, + * so the backend pushes them. A [kotlinx.coroutines.flow.StateFlow]: a new subscriber + * immediately receives the current map, and it is republished whenever it can have changed -- + * after a rename, reset or move, and after a change collection, which is when repositories + * first become resolvable and the branch names stop being empty. + */ + suspend fun getRenamedTabs(projectId: ProjectId): Flow> + companion object { suspend fun getInstance(): UtilRpcApi { return RemoteApiProviderService.resolve(remoteApiDescriptor()) diff --git a/shared/src/main/java/service/GutterDataService.java b/shared/src/main/java/service/GutterDataService.java index f0412b5..9b97ed9 100644 --- a/shared/src/main/java/service/GutterDataService.java +++ b/shared/src/main/java/service/GutterDataService.java @@ -78,17 +78,19 @@ public GutterDataService(Project project) { public void publish(@NotNull String filePath, @NotNull GutterFileData data) { fileDataMap.put(filePath, data); - LOG.info("GutterDataService.publish: file=" + filePath + - ", ranges=" + data.ranges.size() + - ", listeners=" + listeners.size() + - ", hasBaseContent=" + (data.baseContent != null && !data.baseContent.isEmpty()) + - ", hasHeadContent=" + (data.headContent != null)); + if (LOG.isDebugEnabled()) { + LOG.debug("GutterDataService.publish: file=" + filePath + + ", ranges=" + data.ranges.size() + + ", listeners=" + listeners.size() + + ", hasBaseContent=" + (data.baseContent != null && !data.baseContent.isEmpty()) + + ", hasHeadContent=" + (data.headContent != null)); + } for (Listener l : listeners) l.onDataUpdated(filePath, data); } public void clear(@NotNull String filePath) { fileDataMap.remove(filePath); - LOG.info("GutterDataService.clear: file=" + filePath); + LOG.debug("GutterDataService.clear: file=" + filePath); for (Listener l : listeners) l.onDataCleared(filePath); } @@ -103,6 +105,7 @@ public void clearAll() { * without waiting for the next scope/document update. */ public void republishAll() { + LOG.debug("GutterDataService.republishAll: files=" + fileDataMap.size() + ", listeners=" + listeners.size()); for (Map.Entry e : fileDataMap.entrySet()) { for (Listener l : listeners) l.onDataUpdated(e.getKey(), e.getValue()); } @@ -126,7 +129,7 @@ public void setScopeDisplayName(@NotNull String name) { public void addListener(@NotNull Listener listener) { listeners.add(listener); - LOG.info("GutterDataService.addListener: " + listener.getClass().getSimpleName() + ", total=" + listeners.size()); + LOG.debug("GutterDataService.addListener: " + listener.getClass().getSimpleName() + ", total=" + listeners.size()); } public void removeListener(@NotNull Listener listener) { diff --git a/shared/src/main/java/system/Defs.java b/shared/src/main/java/system/Defs.java index 3a7dede..d6367eb 100644 --- a/shared/src/main/java/system/Defs.java +++ b/shared/src/main/java/system/Defs.java @@ -10,6 +10,12 @@ public class Defs { public static String TOOL_WINDOW_NAME = "Git Scope"; public static Icon ICON = AllIcons.Actions.Diff; + /** + * Label of the trailing "add a scope" tab. Lives here rather than in ViewService because the + * frontend needs it too, to recognise the special tabs in the tab context menu. + */ + public static final String PLUS_TAB_LABEL = "+"; + /** * Global logger category for Git Scope plugin. * To enable debug logging for all Git Scope components, add this to Debug Log Settings: