Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 <selection>...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
Expand Down
1 change: 1 addition & 0 deletions backend/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,5 @@ dependencies {

implementation(project(":shared"))
compileOnly("com.google.code.gson:gson:2.14.0")
testImplementation("junit:junit:4.13.2")
}
164 changes: 118 additions & 46 deletions backend/src/main/java/implementation/compare/ChangesService.java

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Change> scopeChangesMap, Map<String, Change> 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();

Expand All @@ -111,7 +119,13 @@ public void update(Map<String, Change> scopeChangesMap, Map<String, Change> 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<String, UpdateInfo> updates = new ConcurrentHashMap<>();
CountDownLatch latch = new CountDownLatch(editorsToUpdate.size());
Expand Down Expand Up @@ -184,15 +198,17 @@ private UpdateInfo prepareUpdateForEditor(Editor editor, Map<String, Change> 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;
}

Expand All @@ -213,10 +229,13 @@ private UpdateInfo prepareUpdateForEditor(Editor editor, Map<String, Change> 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) {
Expand All @@ -228,7 +247,7 @@ private UpdateInfo prepareUpdateForEditor(Editor editor, Map<String, Change> 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());
}
}
}
Expand All @@ -248,13 +267,15 @@ private UpdateInfo prepareUpdateForEditor(Editor editor, Map<String, Change> 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();
}

Expand Down
54 changes: 50 additions & 4 deletions backend/src/main/java/listener/MyBulkFileListener.java
Original file line number Diff line number Diff line change
@@ -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<? extends VFileEvent> events) {
if (events.isEmpty()) return;
Expand All @@ -19,11 +29,47 @@ public void after(@NotNull List<? extends VFileEvent> 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<? extends VFileEvent> events) {
List<GitRepository> 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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
43 changes: 40 additions & 3 deletions backend/src/main/java/listener/MyTabContentListener.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -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<Content> 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) {
Expand Down Expand Up @@ -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());
}
});
}
Expand All @@ -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());
}
}
9 changes: 5 additions & 4 deletions backend/src/main/java/listener/MyTreeSelectionListener.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down
9 changes: 4 additions & 5 deletions backend/src/main/java/model/MyModel.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
Loading