From 9f6bb87cb15f8af63a1214ec35e2823816dcf1de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20W=C3=A5llberg?= Date: Sun, 31 May 2026 21:12:44 +0200 Subject: [PATCH 01/16] feat: add remote development (split mode) support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure the plugin into a modular architecture (shared/backend/ frontend) for JetBrains Remote Development split mode compatibility, following the official IntelliJ Platform Modular Plugin guidelines. Module structure: - shared: GutterDataService, GitScopeSettings, @Rpc interface, DTOs, Range/RangesBuilder - backend: VCS operations, change collection, line status tracking, tool window, actions - frontend: gutter rendering, editor interactions, popup UI, file-open data pickup - root: plugin.xml with module declarations only Split mode compliance (per JetBrains docs): - Module descriptors in src/main/resources/ (not META-INF) - Backend XML declares dependencies on intellij.platform.backend, kernel.backend, shared - Frontend XML declares dependencies on intellij.platform.frontend, shared - Shared XML declares dependency on intellij.platform.rpc - Content modules use required-if-available for strict loading - splitMode=true and pluginInstallationTarget=BOTH at build level - @Rpc interface with RemoteApi in shared, RemoteApiProvider in backend - Frontend uses durable {} for automatic RPC reconnection - rpc compiler plugin (fleet rpc-compiler-plugin 2.3.20-0.1) applied to all modules - Backend RPC replays all current state on subscription - Monolithic mode guard: frontend skips RPC, uses GutterDataService directly RPC communication: - GutterRpcApi defines getGutterUpdates(ProjectId): Flow - GutterUpdateEvent is a sealed class: DataUpdated / DataCleared / AllCleared - BackendGutterRpcImpl uses callbackFlow — registers listener before replay to avoid race condition - FrontendGutterSubscriptions subscribes with durable {} in split mode only - GutterFileDataDto, GutterRangeDto are @Serializable DTOs Dependency changes: - Remove RxJava — replaced with CopyOnWriteArrayList listener pattern in MyModel and ViewService - Remove Gson runtime — compileOnly (platform provides it) - No external runtime dependencies in any module (classloader isolation safe) Startup initialization: - ViewService.initLater() selects saved tab with listener suppressed (tabInitializationInProgress), then calls setActiveModel() once - MyTabContentListener checks isTabInitializationInProgress() to avoid double-firing - GutterRenderingService registers fileOpened handler to pick up existing data on editor switch Gutter popup improvements: - Close popup on mouse wheel scroll and caret movement - Keep popup open during prev/next change navigation (suppressCaretCancel flag) Other fixes: - Guard null changes in ToolWindowView.render() - GutterDataService.getAllData() for RPC replay - SharedReflection stripped to getGutterArea() only - pluginInstallationTarget replaces deprecated splitModeTarget Fixes comod/git-scope-pro#91 --- CHANGELOG.md | 6 + CLASSES.md | 143 +++- REFACTORING.md | 176 +++++ backend/build.gradle.kts | 26 + .../compare/ChangesService.java | 4 + .../GitScopeFileStatusProvider.java | 0 .../MyLineStatusTrackerImpl.java | 411 ++++++++++ .../implementation/scope/MyPackageSet.java | 0 .../java/implementation/scope/MyScope.java | 0 .../implementation/scope/MyScopeInTarget.java | 0 .../scope/MyScopeNameSupplier.java | 0 .../java/listener/BackendStartupActivity.java | 32 + .../java/listener/MyBulkFileListener.java | 0 .../java/listener/MyChangeListListener.java | 0 .../listener/MyDynamicPluginListener.java | 0 .../listener/MyFileEditorManagerListener.java | 0 .../MyGitRepositoryChangeListener.java | 0 .../java/listener/MyTabContentListener.java | 4 + .../java/listener/MyToolWindowListener.java | 0 .../listener/MyTreeSelectionListener.java | 0 .../main/java/listener/ToggleHeadAction.java | 0 .../java/listener/VcsContextMenuAction.java | 0 .../src}/main/java/listener/VcsStartup.java | 0 .../src}/main/java/model/Debounce.java | 0 .../src}/main/java/model/MyModel.java | 31 +- .../src}/main/java/model/MyModelBase.java | 0 .../src}/main/java/model/TargetBranchMap.java | 0 .../src/main/java/rpc/BackendGutterRpcImpl.kt | 63 ++ .../src}/main/java/service/GitService.java | 0 .../main/java/service/StatusBarService.java | 0 .../java/service/TargetBranchService.java | 0 .../main/java/service/ToolWindowService.java | 0 .../service/ToolWindowServiceInterface.java | 0 .../src}/main/java/service/ViewService.java | 58 +- .../settings/GitScopeSettingsComponent.java | 10 +- .../GitScopeSettingsConfigurable.java | 10 + .../main/java/state/MyModelConverter.java | 0 .../src}/main/java/state/State.java | 0 .../java/state/WindowPositionTracker.java | 0 .../main/java/statusBar/MyStatusBarPanel.java | 0 .../java/statusBar/MyStatusBarWidget.java | 0 .../statusBar/MyStatusBarWidgetFactory.java | 0 .../java/toolwindow/BranchSelectView.java | 0 .../main/java/toolwindow/TabOperations.java | 0 .../java/toolwindow/ToolWindowUIFactory.java | 0 .../main/java/toolwindow/ToolWindowView.java | 16 +- .../main/java/toolwindow/VcsTreeActions.java | 0 .../toolwindow/actions/RenameTabAction.java | 0 .../actions/ResetTabNameAction.java | 0 .../toolwindow/actions/TabMoveActions.java | 0 .../java/toolwindow/elements/BranchTree.java | 0 .../toolwindow/elements/BranchTreeEntry.java | 0 .../toolwindow/elements/CurrentBranch.java | 0 .../elements/MySimpleChangesBrowser.java | 0 .../toolwindow/elements/TargetBranch.java | 0 .../java/toolwindow/elements/VcsTree.java | 0 .../src}/main/java/utils/CustomRollback.java | 0 .../src}/main/java/utils/GitUtil.java | 0 .../java/utils/PlatformApiReflection.java | 192 ++--- .../src/main/resources/gitscope.backend.xml | 81 ++ build.gradle.kts | 76 +- frontend/build.gradle.kts | 22 + .../frontend/GutterRenderingService.java | 388 ++++++++++ .../frontend/GutterRenderingStartup.java | 22 + .../gutter/LineStatusGutterMarkerRenderer.kt | 2 +- .../implementation/gutter/ScopeDiffViewer.kt | 0 .../gutter/ScopeGutterHighlighterManager.kt | 0 .../gutter/ScopeGutterPopupPanel.kt | 26 +- .../gutter/ScopeLineStatusMarkerRenderer.kt | 0 .../main/java/rpc/FrontendGutterListeners.kt | 59 ++ .../src/main/resources/gitscope.frontend.xml | 13 + gradle.properties | 6 +- settings.gradle.kts | 6 + shared/build.gradle.kts | 20 + .../main/java/implementation/gutter/Range.kt | 0 .../implementation/gutter/RangesBuilder.kt | 0 shared/src/main/java/rpc/GutterRpcApi.kt | 32 + shared/src/main/java/rpc/GutterTopics.kt | 22 + .../main/java/service/GutterDataService.java | 124 ++++ .../main/java/settings/GitScopeSettings.java | 0 .../src}/main/java/system/Defs.java | 0 .../src}/main/java/utils/Notification.java | 0 .../src/main/java/utils/SharedReflection.java | 74 ++ shared/src/main/resources/gitscope.shared.xml | 10 + .../MyLineStatusTrackerImpl.java | 702 ------------------ src/main/resources/META-INF/plugin.xml | 85 +-- 86 files changed, 1905 insertions(+), 1047 deletions(-) create mode 100644 REFACTORING.md create mode 100644 backend/build.gradle.kts rename {src => backend/src}/main/java/implementation/compare/ChangesService.java (98%) rename {src => backend/src}/main/java/implementation/fileStatus/GitScopeFileStatusProvider.java (100%) create mode 100644 backend/src/main/java/implementation/lineStatusTracker/MyLineStatusTrackerImpl.java rename {src => backend/src}/main/java/implementation/scope/MyPackageSet.java (100%) rename {src => backend/src}/main/java/implementation/scope/MyScope.java (100%) rename {src => backend/src}/main/java/implementation/scope/MyScopeInTarget.java (100%) rename {src => backend/src}/main/java/implementation/scope/MyScopeNameSupplier.java (100%) create mode 100644 backend/src/main/java/listener/BackendStartupActivity.java rename {src => backend/src}/main/java/listener/MyBulkFileListener.java (100%) rename {src => backend/src}/main/java/listener/MyChangeListListener.java (100%) rename {src => backend/src}/main/java/listener/MyDynamicPluginListener.java (100%) rename {src => backend/src}/main/java/listener/MyFileEditorManagerListener.java (100%) rename {src => backend/src}/main/java/listener/MyGitRepositoryChangeListener.java (100%) rename {src => backend/src}/main/java/listener/MyTabContentListener.java (95%) rename {src => backend/src}/main/java/listener/MyToolWindowListener.java (100%) rename {src => backend/src}/main/java/listener/MyTreeSelectionListener.java (100%) rename {src => backend/src}/main/java/listener/ToggleHeadAction.java (100%) rename {src => backend/src}/main/java/listener/VcsContextMenuAction.java (100%) rename {src => backend/src}/main/java/listener/VcsStartup.java (100%) rename {src => backend/src}/main/java/model/Debounce.java (100%) rename {src => backend/src}/main/java/model/MyModel.java (90%) rename {src => backend/src}/main/java/model/MyModelBase.java (100%) rename {src => backend/src}/main/java/model/TargetBranchMap.java (100%) create mode 100644 backend/src/main/java/rpc/BackendGutterRpcImpl.kt rename {src => backend/src}/main/java/service/GitService.java (100%) rename {src => backend/src}/main/java/service/StatusBarService.java (100%) rename {src => backend/src}/main/java/service/TargetBranchService.java (100%) rename {src => backend/src}/main/java/service/ToolWindowService.java (100%) rename {src => backend/src}/main/java/service/ToolWindowServiceInterface.java (100%) rename {src => backend/src}/main/java/service/ViewService.java (96%) rename {src => backend/src}/main/java/settings/GitScopeSettingsComponent.java (88%) rename {src => backend/src}/main/java/settings/GitScopeSettingsConfigurable.java (87%) rename {src => backend/src}/main/java/state/MyModelConverter.java (100%) rename {src => backend/src}/main/java/state/State.java (100%) rename {src => backend/src}/main/java/state/WindowPositionTracker.java (100%) rename {src => backend/src}/main/java/statusBar/MyStatusBarPanel.java (100%) rename {src => backend/src}/main/java/statusBar/MyStatusBarWidget.java (100%) rename {src => backend/src}/main/java/statusBar/MyStatusBarWidgetFactory.java (100%) rename {src => backend/src}/main/java/toolwindow/BranchSelectView.java (100%) rename {src => backend/src}/main/java/toolwindow/TabOperations.java (100%) rename {src => backend/src}/main/java/toolwindow/ToolWindowUIFactory.java (100%) rename {src => backend/src}/main/java/toolwindow/ToolWindowView.java (87%) rename {src => backend/src}/main/java/toolwindow/VcsTreeActions.java (100%) rename {src => backend/src}/main/java/toolwindow/actions/RenameTabAction.java (100%) rename {src => backend/src}/main/java/toolwindow/actions/ResetTabNameAction.java (100%) rename {src => backend/src}/main/java/toolwindow/actions/TabMoveActions.java (100%) rename {src => backend/src}/main/java/toolwindow/elements/BranchTree.java (100%) rename {src => backend/src}/main/java/toolwindow/elements/BranchTreeEntry.java (100%) rename {src => backend/src}/main/java/toolwindow/elements/CurrentBranch.java (100%) rename {src => backend/src}/main/java/toolwindow/elements/MySimpleChangesBrowser.java (100%) rename {src => backend/src}/main/java/toolwindow/elements/TargetBranch.java (100%) rename {src => backend/src}/main/java/toolwindow/elements/VcsTree.java (100%) rename {src => backend/src}/main/java/utils/CustomRollback.java (100%) rename {src => backend/src}/main/java/utils/GitUtil.java (100%) rename {src => backend/src}/main/java/utils/PlatformApiReflection.java (54%) create mode 100644 backend/src/main/resources/gitscope.backend.xml create mode 100644 frontend/build.gradle.kts create mode 100644 frontend/src/main/java/gitscope/frontend/GutterRenderingService.java create mode 100644 frontend/src/main/java/gitscope/frontend/GutterRenderingStartup.java rename {src => frontend/src}/main/java/implementation/gutter/LineStatusGutterMarkerRenderer.kt (99%) rename {src => frontend/src}/main/java/implementation/gutter/ScopeDiffViewer.kt (100%) rename {src => frontend/src}/main/java/implementation/gutter/ScopeGutterHighlighterManager.kt (100%) rename {src => frontend/src}/main/java/implementation/gutter/ScopeGutterPopupPanel.kt (94%) rename {src => frontend/src}/main/java/implementation/gutter/ScopeLineStatusMarkerRenderer.kt (100%) create mode 100644 frontend/src/main/java/rpc/FrontendGutterListeners.kt create mode 100644 frontend/src/main/resources/gitscope.frontend.xml create mode 100644 shared/build.gradle.kts rename {src => shared/src}/main/java/implementation/gutter/Range.kt (100%) rename {src => shared/src}/main/java/implementation/gutter/RangesBuilder.kt (100%) create mode 100644 shared/src/main/java/rpc/GutterRpcApi.kt create mode 100644 shared/src/main/java/rpc/GutterTopics.kt create mode 100644 shared/src/main/java/service/GutterDataService.java rename {src => shared/src}/main/java/settings/GitScopeSettings.java (100%) rename {src => shared/src}/main/java/system/Defs.java (100%) rename {src => shared/src}/main/java/utils/Notification.java (100%) create mode 100644 shared/src/main/java/utils/SharedReflection.java create mode 100644 shared/src/main/resources/gitscope.shared.xml delete mode 100644 src/main/java/implementation/lineStatusTracker/MyLineStatusTrackerImpl.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 86aee39..c3b8a3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [2026.2] + +### Added + +- Added [support for remote development](https://github.com/comod/git-scope-pro/issues/91) + ## [2026.1.4] ### Added diff --git a/CLASSES.md b/CLASSES.md index fb53186..e55a3b3 100644 --- a/CLASSES.md +++ b/CLASSES.md @@ -3,25 +3,31 @@ This document lists all plugin classes to search for when analyzing heap dumps to identify memory leaks after plugin unload. All classes should have **count = 0** after successful plugin unload. -## Services +Classes are organized by module: **backend**, **frontend**, and **shared**. + +--- + +## Backend Module + +### Services *Expected: 1 instance per project, or 0 after unload* - `service.ViewService` - `service.ToolWindowService` -- `service.ToolWindowServiceInterface` *(interface - unlikely to leak but check if implemented by plugin classes)* +- `service.ToolWindowServiceInterface` *(interface)* - `service.StatusBarService` - `service.GitService` - `service.TargetBranchService` - `implementation.compare.ChangesService` -## State/Persistence +### State/Persistence - `state.State` - `state.MyModelConverter` - `state.WindowPositionTracker` -## Listeners +### Listeners *Expected: 0 after unload* @@ -37,9 +43,9 @@ unload. All classes should have **count = 0** after successful plugin unload. - `listener.ToggleHeadAction` - `listener.VcsContextMenuAction` -## UI Components +### UI Components -### Main Components +#### Main Components - `toolwindow.ToolWindowView` - `toolwindow.ToolWindowUIFactory` @@ -48,13 +54,14 @@ unload. All classes should have **count = 0** after successful plugin unload. - `toolwindow.VcsTreeActions` #### Actions + - `toolwindow.actions.TabMoveActions` - `toolwindow.actions.TabMoveActions$MoveTabLeft` - `toolwindow.actions.TabMoveActions$MoveTabRight` - `toolwindow.actions.RenameTabAction` - `toolwindow.actions.ResetTabNameAction` -### UI Elements +#### UI Elements - `toolwindow.elements.VcsTree` - `toolwindow.elements.BranchTree` @@ -63,71 +70,125 @@ unload. All classes should have **count = 0** after successful plugin unload. - `toolwindow.elements.CurrentBranch` - `toolwindow.elements.TargetBranch` -## Status Bar +### Status Bar - `statusBar.MyStatusBarWidget` - `statusBar.MyStatusBarWidgetFactory` - `statusBar.MyStatusBarPanel` -## Models +### Models - `model.MyModel` -- `model.MyModel$field` *(enum - check for RxJava subscription leaks)* +- `model.MyModel$field` *(enum)* - `model.MyModelBase` - `model.TargetBranchMap` - `model.Debounce` -## Implementation Classes +### Implementation Classes -### Line Status Tracker +#### Line Status Tracker - `implementation.lineStatusTracker.MyLineStatusTrackerImpl` -### Gutter Rendering - -- `implementation.gutter.Range` -- `implementation.gutter.RangesBuilder` -- `implementation.gutter.LineStatusGutterMarkerRenderer` -- `implementation.gutter.ScopeLineStatusMarkerRenderer` -- `implementation.gutter.ScopeOverviewMarkerRenderer` -- `implementation.gutter.ScopeDiffViewer` - -### Scope +#### Scope - `implementation.scope.MyScope` - `implementation.scope.MyPackageSet` *(registered with NamedScopeManager - critical leak if not unregistered)* - `implementation.scope.MyScopeInTarget` - `implementation.scope.MyScopeNameSupplier` -### File Status +#### File Status - `implementation.fileStatus.GitScopeFileStatusProvider` -## Settings +### Settings (Backend UI) -- `settings.GitScopeSettings` - `settings.GitScopeSettingsComponent` - `settings.GitScopeSettingsConfigurable` -## Utility Classes +### RPC + +- `rpc.BackendGutterRpcImpl` +- `rpc.BackendGutterRpcProvider` + +### Utility Classes - `utils.CustomRollback` - `utils.GitUtil` -- `utils.Notification` - `utils.PlatformApiReflection` + +--- + +## Frontend Module + +### Services + +- `gitscope.frontend.GutterRenderingService` +- `gitscope.frontend.GutterRenderingStartup` + +### Gutter Rendering + +- `implementation.gutter.LineStatusGutterMarkerRenderer` +- `implementation.gutter.ScopeLineStatusMarkerRenderer` +- `implementation.gutter.ScopeDiffViewer` +- `implementation.gutter.ScopeGutterHighlighterManager` +- `implementation.gutter.ScopeGutterPopupPanel` + +### RPC + +- `rpc.FrontendGutterSubscriptions` +- `rpc.FrontendGutterSubscriptionsStartup` + +--- + +## Shared Module + +### Services + +- `service.GutterDataService` +- `service.GutterDataService$GutterFileData` +- `service.GutterDataService$Listener` *(interface)* + +### Gutter Data Model + +- `implementation.gutter.Range` +- `implementation.gutter.RangesBuilder` + +### RPC Interface & DTOs + +- `rpc.GutterRpcApi` +- `rpc.GutterUpdateEvent` +- `rpc.GutterUpdateEvent$DataUpdated` +- `rpc.GutterUpdateEvent$DataCleared` +- `rpc.GutterUpdateEvent$AllCleared` +- `rpc.GutterFileDataDto` +- `rpc.GutterRangeDto` + +### Settings + +- `settings.GitScopeSettings` + +### System + - `system.Defs` +### Utility Classes + +- `utils.SharedReflection` +- `utils.Notification` + +--- + ## Anonymous/Inner Classes to Look For *These are patterns - search for classes matching these names:* -- `TabOperations$1` *(rename action)* -- `TabOperations$2` *(reset action)* -- `TabOperations$3` *(move left action)* -- `TabOperations$4` *(move right action)* -- `VcsTree$$Lambda` *(any lambda from VcsTree)* +- `ToolWindowView$listener` *(Consumer stored as field)* +- `ViewService$modelListeners` *(HashMap of model → Consumer listeners)* - `MyLineStatusTrackerImpl$$Lambda` *(lambdas from line status tracker)* - `ScopeLineStatusMarkerRenderer$$Lambda` *(lambdas from gutter renderer)* +- `GutterRenderingService$$Lambda` *(lambdas from rendering service)* +- `BackendGutterRpcImpl$$Lambda` *(callbackFlow listener)* - `MySimpleChangesBrowser$1` *(anonymous MouseAdapter)* - `BranchTree$MyColoredTreeCellRenderer` - Any class ending with `$$Lambda$...` @@ -149,6 +210,9 @@ Filter the HPROF classes view using these prefixes: - `statusBar.` - `settings.` - `utils.` +- `rpc.` +- `gitscope.frontend.` +- `system.` ### 2. Filter the Classes View @@ -161,12 +225,15 @@ Filter the HPROF classes view using these prefixes: *Most likely to leak:* 1. **All listeners** - Must be unregistered -2. **TabOperations and its anonymous classes** - Actions must be unregistered -3. **ToolWindowView** - UI components must be disposed -4. **ViewService** - RxJava subscriptions must be disposed -5. **MyLineStatusTrackerImpl** - Background tasks must be cancelled -6. **ScopeLineStatusMarkerRenderer** - Highlighters and mouse listeners must be removed -7. **Any class with `$` in the name** - Anonymous/inner classes often capture outer references +2. **ToolWindowView** - UI components must be disposed, model listener removed +3. **ViewService** - Model listeners must be removed in dispose() +4. **MyLineStatusTrackerImpl** - Background tasks must be cancelled +5. **ScopeLineStatusMarkerRenderer** - Highlighters and mouse listeners must be removed +6. **GutterRenderingService** - Must remove itself from GutterDataService listeners +7. **BackendGutterRpcImpl** - callbackFlow listener removed on awaitClose +8. **FrontendGutterSubscriptions** - Coroutine scope cancelled on service dispose +9. **GutterDataService** - Listener list and file data map cleared on dispose +10. **Any class with `$` in the name** - Anonymous/inner classes often capture outer references --- diff --git a/REFACTORING.md b/REFACTORING.md new file mode 100644 index 0000000..72d7b0c --- /dev/null +++ b/REFACTORING.md @@ -0,0 +1,176 @@ +# Remote Development Refactoring + +This document describes the plugin's modular architecture for JetBrains Remote Development +(Split Mode), following the official IntelliJ Platform Modular Plugin guidelines. + +## Project Structure + +``` +git-scope-pro/ +├── src/main/resources/META-INF/plugin.xml ← root descriptor (metadata + only) +├── shared/ ← loaded everywhere (backend + frontend) +├── backend/ ← loaded on backend (or monolithic IDE) +├── frontend/ ← loaded on frontend (or monolithic IDE) +├── build.gradle.kts ← root build (pluginModule references) +└── settings.gradle.kts ← includes shared, backend, frontend +``` + +## Split Mode Compliance + +The plugin follows the JetBrains modular plugin guidelines: + +- **Module descriptors** in `src/main/resources/` (not META-INF): `gitscope.shared.xml`, `gitscope.backend.xml`, + `gitscope.frontend.xml` +- **Root plugin.xml** contains only `` module declarations — no extensions, actions, or listeners +- **Backend XML** declares dependencies on `intellij.platform.backend`, `intellij.platform.kernel.backend`, + `gitscope.shared`, `Git4Idea` +- **Frontend XML** declares dependencies on `intellij.platform.frontend`, `gitscope.shared` +- **Shared XML** declares dependency on `intellij.platform.rpc` +- **Content modules** use `required-if-available` for strict loading validation +- **`splitMode = true`** and **`pluginInstallationTarget = BOTH`** declared at `intellijPlatform` level +- **`rpc` compiler plugin** (fleet rpc-compiler-plugin 2.3.20-0.1) applied to all modules +- **No external runtime dependencies** in any module (classloader isolation safe) + +## RPC Communication + +### Interface (`shared/src/main/java/rpc/GutterRpcApi.kt`) + +```kotlin +@Rpc +interface GutterRpcApi : RemoteApi { + suspend fun getGutterUpdates(projectId: ProjectId): Flow +} +``` + +`GutterUpdateEvent` is a `@Serializable` sealed class with variants: `DataUpdated`, `DataCleared`, `AllCleared`. + +### Backend (`backend/src/main/java/rpc/BackendGutterRpcImpl.kt`) + +- Implements `GutterRpcApi` using `callbackFlow` +- Registers listener on `GutterDataService` BEFORE replaying existing data (no race condition) +- Registered via `com.intellij.platform.rpc.backend.remoteApiProvider` extension point + +### Frontend (`frontend/src/main/java/rpc/FrontendGutterListeners.kt`) + +- Subscribes with `durable {}` for automatic reconnection on network errors +- Only activates in split mode (`!IdeProductMode.isMonolith`) to avoid feedback loops +- Publishes received data to the frontend's local `GutterDataService` + +### DTOs (`shared/src/main/java/rpc/GutterTopics.kt`) + +`@Serializable` data classes: `GutterFileDataDto`, `GutterRangeDto` — contain file path, line ranges, base/head content, +scope display name, and settings state. + +## Data Flow + +### Monolithic IDE + +``` +MyLineStatusTrackerImpl → GutterDataService.publish() + → GutterRenderingService (direct listener, same JVM) +``` + +RPC subscription is skipped. Frontend gets data directly from the shared `GutterDataService`. + +### Split Mode (Remote Development) + +``` +Backend JVM: + MyLineStatusTrackerImpl → GutterDataService.publish() + → BackendGutterRpcImpl (callbackFlow listener) → Flow ── RPC ──→ + +Frontend JVM: + ← durable { GutterRpcApi.getInstance().getGutterUpdates() } + → FrontendGutterSubscriptions → GutterDataService.publish() + → GutterRenderingService → ScopeLineStatusMarkerRenderer → paint() +``` + +On subscription (or reconnection), the backend replays all current gutter data so the frontend never misses state. + +## Dependency Changes + +| Dependency | Before | After | Reason | +|---------------------------------|------------------|--------------------------|---------------------------------------------------------------------------------------------------| +| RxJava (`io.reactivex.rxjava3`) | `implementation` | **Removed** | Replaced with `CopyOnWriteArrayList` listener pattern. Module classloaders can't see `lib/` JARs. | +| Gson (`com.google.code.gson`) | `implementation` | `compileOnly` | Platform bundles Gson; only needed for compilation of `MyModelConverter`. | +| `rpc` compiler plugin | — | Applied to all modules | Required for `@Rpc` interface codegen (`remoteApiDescriptor`). | +| `intellij.platform.rpc` | — | Shared module dependency | Provides `RemoteApi`, `@Rpc`, `RemoteApiProviderService` classes. | + +## Startup Initialization + +`ViewService.initLater()` handles deterministic tab activation: + +1. `initTabsSequentially()` creates all tabs (HEAD + saved models) +2. `tabInitializationInProgress = true` — suppresses `MyTabContentListener` +3. `selectTabByIndex(savedTabIndex)` — selects the saved tab without side effects +4. `tabInitializationInProgress = false` +5. `setActiveModel()` — fires exactly once, triggers `collectChanges()` for the correct scope + +This prevents the generation counter invalidation that occurred when `selectTabByIndex` triggered the listener, causing +a double `setActiveModel()` → double `incrementUpdate()` → first `collectChanges` result discarded. + +## Gutter Popup Behavior + +The gutter diff popup (`ScopeGutterPopupPanel`) closes on: + +- **Mouse wheel scroll** — `MouseWheelListener` on editor scroll pane +- **Caret movement** — `CaretListener` (covers keyboard scrolling: arrows, Page Up/Down) +- **Mouse click in editor** — existing `EditorMouseListener` + +The popup stays open during **prev/next change navigation** — a `suppressCaretCancel` flag is set during programmatic +`moveToLogicalPosition` + `scrollToCaret` calls. + +## File Moves + +All existing source files moved from `src/main/java/` into subprojects. No packages renamed. + +### → `backend/src/main/java/` + +All VCS, service, model, listener, toolwindow, settings, state, and statusBar packages. + +### → `frontend/src/main/java/` + +Gutter rendering: `ScopeDiffViewer.kt`, `ScopeGutterHighlighterManager.kt`, `ScopeGutterPopupPanel.kt`, +`ScopeLineStatusMarkerRenderer.kt`, `LineStatusGutterMarkerRenderer.kt`. + +### → `shared/src/main/java/` + +`Range.kt`, `RangesBuilder.kt`, `GitScopeSettings.java`, `Defs.java`, `Notification.java`. + +## New Files + +| File | Purpose | +|---------------------------------------------------------------------------------------|------------------------------------------------------------------------------------| +| `shared/src/main/java/service/GutterDataService.java` | In-process bridge: holds per-file gutter data, listener pattern for pub/sub | +| `shared/src/main/java/rpc/GutterRpcApi.kt` | `@Rpc` interface for cross-process gutter data streaming | +| `shared/src/main/java/rpc/GutterTopics.kt` | `@Serializable` DTOs for RPC transport | +| `shared/src/main/java/utils/SharedReflection.java` | Reflection bridge for `getGutterArea()` (internal API) | +| `backend/src/main/java/rpc/BackendGutterRpcImpl.kt` | RPC implementation + `RemoteApiProvider` registration | +| `backend/src/main/java/implementation/lineStatusTracker/MyLineStatusTrackerImpl.java` | Rewritten: computes ranges on background threads, publishes to `GutterDataService` | +| `frontend/src/main/java/gitscope/frontend/GutterRenderingService.java` | Manages renderers per document, listens to `GutterDataService` | +| `frontend/src/main/java/gitscope/frontend/GutterRenderingStartup.java` | Eager init of `GutterRenderingService` | +| `frontend/src/main/java/rpc/FrontendGutterListeners.kt` | RPC subscription with `durable {}`, publishes to frontend `GutterDataService` | + +## Module Dependencies (Gradle) + +``` +root build.gradle.kts: + pluginModule(implementation(project(":shared"))) + pluginModule(implementation(project(":backend"))) + pluginModule(implementation(project(":frontend"))) + +backend/build.gradle.kts: + intellijPlatform: intellijIdea, Git4Idea, intellij.platform.backend, kernel.backend, rpc.backend + implementation(project(":shared")) + compileOnly("com.google.code.gson:gson:2.13.2") + +frontend/build.gradle.kts: + intellijPlatform: intellijIdea, intellij.platform.frontend + implementation(project(":shared")) + +shared/build.gradle.kts: + intellijPlatform: intellijIdea, intellij.platform.rpc +``` + +All modules apply: `org.jetbrains.intellij.platform.module`, `java`, `org.jetbrains.kotlin.jvm`, +`org.jetbrains.kotlin.plugin.serialization`, `rpc`. diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts new file mode 100644 index 0000000..bccfa34 --- /dev/null +++ b/backend/build.gradle.kts @@ -0,0 +1,26 @@ +plugins { + id("org.jetbrains.intellij.platform.module") + id("java") + id("org.jetbrains.kotlin.jvm") + id("org.jetbrains.kotlin.plugin.serialization") + id("rpc") +} + +repositories { + intellijPlatform { + defaultRepositories() + } +} + +dependencies { + intellijPlatform { + intellijIdea(providers.gradleProperty("platformVersion")) + bundledPlugins(providers.gradleProperty("platformBundledPlugins").map { it.split(',') }) + bundledModule("intellij.platform.backend") + bundledModule("intellij.platform.kernel.backend") + bundledModule("intellij.platform.rpc.backend") + } + + implementation(project(":shared")) + compileOnly("com.google.code.gson:gson:2.13.2") +} diff --git a/src/main/java/implementation/compare/ChangesService.java b/backend/src/main/java/implementation/compare/ChangesService.java similarity index 98% rename from src/main/java/implementation/compare/ChangesService.java rename to backend/src/main/java/implementation/compare/ChangesService.java index fc851de..adaebfb 100644 --- a/src/main/java/implementation/compare/ChangesService.java +++ b/backend/src/main/java/implementation/compare/ChangesService.java @@ -173,6 +173,8 @@ public void run(@NotNull ProgressIndicator indicator) { _localChanges.add(change); } } + } catch (com.intellij.openapi.progress.ProcessCanceledException e) { + throw e; } catch (Exception e) { // Catch any unexpected errors from individual repo processing // This ensures one bad repo doesn't crash the entire operation @@ -404,6 +406,8 @@ public RepoChangesResult doCollectChanges(Project project, GitRepository repo, S // Log VCS errors (e.g., locked files, git command failures) but don't fail entirely LOG.warn("Error collecting changes for repository " + repo.getRoot().getPath() + ": " + e.getMessage()); return new RepoChangesResult(new ArrayList<>(), new ArrayList<>(), new ArrayList<>()); + } catch (com.intellij.openapi.progress.ProcessCanceledException e) { + throw e; } catch (Exception e) { // Catch any other unexpected errors (e.g., file system issues) LOG.warn("Unexpected error collecting changes for repository " + repo.getRoot().getPath(), e); diff --git a/src/main/java/implementation/fileStatus/GitScopeFileStatusProvider.java b/backend/src/main/java/implementation/fileStatus/GitScopeFileStatusProvider.java similarity index 100% rename from src/main/java/implementation/fileStatus/GitScopeFileStatusProvider.java rename to backend/src/main/java/implementation/fileStatus/GitScopeFileStatusProvider.java diff --git a/backend/src/main/java/implementation/lineStatusTracker/MyLineStatusTrackerImpl.java b/backend/src/main/java/implementation/lineStatusTracker/MyLineStatusTrackerImpl.java new file mode 100644 index 0000000..4c9691d --- /dev/null +++ b/backend/src/main/java/implementation/lineStatusTracker/MyLineStatusTrackerImpl.java @@ -0,0 +1,411 @@ +package implementation.lineStatusTracker; + +import com.intellij.openapi.Disposable; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.application.ModalityState; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.editor.Document; +import com.intellij.openapi.editor.Editor; +import com.intellij.openapi.editor.EditorFactory; +import com.intellij.openapi.editor.EditorKind; +import com.intellij.openapi.fileEditor.FileDocumentManager; +import com.intellij.openapi.fileEditor.FileEditorManager; +import com.intellij.openapi.fileEditor.FileEditorManagerListener; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.Computable; +import com.intellij.openapi.util.Disposer; +import com.intellij.openapi.util.text.StringUtil; +import com.intellij.openapi.vcs.VcsException; +import com.intellij.openapi.vcs.changes.Change; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.util.concurrency.SequentialTaskExecutor; +import com.intellij.util.messages.MessageBusConnection; +import implementation.gutter.Range; +import implementation.gutter.RangesBuilder; +import org.jetbrains.annotations.NotNull; +import service.GutterDataService; +import system.Defs; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Backend-only service that computes line-level diff ranges for scope changes + * and publishes them to {@link GutterDataService} for rendering on the frontend. + */ +public class MyLineStatusTrackerImpl implements Disposable { + private static final Logger LOG = Defs.getLogger(MyLineStatusTrackerImpl.class); + + private final Project project; + private final GutterDataService gutterDataService; + private MessageBusConnection messageBusConnection; + private final AtomicBoolean disposing = new AtomicBoolean(false); + private final ExecutorService updateExecutor = + SequentialTaskExecutor.createSequentialApplicationPoolExecutor("ScopeGutterUpdate"); + private final AtomicLong updateGeneration = new AtomicLong(0); + + private static class DisposalToken { + volatile boolean disposed = false; + } + private final DisposalToken disposalToken = new DisposalToken(); + + // Track which documents we've published data for (so we can clear them) + private final Set publishedFiles = ConcurrentHashMap.newKeySet(); + + @Override + public void dispose() { + disposalToken.disposed = true; + updateExecutor.shutdownNow(); + releaseAll(); + } + + public MyLineStatusTrackerImpl(Project project, Disposable parentDisposable) { + this.project = project; + this.gutterDataService = project.getService(GutterDataService.class); + + this.messageBusConnection = project.getMessageBus().connect(); + messageBusConnection.subscribe( + FileEditorManagerListener.FILE_EDITOR_MANAGER, + new FileEditorManagerListener() { + @Override + public void fileClosed(@NotNull FileEditorManager source, @NotNull VirtualFile file) { + String path = file.getPath(); + if (publishedFiles.remove(path)) { + gutterDataService.clear(path); + } + } + } + ); + + Disposer.register(parentDisposable, this); + } + + /** + * Updates line status data for all editors based on scope changes. + * Computes ranges on background threads and publishes results to GutterDataService. + */ + public void update(Map scopeChangesMap, Map localChangesMap) { + if (scopeChangesMap == null || disposing.get()) return; + + final DisposalToken token = this.disposalToken; + final long gen = updateGeneration.incrementAndGet(); + + updateExecutor.execute(() -> { + if (token.disposed || updateGeneration.get() != gen) return; + + Editor[] editors = EditorFactory.getInstance().getAllEditors(); + + List editorsToUpdate = new ArrayList<>(); + for (Editor editor : editors) { + if (editor.getEditorKind() == EditorKind.DIFF) continue; + Document doc = editor.getDocument(); + VirtualFile file = FileDocumentManager.getInstance().getFile(doc); + if (file == null) continue; + String filePath = file.getPath(); + if (scopeChangesMap.containsKey(filePath) || publishedFiles.contains(filePath)) { + editorsToUpdate.add(editor); + } + } + + if (editorsToUpdate.isEmpty()) return; + + Map updates = new ConcurrentHashMap<>(); + CountDownLatch latch = new CountDownLatch(editorsToUpdate.size()); + + for (Editor editor : editorsToUpdate) { + ApplicationManager.getApplication().executeOnPooledThread(() -> { + try { + if (!token.disposed) { + UpdateInfo info = prepareUpdateForEditor(editor, scopeChangesMap, localChangesMap); + if (info != null) { + updates.put(info.filePath, info); + } + } + } finally { + latch.countDown(); + } + }); + } + + try { + latch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + + if (!updates.isEmpty() && !token.disposed) { + List updateList = new ArrayList<>(updates.values()); + ApplicationManager.getApplication().invokeLater(() -> { + if (!token.disposed) { + publishBatchedUpdates(updateList); + } + }, ModalityState.defaultModalityState(), __ -> token.disposed); + } + }); + } + + private static class UpdateInfo { + final String filePath; + final String baseContent; + final String headContent; + final List precomputedRanges; + final List scopeRanges; + + UpdateInfo(String filePath, String baseContent, String headContent, + List ranges, List scopeRanges) { + this.filePath = filePath; + this.baseContent = baseContent; + this.headContent = headContent; + this.precomputedRanges = ranges; + this.scopeRanges = scopeRanges; + } + } + + private UpdateInfo prepareUpdateForEditor(Editor editor, Map scopeChangesMap, + Map localChangesMap) { + if (editor == null || disposing.get()) return null; + + Document doc = editor.getDocument(); + VirtualFile file = FileDocumentManager.getInstance().getFile(doc); + if (file == null) return null; + + String filePath = file.getPath(); + Change changeForFile = scopeChangesMap.get(filePath); + boolean hasLocalChanges = (localChangesMap != null && localChangesMap.containsKey(filePath)); + + String baseContent; + 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")); + + try { + baseContent = changeForFile.getBeforeRevision().getContent(); + } catch (VcsException e) { + LOG.warn("Error getting content for revision: " + filePath, e); + baseContent = null; + } + + if (baseContent == null) { + baseContent = ApplicationManager.getApplication().runReadAction( + (Computable) () -> doc.getCharsSequence().toString()); + } + + currentContent = ApplicationManager.getApplication().runReadAction( + (Computable) () -> doc.getImmutableCharSequence().toString()); + } else { + baseContent = ApplicationManager.getApplication().runReadAction( + (Computable) () -> doc.getCharsSequence().toString()); + currentContent = baseContent; + LOG.debug("MyLineStatusTrackerImpl - File: " + filePath + ", no scope change - clearing markers"); + } + + 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); + + String headContent = null; + if (hasLocalChanges) { + Change localChange = localChangesMap.get(filePath); + if (localChange != null && localChange.getBeforeRevision() != null) { + try { + headContent = localChange.getBeforeRevision().getContent(); + if (headContent != null) { + headContent = StringUtil.convertLineSeparators(headContent); + } + } catch (VcsException e) { + LOG.warn("MyLineStatusTrackerImpl - Error caching HEAD content: " + e.getMessage()); + } + } + } + + List ranges; + List scopeRanges = null; + try { + if (headContent != null) { + ranges = computeScopeRangesInCurrentSpace(headContent, normalizedBase, normalizedCurrent, filePath); + scopeRanges = RangesBuilder.INSTANCE.createRanges(headContent, normalizedBase); + } else { + 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()); + } + } catch (Exception e) { + LOG.error("Error precomputing ranges for: " + filePath, e); + ranges = Collections.emptyList(); + } + + return new UpdateInfo(filePath, normalizedBase, headContent, ranges, scopeRanges); + } + + private void publishBatchedUpdates(List updates) { + for (UpdateInfo update : updates) { + if (disposing.get()) break; + GutterDataService.GutterFileData data = new GutterDataService.GutterFileData( + update.precomputedRanges, update.baseContent, update.headContent, update.scopeRanges); + gutterDataService.publish(update.filePath, data); + publishedFiles.add(update.filePath); + } + } + + // --- Range computation (unchanged algorithms) --- + + private List computeScopeRangesInCurrentSpace( + String headContent, String normalizedBase, String currentContent, String filePath) { + + List scopeRanges = RangesBuilder.INSTANCE.createRanges(headContent, normalizedBase); + LOG.debug("computeScopeRangesInCurrentSpace [" + filePath + "] scope ranges: " + scopeRanges.size()); + if (scopeRanges.isEmpty()) return Collections.emptyList(); + + List localRanges = RangesBuilder.INSTANCE.createRanges(currentContent, headContent); + LOG.debug("computeScopeRangesInCurrentSpace [" + filePath + "] local ranges: " + localRanges.size()); + if (localRanges.isEmpty()) return new ArrayList<>(scopeRanges); + + return mapScopeRangesToCurrentSpace(scopeRanges, localRanges, filePath); + } + + private List mapScopeRangesToCurrentSpace( + List scopeRanges, List localRanges, String filePath) { + + List result = new ArrayList<>(); + int numLocals = localRanges.size(); + int cumulativeDelta = 0; + int localIdx = 0; + + for (Range scope : scopeRanges) { + int headStart = scope.getLine1(); + int headEnd = scope.getLine2(); + int vcsStart = scope.getVcsLine1(); + int vcsEnd = scope.getVcsLine2(); + + while (localIdx < numLocals && localRanges.get(localIdx).getVcsLine2() <= headStart) { + Range local = localRanges.get(localIdx); + cumulativeDelta += (local.getLine2() - local.getLine1()) + - (local.getVcsLine2() - local.getVcsLine1()); + localIdx++; + } + + if (headStart == headEnd) { + boolean insideLocal = false; + for (int i = localIdx; i < numLocals; i++) { + Range local = localRanges.get(i); + if (local.getVcsLine1() > headStart) break; + if (local.getVcsLine2() > headStart) { insideLocal = true; break; } + } + if (!insideLocal) { + int pos = headStart + cumulativeDelta; + result.add(new Range(pos, pos, vcsStart, vcsEnd)); + LOG.debug("mapScopeRangesToCurrentSpace [" + filePath + + "] DELETED at current=" + pos + " vcs=[" + vcsStart + "-" + vcsEnd + "]"); + } + continue; + } + + int tempLocalIdx = localIdx; + int headCursor; + int currentCursor; + + if (tempLocalIdx < numLocals) { + Range straddle = localRanges.get(tempLocalIdx); + if (straddle.getVcsLine1() < headStart && straddle.getVcsLine2() > headStart) { + headCursor = straddle.getVcsLine2(); + currentCursor = straddle.getLine2(); + tempLocalIdx++; + } else { + headCursor = headStart; + currentCursor = headStart + cumulativeDelta; + } + } else { + headCursor = headStart; + currentCursor = headStart + cumulativeDelta; + } + + while (headCursor < headEnd) { + Range nextLocal = null; + if (tempLocalIdx < numLocals) { + Range candidate = localRanges.get(tempLocalIdx); + if (candidate.getVcsLine1() < headEnd) nextLocal = candidate; + } + + if (nextLocal != null) { + int localHeadStart = nextLocal.getVcsLine1(); + int localHeadEnd = nextLocal.getVcsLine2(); + + if (localHeadStart > headCursor) { + emitScopeSegment(result, headCursor, localHeadStart, currentCursor, + headStart, headEnd, vcsStart, vcsEnd, filePath); + currentCursor += (localHeadStart - headCursor); + } + + headCursor = Math.max(headCursor, localHeadEnd); + currentCursor = nextLocal.getLine2(); + tempLocalIdx++; + } else { + emitScopeSegment(result, headCursor, headEnd, currentCursor, + headStart, headEnd, vcsStart, vcsEnd, filePath); + headCursor = headEnd; + } + } + } + + LOG.debug("mapScopeRangesToCurrentSpace [" + filePath + + "] " + scopeRanges.size() + " scope → " + result.size() + " result ranges"); + return result; + } + + private void emitScopeSegment(List result, + int headSegStart, int headSegEnd, int currentStart, + int headBlockStart, int headBlockEnd, + int vcsBlockStart, int vcsBlockEnd, String filePath) { + int currentEnd = currentStart + (headSegEnd - headSegStart); + int headBlockLen = headBlockEnd - headBlockStart; + int segVcsStart, segVcsEnd; + + if (headBlockLen == 0) { + segVcsStart = vcsBlockStart; + segVcsEnd = vcsBlockEnd; + } else { + long vcsLen = vcsBlockEnd - vcsBlockStart; + segVcsStart = vcsBlockStart + (int) (vcsLen * (headSegStart - headBlockStart) / headBlockLen); + segVcsEnd = vcsBlockStart + (int) (vcsLen * (headSegEnd - headBlockStart) / headBlockLen); + } + + if (currentStart < currentEnd || segVcsStart < segVcsEnd) { + result.add(new Range(currentStart, currentEnd, segVcsStart, segVcsEnd)); + LOG.debug("emitScopeSegment [" + filePath + + "] current=[" + currentStart + "-" + currentEnd + + "] vcs=[" + segVcsStart + "-" + segVcsEnd + "]"); + } + } + + /** + * Release all published data and disconnect. + */ + public void releaseAll() { + if (!disposing.compareAndSet(false, true)) return; + + if (messageBusConnection != null) { + messageBusConnection.disconnect(); + messageBusConnection = null; + } + + gutterDataService.clearAll(); + publishedFiles.clear(); + } +} diff --git a/src/main/java/implementation/scope/MyPackageSet.java b/backend/src/main/java/implementation/scope/MyPackageSet.java similarity index 100% rename from src/main/java/implementation/scope/MyPackageSet.java rename to backend/src/main/java/implementation/scope/MyPackageSet.java diff --git a/src/main/java/implementation/scope/MyScope.java b/backend/src/main/java/implementation/scope/MyScope.java similarity index 100% rename from src/main/java/implementation/scope/MyScope.java rename to backend/src/main/java/implementation/scope/MyScope.java diff --git a/src/main/java/implementation/scope/MyScopeInTarget.java b/backend/src/main/java/implementation/scope/MyScopeInTarget.java similarity index 100% rename from src/main/java/implementation/scope/MyScopeInTarget.java rename to backend/src/main/java/implementation/scope/MyScopeInTarget.java diff --git a/src/main/java/implementation/scope/MyScopeNameSupplier.java b/backend/src/main/java/implementation/scope/MyScopeNameSupplier.java similarity index 100% rename from src/main/java/implementation/scope/MyScopeNameSupplier.java rename to backend/src/main/java/implementation/scope/MyScopeNameSupplier.java diff --git a/backend/src/main/java/listener/BackendStartupActivity.java b/backend/src/main/java/listener/BackendStartupActivity.java new file mode 100644 index 0000000..2deae73 --- /dev/null +++ b/backend/src/main/java/listener/BackendStartupActivity.java @@ -0,0 +1,32 @@ +package listener; + +import com.intellij.openapi.project.Project; +import com.intellij.openapi.startup.ProjectActivity; +import kotlin.Unit; +import kotlin.coroutines.Continuation; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import service.ViewService; + +/** + * Fallback startup trigger for ViewService initialization. + *

+ * In split mode (backend process), {@code ToolWindowManagerListener.RegisterToolWindow} + * fires only on the frontend process, so {@code MyToolWindowListener} never receives it + * and {@code ViewService.eventToolWindowReady()} is never called. + *

+ * This activity runs after the project is fully initialized (tool windows registered, + * VCS mappings loaded), ensuring both readiness gates are satisfied regardless of mode. + */ +public class BackendStartupActivity implements ProjectActivity { + + @Nullable + @Override + public Object execute(@NotNull Project project, @NotNull Continuation continuation) { + ViewService viewService = project.getService(ViewService.class); + if (viewService != null) { + viewService.eventToolWindowReady(); + } + return Unit.INSTANCE; + } +} diff --git a/src/main/java/listener/MyBulkFileListener.java b/backend/src/main/java/listener/MyBulkFileListener.java similarity index 100% rename from src/main/java/listener/MyBulkFileListener.java rename to backend/src/main/java/listener/MyBulkFileListener.java diff --git a/src/main/java/listener/MyChangeListListener.java b/backend/src/main/java/listener/MyChangeListListener.java similarity index 100% rename from src/main/java/listener/MyChangeListListener.java rename to backend/src/main/java/listener/MyChangeListListener.java diff --git a/src/main/java/listener/MyDynamicPluginListener.java b/backend/src/main/java/listener/MyDynamicPluginListener.java similarity index 100% rename from src/main/java/listener/MyDynamicPluginListener.java rename to backend/src/main/java/listener/MyDynamicPluginListener.java diff --git a/src/main/java/listener/MyFileEditorManagerListener.java b/backend/src/main/java/listener/MyFileEditorManagerListener.java similarity index 100% rename from src/main/java/listener/MyFileEditorManagerListener.java rename to backend/src/main/java/listener/MyFileEditorManagerListener.java diff --git a/src/main/java/listener/MyGitRepositoryChangeListener.java b/backend/src/main/java/listener/MyGitRepositoryChangeListener.java similarity index 100% rename from src/main/java/listener/MyGitRepositoryChangeListener.java rename to backend/src/main/java/listener/MyGitRepositoryChangeListener.java diff --git a/src/main/java/listener/MyTabContentListener.java b/backend/src/main/java/listener/MyTabContentListener.java similarity index 95% rename from src/main/java/listener/MyTabContentListener.java rename to backend/src/main/java/listener/MyTabContentListener.java index 4ca6728..0424382 100644 --- a/src/main/java/listener/MyTabContentListener.java +++ b/backend/src/main/java/listener/MyTabContentListener.java @@ -51,6 +51,10 @@ public void selectionChanged(@NotNull ContentManagerEvent event) { @NlsContexts.TabTitle String tabName = event.getContent().getTabName(); if (operation.equals(add)) { ViewService service = getViewService(); // Get service only when needed + + // Skip during tab initialization — ViewService handles activation itself + if (service.isTabInitializationInProgress()) return; + service.setTabIndex(event.getIndex()); if (Objects.equals(tabName, PLUS_TAB_LABEL)) { diff --git a/src/main/java/listener/MyToolWindowListener.java b/backend/src/main/java/listener/MyToolWindowListener.java similarity index 100% rename from src/main/java/listener/MyToolWindowListener.java rename to backend/src/main/java/listener/MyToolWindowListener.java diff --git a/src/main/java/listener/MyTreeSelectionListener.java b/backend/src/main/java/listener/MyTreeSelectionListener.java similarity index 100% rename from src/main/java/listener/MyTreeSelectionListener.java rename to backend/src/main/java/listener/MyTreeSelectionListener.java diff --git a/src/main/java/listener/ToggleHeadAction.java b/backend/src/main/java/listener/ToggleHeadAction.java similarity index 100% rename from src/main/java/listener/ToggleHeadAction.java rename to backend/src/main/java/listener/ToggleHeadAction.java diff --git a/src/main/java/listener/VcsContextMenuAction.java b/backend/src/main/java/listener/VcsContextMenuAction.java similarity index 100% rename from src/main/java/listener/VcsContextMenuAction.java rename to backend/src/main/java/listener/VcsContextMenuAction.java diff --git a/src/main/java/listener/VcsStartup.java b/backend/src/main/java/listener/VcsStartup.java similarity index 100% rename from src/main/java/listener/VcsStartup.java rename to backend/src/main/java/listener/VcsStartup.java diff --git a/src/main/java/model/Debounce.java b/backend/src/main/java/model/Debounce.java similarity index 100% rename from src/main/java/model/Debounce.java rename to backend/src/main/java/model/Debounce.java diff --git a/src/main/java/model/MyModel.java b/backend/src/main/java/model/MyModel.java similarity index 90% rename from src/main/java/model/MyModel.java rename to backend/src/main/java/model/MyModel.java index 5e0fdeb..8e8a709 100644 --- a/src/main/java/model/MyModel.java +++ b/backend/src/main/java/model/MyModel.java @@ -3,17 +3,18 @@ import com.intellij.openapi.vcs.changes.Change; import com.intellij.openapi.vcs.changes.ChangesUtil; import git4idea.repo.GitRepository; -import io.reactivex.rxjava3.core.Observable; -import io.reactivex.rxjava3.subjects.PublishSubject; import org.jetbrains.annotations.Nullable; import service.GitService; import java.util.Collection; import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; public class MyModel extends MyModelBase { - private final PublishSubject changeObservable = PublishSubject.create(); + private final List> listeners = new CopyOnWriteArrayList<>(); private final boolean isHeadTab; private Collection changes; // Merged changes (scope + local) private Collection scopeChanges; // Scope changes only (from target branch comparison) @@ -38,12 +39,12 @@ public boolean isHeadTab() { public void setTargetBranchMap(TargetBranchMap targetBranch) { this.targetBranchMap = targetBranch; - changeObservable.onNext(field.targetBranch); + notifyListeners(field.targetBranch); } public void addTargetBranch(GitRepository repo, String branch) { super.addTargetBranch(repo, branch); - changeObservable.onNext(field.targetBranch); + notifyListeners(field.targetBranch); } /** @@ -98,7 +99,7 @@ public String getCustomTabName() { @Override public void setCustomTabName(String customTabName) { this.customTabName = customTabName; - changeObservable.onNext(field.tabName); + notifyListeners(field.tabName); } public Collection getChanges() { @@ -108,7 +109,7 @@ public Collection getChanges() { public void setChanges(Collection changes) { this.changes = changes; this.changesMap = buildChangesByPathMap(changes); - changeObservable.onNext(field.changes); + notifyListeners(field.changes); } /** @@ -118,7 +119,7 @@ public void setChanges(Collection changes) { public void setChangesWithMap(Collection changes, Map changesMap) { this.changes = changes; this.changesMap = changesMap; - changeObservable.onNext(field.changes); + notifyListeners(field.changes); } public Collection getScopeChanges() { @@ -190,8 +191,16 @@ public static Map buildChangesByPathMap(Collection chang return changeMap; } - public Observable getObservable() { - return changeObservable; + public void addListener(Consumer listener) { + listeners.add(listener); + } + + public void removeListener(Consumer listener) { + listeners.remove(listener); + } + + private void notifyListeners(field f) { + for (Consumer l : listeners) l.accept(f); } public boolean isNew() { @@ -208,7 +217,7 @@ public boolean isActive() { public void setActive(boolean b) { if (b) { - changeObservable.onNext(field.active); + notifyListeners(field.active); } this.isActive = b; } diff --git a/src/main/java/model/MyModelBase.java b/backend/src/main/java/model/MyModelBase.java similarity index 100% rename from src/main/java/model/MyModelBase.java rename to backend/src/main/java/model/MyModelBase.java diff --git a/src/main/java/model/TargetBranchMap.java b/backend/src/main/java/model/TargetBranchMap.java similarity index 100% rename from src/main/java/model/TargetBranchMap.java rename to backend/src/main/java/model/TargetBranchMap.java diff --git a/backend/src/main/java/rpc/BackendGutterRpcImpl.kt b/backend/src/main/java/rpc/BackendGutterRpcImpl.kt new file mode 100644 index 0000000..381a2e1 --- /dev/null +++ b/backend/src/main/java/rpc/BackendGutterRpcImpl.kt @@ -0,0 +1,63 @@ +package rpc + +import com.intellij.openapi.components.service +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.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import service.GutterDataService +import settings.GitScopeSettings + +class BackendGutterRpcImpl : GutterRpcApi { + override suspend fun getGutterUpdates(projectId: ProjectId): Flow { + val project = projectId.findProjectOrNull() ?: return kotlinx.coroutines.flow.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))) + } + + override fun onDataCleared(filePath: String) { + trySend(GutterUpdateEvent.DataCleared(filePath)) + } + + override fun onAllCleared() { + trySend(GutterUpdateEvent.AllCleared) + } + } + 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))) + } + + awaitClose { 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 + ) +} + +class BackendGutterRpcProvider : RemoteApiProvider { + override fun RemoteApiProvider.Sink.remoteApis() { + remoteApi(remoteApiDescriptor()) { + BackendGutterRpcImpl() + } + } +} diff --git a/src/main/java/service/GitService.java b/backend/src/main/java/service/GitService.java similarity index 100% rename from src/main/java/service/GitService.java rename to backend/src/main/java/service/GitService.java diff --git a/src/main/java/service/StatusBarService.java b/backend/src/main/java/service/StatusBarService.java similarity index 100% rename from src/main/java/service/StatusBarService.java rename to backend/src/main/java/service/StatusBarService.java diff --git a/src/main/java/service/TargetBranchService.java b/backend/src/main/java/service/TargetBranchService.java similarity index 100% rename from src/main/java/service/TargetBranchService.java rename to backend/src/main/java/service/TargetBranchService.java diff --git a/src/main/java/service/ToolWindowService.java b/backend/src/main/java/service/ToolWindowService.java similarity index 100% rename from src/main/java/service/ToolWindowService.java rename to backend/src/main/java/service/ToolWindowService.java diff --git a/src/main/java/service/ToolWindowServiceInterface.java b/backend/src/main/java/service/ToolWindowServiceInterface.java similarity index 100% rename from src/main/java/service/ToolWindowServiceInterface.java rename to backend/src/main/java/service/ToolWindowServiceInterface.java diff --git a/src/main/java/service/ViewService.java b/backend/src/main/java/service/ViewService.java similarity index 96% rename from src/main/java/service/ViewService.java rename to backend/src/main/java/service/ViewService.java index 889bacc..a866ac7 100644 --- a/src/main/java/service/ViewService.java +++ b/backend/src/main/java/service/ViewService.java @@ -18,7 +18,6 @@ import com.intellij.util.concurrency.SequentialTaskExecutor; import implementation.compare.ChangesService; import implementation.lineStatusTracker.MyLineStatusTrackerImpl; -import io.reactivex.rxjava3.core.Observable; import model.Debounce; import model.MyModel; import model.MyModelBase; @@ -34,6 +33,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.List; +import java.util.HashMap; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; @@ -80,7 +80,7 @@ private static class DisposalToken { private Integer savedTabIndex; private final AtomicBoolean tabInitializationInProgress = new AtomicBoolean(false); private final AtomicBoolean initialFileColorsRefreshed = new AtomicBoolean(false); - private final List rxSubscriptions = new ArrayList<>(); + private final Map> modelListeners = new HashMap<>(); public ViewService(Project project) { this.project = project; @@ -107,13 +107,11 @@ public void dispose() { isDisposed = true; disposalToken.disposed = true; - // Dispose all RxJava subscriptions to break references to MyModel.field enum - for (io.reactivex.rxjava3.disposables.Disposable subscription : rxSubscriptions) { - if (subscription != null && !subscription.isDisposed()) { - subscription.dispose(); - } + // Remove all model listeners to break references + for (Map.Entry> entry : modelListeners.entrySet()) { + entry.getKey().removeListener(entry.getValue()); } - rxSubscriptions.clear(); + modelListeners.clear(); // Shutdown the debouncer scheduler FIRST to cancel pending tasks if (debouncer != null) { @@ -374,18 +372,17 @@ public void initLater() { // Initialize tabs in a synchronized manner initTabsSequentially(); - // Set the active model - setActiveModel(); - - if (savedTabIndex != null) { - this.currentTabIndex = savedTabIndex; - runAfterCurrentChangeCollection(() -> { - if (toolWindowService != null) { - toolWindowService.selectTabByIndex(savedTabIndex); - setActiveModel(); - } - }); + // Select the saved tab and activate — wrapped in flag to prevent listener from double-firing + tabInitializationInProgress.set(true); + try { + if (savedTabIndex != null) { + this.currentTabIndex = savedTabIndex; + toolWindowService.selectTabByIndex(savedTabIndex); + } + } finally { + tabInitializationInProgress.set(false); } + setActiveModel(); } public void initTabsSequentially() { @@ -522,8 +519,7 @@ public void toggleActionInvoked() { } private void subscribeToObservable(MyModel model) { - Observable observable = model.getObservable(); - io.reactivex.rxjava3.disposables.Disposable subscription = observable.subscribe(field -> { + Consumer listener = field -> { switch (field) { case targetBranch -> { // Don't update tab names during initialization - tabs are named correctly during creation @@ -579,11 +575,10 @@ private void subscribeToObservable(MyModel model) { } - }, (e -> { - })); + }; - // Track the subscription so it can be disposed when ViewService is disposed - rxSubscriptions.add(subscription); + model.addListener(listener); + modelListeners.put(model, listener); } private void getTargetBranchDisplayCurrent(Consumer callback) { @@ -888,6 +883,10 @@ public boolean isProcessingTabReorder() { return isProcessingTabReorder; } + public boolean isTabInitializationInProgress() { + return tabInitializationInProgress.get(); + } + public void setProcessingTabReorder(boolean processing) { this.isProcessingTabReorder = processing; } @@ -992,6 +991,7 @@ public void onUpdate(Collection changes) { if (token.disposed) return; updateStatusBarWidget(); + updateScopeDisplayName(); // Determine which changes to show in gutter based on IDE's VCS gutter setting Map gutterChangesMap = getGutterChangesMap(); @@ -1043,4 +1043,12 @@ private void updateStatusBarWidget() { this.statusBarService.updateText(Defs.APPLICATION_NAME + ": " + branchName); }); } + + private void updateScopeDisplayName() { + GutterDataService gds = project.getService(GutterDataService.class); + if (gds != null) { + MyModel current = getCurrent(); + gds.setScopeDisplayName(current != null ? current.getDisplayName() : ""); + } + } } \ No newline at end of file diff --git a/src/main/java/settings/GitScopeSettingsComponent.java b/backend/src/main/java/settings/GitScopeSettingsComponent.java similarity index 88% rename from src/main/java/settings/GitScopeSettingsComponent.java rename to backend/src/main/java/settings/GitScopeSettingsComponent.java index 398b83a..74519e8 100644 --- a/src/main/java/settings/GitScopeSettingsComponent.java +++ b/backend/src/main/java/settings/GitScopeSettingsComponent.java @@ -28,17 +28,17 @@ public GitScopeSettingsComponent() { ); showUntrackedFilesCheckBox = new JBCheckBox( - "See untracked files" + "Display untracked files" ); showDeletedFilesCheckBox = new JBCheckBox( - "See deleted files" + "Display deleted files" ); mainPanel = FormBuilder.createFormBuilder() .addComponent(new TitledSeparator("Gutter Rendering")) .addComponent(separateGutterRenderingCheckBox, 1) - .addTooltip("When enabled, GitScope gutter markers is rendered to the left of the line numbers. ") + .addTooltip("When enabled, Git Scope gutter markers is rendered to the left of the line numbers. ") .addVerticalGap(10) .addComponent(new TitledSeparator("File Colors")) .addComponent(scopeFileColorsCheckBox, 1) @@ -46,9 +46,9 @@ public GitScopeSettingsComponent() { .addVerticalGap(10) .addComponent(new TitledSeparator("Working Tree")) .addComponent(showUntrackedFilesCheckBox, 1) - .addTooltip("When enabled (default), untracked (unversioned) files appear in the Git Scope view") + .addTooltip("When enabled, untracked (unversioned) files appear in the Git Scope view") .addComponent(showDeletedFilesCheckBox, 1) - .addTooltip("When enabled (default), locally deleted files appear in the Git Scope view") + .addTooltip("When enabled, locally deleted files appear in the Git Scope view") .addComponentFillVertically(new JPanel(), 0) .getPanel(); diff --git a/src/main/java/settings/GitScopeSettingsConfigurable.java b/backend/src/main/java/settings/GitScopeSettingsConfigurable.java similarity index 87% rename from src/main/java/settings/GitScopeSettingsConfigurable.java rename to backend/src/main/java/settings/GitScopeSettingsConfigurable.java index 2ba9d38..3d9f0a1 100644 --- a/src/main/java/settings/GitScopeSettingsConfigurable.java +++ b/backend/src/main/java/settings/GitScopeSettingsConfigurable.java @@ -49,6 +49,7 @@ public void apply() throws ConfigurationException { boolean tabColorsChanged = settingsComponent.isScopeFileColors() != settings.isScopeFileColors(); boolean workingTreeChanged = settingsComponent.isShowUntrackedFiles() != settings.isShowUntrackedFiles() || settingsComponent.isShowDeletedFiles() != settings.isShowDeletedFiles(); + boolean separateGutterChanged = settingsComponent.isSeparateGutterRendering() != settings.isSeparateGutterRendering(); settings.setSeparateGutterRendering(settingsComponent.isSeparateGutterRendering()); settings.setScopeFileColors(settingsComponent.isScopeFileColors()); settings.setShowUntrackedFiles(settingsComponent.isShowUntrackedFiles()); @@ -67,6 +68,15 @@ public void apply() throws ConfigurationException { } } } + + if (separateGutterChanged) { + for (var project : ProjectManager.getInstance().getOpenProjects()) { + if (!project.isDisposed()) { + var gds = project.getService(service.GutterDataService.class); + if (gds != null) gds.republishAll(); + } + } + } } @Override diff --git a/src/main/java/state/MyModelConverter.java b/backend/src/main/java/state/MyModelConverter.java similarity index 100% rename from src/main/java/state/MyModelConverter.java rename to backend/src/main/java/state/MyModelConverter.java diff --git a/src/main/java/state/State.java b/backend/src/main/java/state/State.java similarity index 100% rename from src/main/java/state/State.java rename to backend/src/main/java/state/State.java diff --git a/src/main/java/state/WindowPositionTracker.java b/backend/src/main/java/state/WindowPositionTracker.java similarity index 100% rename from src/main/java/state/WindowPositionTracker.java rename to backend/src/main/java/state/WindowPositionTracker.java diff --git a/src/main/java/statusBar/MyStatusBarPanel.java b/backend/src/main/java/statusBar/MyStatusBarPanel.java similarity index 100% rename from src/main/java/statusBar/MyStatusBarPanel.java rename to backend/src/main/java/statusBar/MyStatusBarPanel.java diff --git a/src/main/java/statusBar/MyStatusBarWidget.java b/backend/src/main/java/statusBar/MyStatusBarWidget.java similarity index 100% rename from src/main/java/statusBar/MyStatusBarWidget.java rename to backend/src/main/java/statusBar/MyStatusBarWidget.java diff --git a/src/main/java/statusBar/MyStatusBarWidgetFactory.java b/backend/src/main/java/statusBar/MyStatusBarWidgetFactory.java similarity index 100% rename from src/main/java/statusBar/MyStatusBarWidgetFactory.java rename to backend/src/main/java/statusBar/MyStatusBarWidgetFactory.java diff --git a/src/main/java/toolwindow/BranchSelectView.java b/backend/src/main/java/toolwindow/BranchSelectView.java similarity index 100% rename from src/main/java/toolwindow/BranchSelectView.java rename to backend/src/main/java/toolwindow/BranchSelectView.java diff --git a/src/main/java/toolwindow/TabOperations.java b/backend/src/main/java/toolwindow/TabOperations.java similarity index 100% rename from src/main/java/toolwindow/TabOperations.java rename to backend/src/main/java/toolwindow/TabOperations.java diff --git a/src/main/java/toolwindow/ToolWindowUIFactory.java b/backend/src/main/java/toolwindow/ToolWindowUIFactory.java similarity index 100% rename from src/main/java/toolwindow/ToolWindowUIFactory.java rename to backend/src/main/java/toolwindow/ToolWindowUIFactory.java diff --git a/src/main/java/toolwindow/ToolWindowView.java b/backend/src/main/java/toolwindow/ToolWindowView.java similarity index 87% rename from src/main/java/toolwindow/ToolWindowView.java rename to backend/src/main/java/toolwindow/ToolWindowView.java index 3b9078f..27782dc 100644 --- a/src/main/java/toolwindow/ToolWindowView.java +++ b/backend/src/main/java/toolwindow/ToolWindowView.java @@ -7,6 +7,7 @@ import org.jdesktop.swingx.StackLayout; import toolwindow.elements.VcsTree; import java.util.Collection; +import java.util.function.Consumer; import javax.swing.*; import java.awt.*; @@ -21,24 +22,21 @@ public class ToolWindowView implements Disposable { private JPanel sceneA; private JPanel sceneB; private BranchSelectView branchSelectView; - private io.reactivex.rxjava3.disposables.Disposable subscription; + private final Consumer listener = field -> render(); public ToolWindowView(Project project, MyModel myModel) { this.project = project; this.myModel = myModel; - subscription = myModel.getObservable().subscribe(model -> render()); + myModel.addListener(listener); draw(); render(); } @Override public void dispose() { - // Dispose the observable subscription first to prevent further updates - if (subscription != null && !subscription.isDisposed()) { - subscription.dispose(); - subscription = null; - } + // Remove the listener first to prevent further updates + myModel.removeListener(listener); // Dispose BranchSelectView (removes listeners from trees, checkboxes, etc.) if (branchSelectView != null) { @@ -94,7 +92,9 @@ private void render() { sceneA.setVisible(showSceneA); sceneB.setVisible(!showSceneA); Collection modelChanges = myModel.getChanges(); - vcsTree.update(modelChanges); + if (modelChanges != null) { + vcsTree.update(modelChanges); + } } public VcsTree getVcsTree() { diff --git a/src/main/java/toolwindow/VcsTreeActions.java b/backend/src/main/java/toolwindow/VcsTreeActions.java similarity index 100% rename from src/main/java/toolwindow/VcsTreeActions.java rename to backend/src/main/java/toolwindow/VcsTreeActions.java diff --git a/src/main/java/toolwindow/actions/RenameTabAction.java b/backend/src/main/java/toolwindow/actions/RenameTabAction.java similarity index 100% rename from src/main/java/toolwindow/actions/RenameTabAction.java rename to backend/src/main/java/toolwindow/actions/RenameTabAction.java diff --git a/src/main/java/toolwindow/actions/ResetTabNameAction.java b/backend/src/main/java/toolwindow/actions/ResetTabNameAction.java similarity index 100% rename from src/main/java/toolwindow/actions/ResetTabNameAction.java rename to backend/src/main/java/toolwindow/actions/ResetTabNameAction.java diff --git a/src/main/java/toolwindow/actions/TabMoveActions.java b/backend/src/main/java/toolwindow/actions/TabMoveActions.java similarity index 100% rename from src/main/java/toolwindow/actions/TabMoveActions.java rename to backend/src/main/java/toolwindow/actions/TabMoveActions.java diff --git a/src/main/java/toolwindow/elements/BranchTree.java b/backend/src/main/java/toolwindow/elements/BranchTree.java similarity index 100% rename from src/main/java/toolwindow/elements/BranchTree.java rename to backend/src/main/java/toolwindow/elements/BranchTree.java diff --git a/src/main/java/toolwindow/elements/BranchTreeEntry.java b/backend/src/main/java/toolwindow/elements/BranchTreeEntry.java similarity index 100% rename from src/main/java/toolwindow/elements/BranchTreeEntry.java rename to backend/src/main/java/toolwindow/elements/BranchTreeEntry.java diff --git a/src/main/java/toolwindow/elements/CurrentBranch.java b/backend/src/main/java/toolwindow/elements/CurrentBranch.java similarity index 100% rename from src/main/java/toolwindow/elements/CurrentBranch.java rename to backend/src/main/java/toolwindow/elements/CurrentBranch.java diff --git a/src/main/java/toolwindow/elements/MySimpleChangesBrowser.java b/backend/src/main/java/toolwindow/elements/MySimpleChangesBrowser.java similarity index 100% rename from src/main/java/toolwindow/elements/MySimpleChangesBrowser.java rename to backend/src/main/java/toolwindow/elements/MySimpleChangesBrowser.java diff --git a/src/main/java/toolwindow/elements/TargetBranch.java b/backend/src/main/java/toolwindow/elements/TargetBranch.java similarity index 100% rename from src/main/java/toolwindow/elements/TargetBranch.java rename to backend/src/main/java/toolwindow/elements/TargetBranch.java diff --git a/src/main/java/toolwindow/elements/VcsTree.java b/backend/src/main/java/toolwindow/elements/VcsTree.java similarity index 100% rename from src/main/java/toolwindow/elements/VcsTree.java rename to backend/src/main/java/toolwindow/elements/VcsTree.java diff --git a/src/main/java/utils/CustomRollback.java b/backend/src/main/java/utils/CustomRollback.java similarity index 100% rename from src/main/java/utils/CustomRollback.java rename to backend/src/main/java/utils/CustomRollback.java diff --git a/src/main/java/utils/GitUtil.java b/backend/src/main/java/utils/GitUtil.java similarity index 100% rename from src/main/java/utils/GitUtil.java rename to backend/src/main/java/utils/GitUtil.java diff --git a/src/main/java/utils/PlatformApiReflection.java b/backend/src/main/java/utils/PlatformApiReflection.java similarity index 54% rename from src/main/java/utils/PlatformApiReflection.java rename to backend/src/main/java/utils/PlatformApiReflection.java index aa7fe0a..814529f 100644 --- a/src/main/java/utils/PlatformApiReflection.java +++ b/backend/src/main/java/utils/PlatformApiReflection.java @@ -1,7 +1,6 @@ package utils; import com.intellij.openapi.diagnostic.Logger; -import com.intellij.openapi.editor.Editor; import com.intellij.openapi.fileEditor.FileEditorManager; import com.intellij.openapi.project.Project; import com.intellij.openapi.vcs.changes.Change; @@ -21,8 +20,6 @@ import java.util.Collection; import java.util.Collections; import java.util.Map; -import java.util.Optional; -import java.util.concurrent.atomic.AtomicReference; /** * Centralized registry of reflective API bridges for IntelliJ Platform APIs that are @@ -41,11 +38,14 @@ * {@code @ApiStatus.Experimental} annotation *

  • {@link #findTagByName} — tag lookup via the newer {@code getTagsHolder()} * (2026.1+) or legacy {@code getTagHolder()} (older IDEs)
  • - *
  • {@link #openInPreviewTab} — open a file using {@code FileEditorOpenOptions} - * from the internal {@code fileEditor.impl} package
  • - *
  • {@link #getGutterArea} — {@code LineStatusMarkerDrawUtil.getGutterArea(Editor)} - * which uses {@code @ApiStatus.Internal} gutter offset methods
  • + *
  • {@link #openInPreviewTab} — open a file using {@code FileEditorOpenRequest} + * via the {@code @ApiStatus.Experimental} {@code FileEditorManagerEx.openFile} + * overload (the older {@code FileEditorOpenOptions} overload is {@code @Internal} + * in 2026.1+ and JetBrains directs external plugins to the Request overload)
  • * + * + *

    See also {@link utils.SharedReflection} for shared-module bridges (gutter area, + * split-mode RPC transport) that the frontend also needs. */ public final class PlatformApiReflection { @@ -66,22 +66,16 @@ public final class PlatformApiReflection { private static final @Nullable MethodHandle REPO_GET_TAG_HOLDER; private static final @Nullable MethodHandle TAG_HOLDER_GET_TAG; // (Object, Object name) -> Object - // ── Gutter area (LineStatusMarkerDrawUtil.getGutterArea, uses @Internal offset) ─ - // Static method: (Editor) -> IntPair; IntPair has public int fields 'first', 'second' - private static final @Nullable MethodHandle GUTTER_GET_AREA; // (Object editor) -> Object IntPair - private static final @Nullable MethodHandle INT_PAIR_FIRST; // (Object) -> Object (int boxed) - private static final @Nullable MethodHandle INT_PAIR_SECOND; // (Object) -> Object (int boxed) - - // ── Preview tab (FileEditorOpenOptions, internal impl package) ──────────── - private static final @Nullable Class OPEN_OPTIONS_CLASS; - private static final @Nullable MethodHandle OPEN_OPTIONS_CTOR; // () -> Object - private static final @Nullable MethodHandle WITH_REQUEST_FOCUS; // (Object, Object bool) -> Object - private static final @Nullable MethodHandle WITH_USE_PREVIEW_TAB; // (Object, Object bool) -> Object - private static final @Nullable MethodHandle WITH_REUSE_OPEN; // (Object, Object bool) -> Object - // openFile handle resolved lazily on first use (requires a concrete FileEditorManager instance - // to walk the class hierarchy; implementation class is fixed for the IDE lifetime so we cache it). - private static final AtomicReference> EDITOR_OPEN_FILE_REF = - new AtomicReference<>(null); + // ── Preview tab (FileEditorOpenRequest, @ApiStatus.Experimental) ────────── + // The old (Editor, FileEditorOpenOptions) path is @ApiStatus.Internal in 2026.1; + // JetBrains directs external plugins to the FileEditorOpenRequest overload instead + // (see FileEditorManagerEx.openFile javadoc). That overload is @ApiStatus.Experimental, + // so we still route through reflection to keep verifyPlugin clean. + // Primary constructor: (EditorWindow?, FileEditorOpenMode?, selectAsCurrent, reuseOpen, + // usePreviewTab, requestFocus, pin) + private static final @Nullable MethodHandle OPEN_REQUEST_CTOR; + // FileEditorManagerEx.openFile(VirtualFile, FileEditorOpenRequest) -> FileEditorComposite + private static final @Nullable MethodHandle OPEN_FILE_WITH_REQUEST; static { // ── GitCommit.getChanges() ───────────────────────────────────────── @@ -100,51 +94,27 @@ public final class PlatformApiReflection { REPO_GET_TAG_HOLDER = resolvePublicVirtual(GitRepository.class, "getTagHolder"); TAG_HOLDER_GET_TAG = resolveByClassName("git4idea.repo.GitTagHolder", "getTag", String.class); - // ── Gutter area (LineStatusMarkerDrawUtil) ───────────────────────── - MethodHandle gutterArea = null; - MethodHandle pairFirst = null; - MethodHandle pairSecond = null; - try { - Class drawUtilClass = Class.forName("com.intellij.openapi.diff.LineStatusMarkerDrawUtil"); - Method m = drawUtilClass.getMethod("getGutterArea", Editor.class); - gutterArea = MethodHandles.publicLookup().unreflect(m) - .asType(MethodType.genericMethodType(1)); - // IntPair has public int fields 'first' and 'second' - Class intPairClass = m.getReturnType(); - pairFirst = MethodHandles.publicLookup().unreflectGetter(intPairClass.getField("first")) - .asType(MethodType.methodType(int.class, Object.class)); - pairSecond = MethodHandles.publicLookup().unreflectGetter(intPairClass.getField("second")) - .asType(MethodType.methodType(int.class, Object.class)); - } catch (Exception e) { - LOG.debug("PlatformApiReflection: LineStatusMarkerDrawUtil.getGutterArea not available — " + e.getMessage()); - } - GUTTER_GET_AREA = gutterArea; - INT_PAIR_FIRST = pairFirst; - INT_PAIR_SECOND = pairSecond; - - // ── Preview tab options ──────────────────────────────────────────── - Class optClass = null; - MethodHandle ctor = null; - MethodHandle withFocus = null; - MethodHandle withPreview = null; - MethodHandle withReuse = null; + // ── Preview tab (FileEditorOpenRequest + FileEditorManagerEx.openFile) ─ + MethodHandle openReqCtor = null; + MethodHandle openFileWithReq = null; try { - optClass = Class.forName("com.intellij.openapi.fileEditor.impl.FileEditorOpenOptions"); - Constructor c = optClass.getDeclaredConstructor(); - c.setAccessible(true); - ctor = MethodHandles.lookup().unreflectConstructor(c) - .asType(MethodType.methodType(Object.class)); - withFocus = resolvePublicVirtual(optClass, "withRequestFocus", boolean.class); - withPreview = resolvePublicVirtual(optClass, "withUsePreviewTab", boolean.class); - withReuse = resolvePublicVirtual(optClass, "withReuseOpen", boolean.class); + Class editorWindowClass = Class.forName("com.intellij.openapi.fileEditor.impl.EditorWindow"); + Class openModeClass = Class.forName("com.intellij.openapi.fileEditor.ex.FileEditorOpenMode"); + Class openRequestClass = Class.forName("com.intellij.openapi.fileEditor.ex.FileEditorOpenRequest"); + Class femExClass = Class.forName("com.intellij.openapi.fileEditor.ex.FileEditorManagerEx"); + + Constructor c = openRequestClass.getDeclaredConstructor( + editorWindowClass, openModeClass, + boolean.class, boolean.class, boolean.class, boolean.class, boolean.class); + openReqCtor = MethodHandles.publicLookup().unreflectConstructor(c); + + Method m = femExClass.getMethod("openFile", VirtualFile.class, openRequestClass); + openFileWithReq = MethodHandles.publicLookup().unreflect(m); } catch (Exception e) { - LOG.debug("PlatformApiReflection: FileEditorOpenOptions not available — " + e.getMessage()); + LOG.debug("PlatformApiReflection: FileEditorOpenRequest API not available — " + e.getMessage()); } - OPEN_OPTIONS_CLASS = optClass; - OPEN_OPTIONS_CTOR = ctor; - WITH_REQUEST_FOCUS = withFocus; - WITH_USE_PREVIEW_TAB = withPreview; - WITH_REUSE_OPEN = withReuse; + OPEN_REQUEST_CTOR = openReqCtor; + OPEN_FILE_WITH_REQUEST = openFileWithReq; } private PlatformApiReflection() {} @@ -214,28 +184,6 @@ public static Collection getCommitChanges(@NotNull GitCommit commit) { return Collections.emptyList(); } - /** - * Returns the gutter area (x, endX) used by the IDE's own VCS line status markers, - * via {@code LineStatusMarkerDrawUtil.getGutterArea(Editor)}. - * This internally uses {@code @ApiStatus.Internal} gutter offset methods. - * - * @return {@code int[]{x, endX}}, or {@code null} when the reflection bridge is unavailable - */ - public static int @Nullable [] getGutterArea(@NotNull Editor editor) { - if (GUTTER_GET_AREA == null || INT_PAIR_FIRST == null || INT_PAIR_SECOND == null) { - return null; - } - try { - Object intPair = GUTTER_GET_AREA.invoke(editor); - int x = (int) INT_PAIR_FIRST.invoke(intPair); - int endX = (int) INT_PAIR_SECOND.invoke(intPair); - return new int[]{x, endX}; - } catch (Throwable t) { - LOG.warn("PlatformApiReflection: getGutterArea invocation failed", t); - return null; - } - } - /** * Finds a Git tag by name on the given repository. * @@ -285,76 +233,22 @@ public static GitReference findTagByName(@NotNull GitRepository repo, @NotNull S } /** - * Opens a file in the editor's preview tab using the internal - * {@code FileEditorOpenOptions} API. Does nothing if the API is unavailable. + * Opens a file in the editor's preview tab via the {@code @ApiStatus.Experimental} + * {@code FileEditorManagerEx.openFile(VirtualFile, FileEditorOpenRequest)} overload. + * Does nothing if the API is unavailable. * * @param project the current project * @param file the file to open */ public static void openInPreviewTab(@NotNull Project project, @NotNull VirtualFile file) { - if (OPEN_OPTIONS_CLASS == null || OPEN_OPTIONS_CTOR == null - || WITH_REQUEST_FOCUS == null || WITH_USE_PREVIEW_TAB == null - || WITH_REUSE_OPEN == null) { - return; - } + if (OPEN_REQUEST_CTOR == null || OPEN_FILE_WITH_REQUEST == null) return; try { - FileEditorManager editorManager = FileEditorManager.getInstance(project); - MethodHandle openFile = resolveEditorOpenFile(editorManager); - if (openFile == null) { - LOG.debug("PlatformApiReflection: openFile method not found on " - + editorManager.getClass().getName()); - return; - } - Object options = OPEN_OPTIONS_CTOR.invoke(); - options = WITH_REQUEST_FOCUS.invoke(options, false); - options = WITH_USE_PREVIEW_TAB.invoke(options, true); - options = WITH_REUSE_OPEN.invoke(options, true); - openFile.invoke(editorManager, file, null, options); + // (targetWindow=null, openMode=null, selectAsCurrent=true, reuseOpen=true, + // usePreviewTab=true, requestFocus=true, pin=false) + Object request = OPEN_REQUEST_CTOR.invoke(null, null, true, true, true, true, false); + OPEN_FILE_WITH_REQUEST.invoke(FileEditorManager.getInstance(project), file, request); } catch (Throwable t) { LOG.debug("PlatformApiReflection: openInPreviewTab failed", t); } } - - /** - * Lazily resolves and caches the {@code FileEditorManager.openFile(VirtualFile, ?, FileEditorOpenOptions)} - * method handle by walking the manager's class hierarchy. - * The implementation class is fixed for the IDE lifetime, so the handle is cached - * in {@link #EDITOR_OPEN_FILE_REF} after the first successful resolution. - */ - private static @Nullable MethodHandle resolveEditorOpenFile(@NotNull FileEditorManager mgr) { - Optional cached = EDITOR_OPEN_FILE_REF.get(); - if (cached.isPresent()) { - return cached.orElse(null); - } - - MethodHandle found = null; - Class cls = mgr.getClass(); - outer: - while (cls != null) { - for (Method m : cls.getDeclaredMethods()) { - if (!"openFile".equals(m.getName())) continue; - Class[] pts = m.getParameterTypes(); - if (pts.length == 3 - && VirtualFile.class.isAssignableFrom(pts[0])) { - assert OPEN_OPTIONS_CLASS != null; - if (OPEN_OPTIONS_CLASS.isAssignableFrom(pts[2])) { - try { - m.setAccessible(true); - // receiver + 3 params, all widened to Object - found = MethodHandles.lookup().unreflect(m) - .asType(MethodType.genericMethodType(4)); - } catch (Exception e) { - LOG.debug("PlatformApiReflection: could not unreflect openFile — " - + e.getMessage()); - } - break outer; - } - } - } - cls = cls.getSuperclass(); - } - - EDITOR_OPEN_FILE_REF.compareAndSet(null, Optional.ofNullable(found)); - return found; - } } diff --git a/backend/src/main/resources/gitscope.backend.xml b/backend/src/main/resources/gitscope.backend.xml new file mode 100644 index 0000000..087003c --- /dev/null +++ b/backend/src/main/resources/gitscope.backend.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/build.gradle.kts b/build.gradle.kts index eaed095..41004a0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,6 @@ +import org.jetbrains.changelog.Changelog.OutputType +import org.jetbrains.intellij.platform.gradle.tasks.aware.SplitModeAware.PluginInstallationTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension fun properties(key: String) = project.findProperty(key).toString() @@ -5,28 +8,43 @@ val platformVersion = properties("platformVersion") val platformType = properties("platformType") plugins { + application id("java") - id("org.jetbrains.kotlin.jvm") version "2.3.20" id("org.jetbrains.intellij.platform") version "2.16.0" + id("org.jetbrains.intellij.platform.module") version "2.16.0" apply false id("org.jetbrains.changelog") version "2.5.0" + id("org.jetbrains.kotlin.jvm") version "2.3.20" apply false + id("org.jetbrains.kotlin.plugin.serialization") version "2.3.20" apply false + id("rpc") version "2.3.20-0.1" apply false } group = properties("pluginGroup") version = properties("pluginVersion") +subprojects { + + afterEvaluate { + extensions.findByType()?.jvmToolchain(21) + } + + tasks.withType { + sourceCompatibility = "21" + targetCompatibility = "21" + } +} + +allprojects { + repositories { + mavenCentral() + } +} + repositories { - mavenCentral() intellijPlatform { defaultRepositories() } } -kotlin { - jvmToolchain(21) - compilerOptions { - jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21) - } -} fun getMajorVersion(version: String): String { val parts = version.split(".") @@ -36,7 +54,6 @@ fun getMajorVersion(version: String): String { changelog { version.set(properties("pluginVersion")) groups.set(emptyList()) - // Configure to accept your version format (YYYY.N or YYYY.N.N) headerParserRegex.set("""(\d{4}\.\d+(?:\.\d+)?)""".toRegex()) keepUnreleasedSection.set(true) } @@ -46,15 +63,22 @@ dependencies { val type: String = providers.gradleProperty("platformType").get() val version: String = providers.gradleProperty("platformVersion").get() - create(type, version) { useInstaller = !version.endsWith("EAP-SNAPSHOT") } bundledPlugins(providers.gradleProperty("platformBundledPlugins").map { it.split(',') }) + + pluginModule(implementation(project(":shared"))) + pluginModule(implementation(project(":backend"))) + pluginModule(implementation(project(":frontend"))) } } intellijPlatform { + + splitMode = true + pluginInstallationTarget = PluginInstallationTarget.BOTH + signing { certificateChainFile = providers.environmentVariable("CERTIFICATE_CHAIN_FILE") .map { File(it) } @@ -77,19 +101,17 @@ intellijPlatform { changeNotes.set(provider { val majorVersion = getMajorVersion(project.version.toString()) - // Get all changelog entries that start with the major version val matchingEntries = changelog.getAll().values .filter { it.version.startsWith(majorVersion) } if (matchingEntries.isNotEmpty()) { matchingEntries.joinToString("\n\n") { - changelog.renderItem(it, org.jetbrains.changelog.Changelog.OutputType.HTML) + changelog.renderItem(it, OutputType.HTML) } } else { - // Fallback to current version only changelog.renderItem( changelog.getOrNull(properties("pluginVersion")) ?: changelog.getLatest(), - org.jetbrains.changelog.Changelog.OutputType.HTML + OutputType.HTML ) } }) @@ -101,6 +123,19 @@ intellijPlatform { } +// Split Mode run task — launches frontend + backend processes locally for remote dev testing. +// Usage: ./gradlew runIdeSplitMode +val runIdeSplitMode by intellijPlatformTesting.runIde.registering { + splitMode = true + pluginInstallationTarget = PluginInstallationTarget.BOTH +} + +// Monolith run task — single-process mode for everyday development. +// Usage: ./gradlew runIdeMonolith +val runIdeMonolith by intellijPlatformTesting.runIde.registering { + splitMode = false +} + gradle.taskGraph.whenReady { val isRelease = hasTask(":signPlugin") || hasTask(":publishPlugin") || hasTask(":verifyPlugin") tasks.named("buildSearchableOptions") { enabled = isRelease } @@ -115,7 +150,7 @@ tasks { } register("verifyWrapperVersion") { - // Wire expected version as a declared input so the action doesn't capture Project + description = "Verifies that the Gradle Wrapper version matches the gradleVersion property." val expectedVersion = providers.gradleProperty("gradleVersion").orElse("") inputs.property("expectedGradleVersion", expectedVersion) @@ -132,22 +167,11 @@ tasks { } } } - // Set the JVM compatibility versions - withType { - sourceCompatibility = "21" - targetCompatibility = "21" - } } - -// Verify that we have the expected version of the Gradle wrapper listOf("build", "buildPlugin").forEach { taskName -> tasks.matching { it.name == taskName }.configureEach { dependsOn(tasks.named("verifyWrapperVersion")) } } -dependencies { - implementation("com.google.code.gson:gson:2.14.0") - implementation("io.reactivex.rxjava3:rxjava:3.1.12") -} \ No newline at end of file diff --git a/frontend/build.gradle.kts b/frontend/build.gradle.kts new file mode 100644 index 0000000..46f97e0 --- /dev/null +++ b/frontend/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + id("org.jetbrains.intellij.platform.module") + id("java") + id("org.jetbrains.kotlin.jvm") + id("org.jetbrains.kotlin.plugin.serialization") + id("rpc") +} + +repositories { + intellijPlatform { + defaultRepositories() + } +} + +dependencies { + intellijPlatform { + intellijIdea(providers.gradleProperty("platformVersion")) + bundledModule("intellij.platform.frontend") + } + + implementation(project(":shared")) +} diff --git a/frontend/src/main/java/gitscope/frontend/GutterRenderingService.java b/frontend/src/main/java/gitscope/frontend/GutterRenderingService.java new file mode 100644 index 0000000..14717e0 --- /dev/null +++ b/frontend/src/main/java/gitscope/frontend/GutterRenderingService.java @@ -0,0 +1,388 @@ +package gitscope.frontend; + +import com.intellij.openapi.Disposable; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.application.ModalityState; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.editor.Document; +import com.intellij.openapi.editor.Editor; +import com.intellij.openapi.editor.EditorFactory; +import com.intellij.openapi.editor.EditorKind; +import com.intellij.openapi.editor.event.DocumentEvent; +import com.intellij.openapi.editor.event.DocumentListener; +import com.intellij.openapi.fileEditor.FileDocumentManager; +import com.intellij.openapi.fileEditor.FileEditorManager; +import com.intellij.openapi.fileEditor.FileEditorManagerListener; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.Computable; +import com.intellij.openapi.util.text.StringUtil; +import com.intellij.openapi.vcs.VcsApplicationSettings; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.util.messages.MessageBusConnection; +import implementation.gutter.Range; +import implementation.gutter.RangesBuilder; +import implementation.gutter.ScopeLineStatusMarkerRenderer; +import org.jetbrains.annotations.NotNull; +import service.GutterDataService; +import system.Defs; + +import java.util.*; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Frontend service that manages gutter renderers based on data published by the backend + * via {@link GutterDataService}. + *

    + * Handles renderer lifecycle, document change listeners for live recomputation, + * and file close cleanup. + */ +public class GutterRenderingService implements Disposable, GutterDataService.Listener { + private static final Logger LOG = Defs.getLogger(GutterRenderingService.class); + + private final Project project; + private final GutterDataService gutterDataService; + private final AtomicBoolean disposed = new AtomicBoolean(false); + private final Map renderers = new HashMap<>(); + private MessageBusConnection messageBusConnection; + + private static final class RendererInfo { + volatile ScopeLineStatusMarkerRenderer renderer; + volatile String baseContent; + volatile String headContent; + volatile List scopeRanges; + volatile DocumentListener documentListener; + + RendererInfo(ScopeLineStatusMarkerRenderer renderer, String baseContent) { + this.renderer = renderer; + this.baseContent = baseContent; + } + } + + public GutterRenderingService(Project project) { + this.project = project; + this.gutterDataService = project.getService(GutterDataService.class); + LOG.info("GutterRenderingService created, registering as listener on GutterDataService"); + this.gutterDataService.addListener(this); + + this.messageBusConnection = project.getMessageBus().connect(); + messageBusConnection.subscribe( + FileEditorManagerListener.FILE_EDITOR_MANAGER, + new FileEditorManagerListener() { + @Override + public void fileOpened(@NotNull FileEditorManager source, @NotNull VirtualFile file) { + // Check if backend already has data for this file (startup race fix) + GutterDataService.GutterFileData data = gutterDataService.getData(file.getPath()); + if (data != null) { + onDataUpdated(file.getPath(), data); + } + } + + @Override + public void fileClosed(@NotNull FileEditorManager source, @NotNull VirtualFile file) { + Document doc = ApplicationManager.getApplication().runReadAction( + (Computable) () -> FileDocumentManager.getInstance().getDocument(file)); + if (doc != null) { + releaseRenderer(doc); + } + } + } + ); + + // Replay existing data for all currently open editors (handles startup race) + ApplicationManager.getApplication().invokeLater(() -> { + if (disposed.get()) return; + for (Map.Entry entry : gutterDataService.getAllData().entrySet()) { + onDataUpdated(entry.getKey(), entry.getValue()); + } + }, ModalityState.any()); + } + + // --- GutterDataService.Listener --- + + @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()); + + ApplicationManager.getApplication().invokeLater(() -> { + if (disposed.get()) return; + + // Find the document for this file path + boolean found = false; + for (Editor editor : EditorFactory.getInstance().getAllEditors()) { + if (editor.getEditorKind() == EditorKind.DIFF) continue; + 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"); + updateRenderer(doc, file, data); + found = true; + break; + } + } + if (!found) { + LOG.info("GutterRenderingService: NO editor found for " + filePath); + } + }, ModalityState.defaultModalityState(), __ -> disposed.get()); + } + + @Override + public void onDataCleared(@NotNull String filePath) { + if (disposed.get()) return; + + ApplicationManager.getApplication().invokeLater(() -> { + if (disposed.get()) return; + for (Map.Entry entry : new ArrayList<>(renderers.entrySet())) { + VirtualFile file = FileDocumentManager.getInstance().getFile(entry.getKey()); + if (file != null && file.getPath().equals(filePath)) { + releaseRenderer(entry.getKey()); + break; + } + } + }, ModalityState.defaultModalityState(), __ -> disposed.get()); + } + + @Override + public void onAllCleared() { + if (disposed.get()) return; + + ApplicationManager.getApplication().invokeLater(() -> { + if (disposed.get()) return; + releaseAllRenderers(); + }, ModalityState.defaultModalityState(), __ -> disposed.get()); + } + + // --- Renderer management --- + + private synchronized void updateRenderer(@NotNull Document document, @NotNull VirtualFile file, + @NotNull GutterDataService.GutterFileData data) { + if (disposed.get()) return; + + RendererInfo info = renderers.get(document); + if (info == null) { + LOG.info("GutterRenderingService.updateRenderer: CREATING new renderer for " + file.getPath()); + ScopeLineStatusMarkerRenderer renderer = new ScopeLineStatusMarkerRenderer( + project, document, file, this); + info = new RendererInfo(renderer, data.baseContent); + renderers.put(document, info); + + DocumentListener docListener = createDocumentListener(document, info); + info.documentListener = docListener; + document.addDocumentListener(docListener, this); + } else { + info.baseContent = data.baseContent; + } + + info.headContent = data.headContent; + 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()); + } + + private DocumentListener createDocumentListener(@NotNull Document document, @NotNull RendererInfo info) { + return new DocumentListener() { + @Override + public void documentChanged(@NotNull DocumentEvent event) { + if (disposed.get() || info.baseContent == null) return; + + ApplicationManager.getApplication().executeOnPooledThread(() -> { + if (!disposed.get()) { + recalculateRangesAsync(document, info); + } + }); + } + }; + } + + private void recalculateRangesAsync(@NotNull Document document, @NotNull RendererInfo info) { + try { + if (!VcsApplicationSettings.getInstance().SHOW_LST_GUTTER_MARKERS) { + ApplicationManager.getApplication().invokeLater(() -> { + if (!disposed.get() && info.renderer != null) { + info.renderer.updateRanges(Collections.emptyList()); + } + }, ModalityState.defaultModalityState()); + return; + } + + String currentContent = ApplicationManager.getApplication().runReadAction( + (Computable) () -> document.getImmutableCharSequence().toString()); + String normalizedCurrent = StringUtil.convertLineSeparators(currentContent); + + List filteredRanges; + if (info.headContent != null && info.scopeRanges != null) { + List localRanges = RangesBuilder.INSTANCE.createRanges(normalizedCurrent, info.headContent); + if (localRanges.isEmpty()) { + filteredRanges = new ArrayList<>(info.scopeRanges); + } else { + filteredRanges = mapScopeRangesToCurrentSpace(info.scopeRanges, localRanges); + } + } else { + filteredRanges = RangesBuilder.INSTANCE.createRanges(normalizedCurrent, info.baseContent); + } + + final List rangesToApply = filteredRanges; + ApplicationManager.getApplication().invokeLater(() -> { + if (!disposed.get() && info.renderer != null) { + info.renderer.updateRanges(rangesToApply); + } + }, ModalityState.defaultModalityState()); + } catch (Exception e) { + LOG.error("Error recalculating ranges", e); + } + } + + /** + * Maps scope ranges from HEAD coordinate space into current-document space, + * splitting or suppressing portions that overlap with local changes. + * Same algorithm as MyLineStatusTrackerImpl.mapScopeRangesToCurrentSpace. + */ + private List mapScopeRangesToCurrentSpace(List scopeRanges, List localRanges) { + List result = new ArrayList<>(); + int numLocals = localRanges.size(); + int cumulativeDelta = 0; + int localIdx = 0; + + for (Range scope : scopeRanges) { + int headStart = scope.getLine1(); + int headEnd = scope.getLine2(); + int vcsStart = scope.getVcsLine1(); + int vcsEnd = scope.getVcsLine2(); + + while (localIdx < numLocals && localRanges.get(localIdx).getVcsLine2() <= headStart) { + Range local = localRanges.get(localIdx); + cumulativeDelta += (local.getLine2() - local.getLine1()) + - (local.getVcsLine2() - local.getVcsLine1()); + localIdx++; + } + + if (headStart == headEnd) { + boolean insideLocal = false; + for (int i = localIdx; i < numLocals; i++) { + Range local = localRanges.get(i); + if (local.getVcsLine1() > headStart) break; + if (local.getVcsLine2() > headStart) { insideLocal = true; break; } + } + if (!insideLocal) { + int pos = headStart + cumulativeDelta; + result.add(new Range(pos, pos, vcsStart, vcsEnd)); + } + continue; + } + + int tempLocalIdx = localIdx; + int headCursor; + int currentCursor; + + if (tempLocalIdx < numLocals) { + Range straddle = localRanges.get(tempLocalIdx); + if (straddle.getVcsLine1() < headStart && straddle.getVcsLine2() > headStart) { + headCursor = straddle.getVcsLine2(); + currentCursor = straddle.getLine2(); + tempLocalIdx++; + } else { + headCursor = headStart; + currentCursor = headStart + cumulativeDelta; + } + } else { + headCursor = headStart; + currentCursor = headStart + cumulativeDelta; + } + + while (headCursor < headEnd) { + Range nextLocal = null; + if (tempLocalIdx < numLocals) { + Range candidate = localRanges.get(tempLocalIdx); + if (candidate.getVcsLine1() < headEnd) nextLocal = candidate; + } + + if (nextLocal != null) { + int localHeadStart = nextLocal.getVcsLine1(); + int localHeadEnd = nextLocal.getVcsLine2(); + + if (localHeadStart > headCursor) { + emitScopeSegment(result, headCursor, localHeadStart, currentCursor, + headStart, headEnd, vcsStart, vcsEnd); + currentCursor += (localHeadStart - headCursor); + } + + headCursor = Math.max(headCursor, localHeadEnd); + currentCursor = nextLocal.getLine2(); + tempLocalIdx++; + } else { + emitScopeSegment(result, headCursor, headEnd, currentCursor, + headStart, headEnd, vcsStart, vcsEnd); + headCursor = headEnd; + } + } + } + return result; + } + + private void emitScopeSegment(List result, + int headSegStart, int headSegEnd, int currentStart, + int headBlockStart, int headBlockEnd, + int vcsBlockStart, int vcsBlockEnd) { + int currentEnd = currentStart + (headSegEnd - headSegStart); + int headBlockLen = headBlockEnd - headBlockStart; + int segVcsStart, segVcsEnd; + + if (headBlockLen == 0) { + segVcsStart = vcsBlockStart; + segVcsEnd = vcsBlockEnd; + } else { + long vcsLen = vcsBlockEnd - vcsBlockStart; + segVcsStart = vcsBlockStart + (int) (vcsLen * (headSegStart - headBlockStart) / headBlockLen); + segVcsEnd = vcsBlockStart + (int) (vcsLen * (headSegEnd - headBlockStart) / headBlockLen); + } + + if (currentStart < currentEnd || segVcsStart < segVcsEnd) { + result.add(new Range(currentStart, currentEnd, segVcsStart, segVcsEnd)); + } + } + + 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); } + } + if (info.renderer != null) { + try { info.renderer.dispose(); } + catch (Exception e) { LOG.warn("Error disposing renderer", e); } + } + } + } + + private void releaseAllRenderers() { + for (Map.Entry entry : renderers.entrySet()) { + RendererInfo info = entry.getValue(); + if (info != null && info.renderer != null) { + try { info.renderer.dispose(); } + catch (Exception e) { LOG.warn("Error disposing renderer", e); } + } + } + renderers.clear(); + } + + @Override + public void dispose() { + if (!disposed.compareAndSet(false, true)) return; + + gutterDataService.removeListener(this); + + if (messageBusConnection != null) { + messageBusConnection.disconnect(); + messageBusConnection = null; + } + + Runnable release = this::releaseAllRenderers; + if (ApplicationManager.getApplication().isDispatchThread()) { + release.run(); + } else { + ApplicationManager.getApplication().invokeAndWait(release); + } + } +} diff --git a/frontend/src/main/java/gitscope/frontend/GutterRenderingStartup.java b/frontend/src/main/java/gitscope/frontend/GutterRenderingStartup.java new file mode 100644 index 0000000..168c9b5 --- /dev/null +++ b/frontend/src/main/java/gitscope/frontend/GutterRenderingStartup.java @@ -0,0 +1,22 @@ +package gitscope.frontend; + +import com.intellij.openapi.project.Project; +import com.intellij.openapi.startup.ProjectActivity; +import kotlin.Unit; +import kotlin.coroutines.Continuation; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Triggers eager initialization of {@link GutterRenderingService} on project open + * so it registers as a listener on {@link service.GutterDataService} before any + * ranges are published. + */ +public class GutterRenderingStartup implements ProjectActivity { + @Nullable + @Override + public Object execute(@NotNull Project project, @NotNull Continuation continuation) { + project.getService(GutterRenderingService.class); + return Unit.INSTANCE; + } +} diff --git a/src/main/java/implementation/gutter/LineStatusGutterMarkerRenderer.kt b/frontend/src/main/java/implementation/gutter/LineStatusGutterMarkerRenderer.kt similarity index 99% rename from src/main/java/implementation/gutter/LineStatusGutterMarkerRenderer.kt rename to frontend/src/main/java/implementation/gutter/LineStatusGutterMarkerRenderer.kt index 2d11561..6b6f976 100644 --- a/src/main/java/implementation/gutter/LineStatusGutterMarkerRenderer.kt +++ b/frontend/src/main/java/implementation/gutter/LineStatusGutterMarkerRenderer.kt @@ -26,7 +26,7 @@ import java.awt.event.MouseEvent */ internal fun getGutterArea(editor: EditorEx): Pair { // Primary: exact IDE positioning via reflection - val area = utils.PlatformApiReflection.getGutterArea(editor) + val area = utils.SharedReflection.getGutterArea(editor) if (area != null) return Pair(area[0], area[1]) // Fallback: derive from public APIs (new UI only — old UI was removed in 2024.1) diff --git a/src/main/java/implementation/gutter/ScopeDiffViewer.kt b/frontend/src/main/java/implementation/gutter/ScopeDiffViewer.kt similarity index 100% rename from src/main/java/implementation/gutter/ScopeDiffViewer.kt rename to frontend/src/main/java/implementation/gutter/ScopeDiffViewer.kt diff --git a/src/main/java/implementation/gutter/ScopeGutterHighlighterManager.kt b/frontend/src/main/java/implementation/gutter/ScopeGutterHighlighterManager.kt similarity index 100% rename from src/main/java/implementation/gutter/ScopeGutterHighlighterManager.kt rename to frontend/src/main/java/implementation/gutter/ScopeGutterHighlighterManager.kt diff --git a/src/main/java/implementation/gutter/ScopeGutterPopupPanel.kt b/frontend/src/main/java/implementation/gutter/ScopeGutterPopupPanel.kt similarity index 94% rename from src/main/java/implementation/gutter/ScopeGutterPopupPanel.kt rename to frontend/src/main/java/implementation/gutter/ScopeGutterPopupPanel.kt index cad2c39..b718555 100644 --- a/src/main/java/implementation/gutter/ScopeGutterPopupPanel.kt +++ b/frontend/src/main/java/implementation/gutter/ScopeGutterPopupPanel.kt @@ -19,6 +19,8 @@ import com.intellij.openapi.editor.colors.EditorFontType import com.intellij.openapi.editor.ex.EditorEx import com.intellij.openapi.editor.event.EditorMouseEvent import com.intellij.openapi.editor.event.EditorMouseListener +import com.intellij.openapi.fileEditor.FileEditorManagerEvent +import com.intellij.openapi.fileEditor.FileEditorManagerListener import com.intellij.openapi.editor.markup.RangeHighlighter import com.intellij.openapi.progress.DumbProgressIndicator import com.intellij.openapi.project.Project @@ -64,6 +66,7 @@ internal class ScopeGutterPopupPanel( } private var activePopup: com.intellij.openapi.ui.popup.JBPopup? = null + private var suppressCaretCancel = false fun show(editor: EditorEx, range: Range, e: MouseEvent) { // Close any existing popup before opening a new one @@ -120,8 +123,10 @@ internal class ScopeGutterPopupPanel( if (currentIndex > 0) { currentIndex-- val previousRange = sortedRanges[currentIndex] + suppressCaretCancel = true editor.caretModel.moveToLogicalPosition(LogicalPosition(previousRange.line1, 0)) editor.scrollingModel.scrollToCaret(ScrollType.CENTER) + suppressCaretCancel = false updateDiffPanel(previousRange) updateEditorHighlights(previousRange) } @@ -138,8 +143,10 @@ internal class ScopeGutterPopupPanel( if (currentIndex >= 0 && currentIndex < sortedRanges.size - 1) { currentIndex++ val nextRange = sortedRanges[currentIndex] + suppressCaretCancel = true editor.caretModel.moveToLogicalPosition(LogicalPosition(nextRange.line1, 0)) editor.scrollingModel.scrollToCaret(ScrollType.CENTER) + suppressCaretCancel = false updateDiffPanel(nextRange) updateEditorHighlights(nextRange) } @@ -178,7 +185,7 @@ internal class ScopeGutterPopupPanel( // Scope name label — shows the active diff base (e.g. "master", "HEAD~2") val scopeName = try { - project.getService(service.ViewService::class.java)?.getCurrent()?.getDisplayName() ?: "" + project.getService(service.GutterDataService::class.java)?.scopeDisplayName ?: "" } catch (_: Exception) { "" } if (scopeName.isNotEmpty()) { addSeparator() @@ -256,6 +263,23 @@ internal class ScopeGutterPopupPanel( activePopup?.cancel() } }, popupDisposable) + val scrollPane = editor.scrollPane + val wheelListener = java.awt.event.MouseWheelListener { activePopup?.cancel() } + scrollPane.addMouseWheelListener(wheelListener) + Disposer.register(popupDisposable) { scrollPane.removeMouseWheelListener(wheelListener) } + editor.caretModel.addCaretListener(object : com.intellij.openapi.editor.event.CaretListener { + override fun caretPositionChanged(event: com.intellij.openapi.editor.event.CaretEvent) { + if (!suppressCaretCancel) activePopup?.cancel() + } + }, popupDisposable) + project.messageBus.connect(popupDisposable).subscribe( + FileEditorManagerListener.FILE_EDITOR_MANAGER, + object : FileEditorManagerListener { + override fun selectionChanged(event: FileEditorManagerEvent) { + activePopup?.cancel() + } + } + ) } /** diff --git a/src/main/java/implementation/gutter/ScopeLineStatusMarkerRenderer.kt b/frontend/src/main/java/implementation/gutter/ScopeLineStatusMarkerRenderer.kt similarity index 100% rename from src/main/java/implementation/gutter/ScopeLineStatusMarkerRenderer.kt rename to frontend/src/main/java/implementation/gutter/ScopeLineStatusMarkerRenderer.kt diff --git a/frontend/src/main/java/rpc/FrontendGutterListeners.kt b/frontend/src/main/java/rpc/FrontendGutterListeners.kt new file mode 100644 index 0000000..cf705ff --- /dev/null +++ b/frontend/src/main/java/rpc/FrontendGutterListeners.kt @@ -0,0 +1,59 @@ +package rpc + +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.openapi.startup.ProjectActivity +import com.intellij.platform.project.projectId +import fleet.rpc.client.durable +import implementation.gutter.Range +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import service.GutterDataService +import settings.GitScopeSettings + +@Service(Service.Level.PROJECT) +class FrontendGutterSubscriptions( + private val project: Project, + private val coroutineScope: CoroutineScope +) { + init { + // Only subscribe via RPC in split mode — in monolithic mode, + // GutterRenderingService listens to GutterDataService directly. + if (!com.intellij.platform.ide.productMode.IdeProductMode.isMonolith) { + coroutineScope.launch { + durable { + GutterRpcApi.getInstance() + .getGutterUpdates(project.projectId()) + .collect { event -> handleEvent(event) } + } + } + } + } + + private fun handleEvent(event: GutterUpdateEvent) { + val gds = project.service() + when (event) { + is GutterUpdateEvent.DataUpdated -> { + val dto = event.data + gds.scopeDisplayName = dto.scopeDisplayName + GitScopeSettings.getInstance().isSeparateGutterRendering = dto.separateGutterRendering + val data = GutterDataService.GutterFileData( + dto.ranges.map { Range(it.line1, it.line2, it.vcsLine1, it.vcsLine2) }, + dto.baseContent, + dto.headContent, + dto.scopeRanges?.map { Range(it.line1, it.line2, it.vcsLine1, it.vcsLine2) } + ) + gds.publish(dto.filePath, data) + } + is GutterUpdateEvent.DataCleared -> gds.clear(event.filePath) + is GutterUpdateEvent.AllCleared -> gds.clearAll() + } + } +} + +class FrontendGutterSubscriptionsStartup : 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 new file mode 100644 index 0000000..093aec5 --- /dev/null +++ b/frontend/src/main/resources/gitscope.frontend.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/gradle.properties b/gradle.properties index df1a77c..2129c98 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,12 +2,12 @@ pluginGroup=org.woelkit.plugins pluginName=Git Scope pluginRepositoryUrl=https://github.com/comod/git-scope-pro -pluginVersion=2026.1.4 -pluginSinceBuild=243 +pluginVersion=2026.2 +pluginSinceBuild=253 platformType=IU #platformVersion=LATEST-EAP-SNAPSHOT -platformVersion=2026.1 +platformVersion=2026.1.2 platformBundledPlugins=Git4Idea gradleVersion=9.5.1 diff --git a/settings.gradle.kts b/settings.gradle.kts index 279444c..b259318 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,7 +1,13 @@ rootProject.name = "gitscope" + +include("shared") +include("backend") +include("frontend") + pluginManagement { repositories { maven("https://central.sonatype.com/repository/maven-snapshots/") gradlePluginPortal() + maven("https://packages.jetbrains.team/maven/p/ij/intellij-dependencies/") } } \ No newline at end of file diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts new file mode 100644 index 0000000..d57e3d4 --- /dev/null +++ b/shared/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + id("org.jetbrains.intellij.platform.module") + id("java") + id("org.jetbrains.kotlin.jvm") + id("org.jetbrains.kotlin.plugin.serialization") + id("rpc") +} + +repositories { + intellijPlatform { + defaultRepositories() + } +} + +dependencies { + intellijPlatform { + intellijIdea(providers.gradleProperty("platformVersion")) + bundledModule("intellij.platform.rpc") + } +} diff --git a/src/main/java/implementation/gutter/Range.kt b/shared/src/main/java/implementation/gutter/Range.kt similarity index 100% rename from src/main/java/implementation/gutter/Range.kt rename to shared/src/main/java/implementation/gutter/Range.kt diff --git a/src/main/java/implementation/gutter/RangesBuilder.kt b/shared/src/main/java/implementation/gutter/RangesBuilder.kt similarity index 100% rename from src/main/java/implementation/gutter/RangesBuilder.kt rename to shared/src/main/java/implementation/gutter/RangesBuilder.kt diff --git a/shared/src/main/java/rpc/GutterRpcApi.kt b/shared/src/main/java/rpc/GutterRpcApi.kt new file mode 100644 index 0000000..89bccfd --- /dev/null +++ b/shared/src/main/java/rpc/GutterRpcApi.kt @@ -0,0 +1,32 @@ +package rpc + +import com.intellij.platform.project.ProjectId +import com.intellij.platform.rpc.RemoteApiProviderService +import fleet.rpc.RemoteApi +import fleet.rpc.Rpc +import fleet.rpc.remoteApiDescriptor +import kotlinx.coroutines.flow.Flow +import kotlinx.serialization.Serializable + +@Serializable +sealed class GutterUpdateEvent { + @Serializable + data class DataUpdated(val data: GutterFileDataDto) : GutterUpdateEvent() + + @Serializable + data class DataCleared(val filePath: String) : GutterUpdateEvent() + + @Serializable + data object AllCleared : GutterUpdateEvent() +} + +@Rpc +interface GutterRpcApi : RemoteApi { + suspend fun getGutterUpdates(projectId: ProjectId): Flow + + companion object { + suspend fun getInstance(): GutterRpcApi { + return RemoteApiProviderService.resolve(remoteApiDescriptor()) + } + } +} diff --git a/shared/src/main/java/rpc/GutterTopics.kt b/shared/src/main/java/rpc/GutterTopics.kt new file mode 100644 index 0000000..1dd9ebc --- /dev/null +++ b/shared/src/main/java/rpc/GutterTopics.kt @@ -0,0 +1,22 @@ +package rpc + +import kotlinx.serialization.Serializable + +@Serializable +data class GutterRangeDto( + val line1: Int, + val line2: Int, + val vcsLine1: Int, + val vcsLine2: Int +) + +@Serializable +data class GutterFileDataDto( + val filePath: String, + val ranges: List, + val baseContent: String, + val headContent: String? = null, + val scopeRanges: List? = null, + val scopeDisplayName: String = "", + val separateGutterRendering: Boolean = false +) diff --git a/shared/src/main/java/service/GutterDataService.java b/shared/src/main/java/service/GutterDataService.java new file mode 100644 index 0000000..f9450b9 --- /dev/null +++ b/shared/src/main/java/service/GutterDataService.java @@ -0,0 +1,124 @@ +package service; + +import com.intellij.openapi.Disposable; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.project.Project; +import implementation.gutter.Range; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import system.Defs; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * Shared project service that acts as a bridge between the backend (range computation) + * and the frontend (gutter rendering). + *

    + * In local IDE mode, both sides run in the same JVM and communicate via direct method calls. + * In remote development, this service is loaded on both sides; the platform handles + * data synchronization. + */ +public class GutterDataService implements Disposable { + + private static final Logger LOG = Defs.getLogger(GutterDataService.class); + + /** + * Immutable snapshot of gutter data for a single file. + */ + public static class GutterFileData { + public final @NotNull List ranges; + public final @NotNull String baseContent; + public final @Nullable String headContent; + public final @Nullable List scopeRanges; + + public GutterFileData(@NotNull List ranges, + @NotNull String baseContent, + @Nullable String headContent, + @Nullable List scopeRanges) { + this.ranges = ranges; + this.baseContent = baseContent; + this.headContent = headContent; + this.scopeRanges = scopeRanges; + } + } + + public interface Listener { + void onDataUpdated(@NotNull String filePath, @NotNull GutterFileData data); + void onDataCleared(@NotNull String filePath); + void onAllCleared(); + } + + private final Map fileDataMap = new ConcurrentHashMap<>(); + private final List listeners = new CopyOnWriteArrayList<>(); + private volatile @NotNull String scopeDisplayName = ""; + + @SuppressWarnings("unused") + 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)); + for (Listener l : listeners) l.onDataUpdated(filePath, data); + } + + public void clear(@NotNull String filePath) { + fileDataMap.remove(filePath); + LOG.info("GutterDataService.clear: file=" + filePath); + for (Listener l : listeners) l.onDataCleared(filePath); + } + + public void clearAll() { + fileDataMap.clear(); + for (Listener l : listeners) l.onAllCleared(); + } + + /** + * Re-notifies listeners for every cached file. Used when a setting that affects the + * rendered data (e.g. separateGutterRendering) changes, so the frontend re-renders + * without waiting for the next scope/document update. + */ + public void republishAll() { + for (Map.Entry e : fileDataMap.entrySet()) { + for (Listener l : listeners) l.onDataUpdated(e.getKey(), e.getValue()); + } + } + + public @Nullable GutterFileData getData(@NotNull String filePath) { + return fileDataMap.get(filePath); + } + + public @NotNull Map getAllData() { + return fileDataMap; + } + + public @NotNull String getScopeDisplayName() { + return scopeDisplayName; + } + + public void setScopeDisplayName(@NotNull String name) { + this.scopeDisplayName = name; + } + + public void addListener(@NotNull Listener listener) { + listeners.add(listener); + LOG.info("GutterDataService.addListener: " + listener.getClass().getSimpleName() + ", total=" + listeners.size()); + } + + public void removeListener(@NotNull Listener listener) { + listeners.remove(listener); + } + + @Override + public void dispose() { + listeners.clear(); + fileDataMap.clear(); + } +} diff --git a/src/main/java/settings/GitScopeSettings.java b/shared/src/main/java/settings/GitScopeSettings.java similarity index 100% rename from src/main/java/settings/GitScopeSettings.java rename to shared/src/main/java/settings/GitScopeSettings.java diff --git a/src/main/java/system/Defs.java b/shared/src/main/java/system/Defs.java similarity index 100% rename from src/main/java/system/Defs.java rename to shared/src/main/java/system/Defs.java diff --git a/src/main/java/utils/Notification.java b/shared/src/main/java/utils/Notification.java similarity index 100% rename from src/main/java/utils/Notification.java rename to shared/src/main/java/utils/Notification.java diff --git a/shared/src/main/java/utils/SharedReflection.java b/shared/src/main/java/utils/SharedReflection.java new file mode 100644 index 0000000..847c89d --- /dev/null +++ b/shared/src/main/java/utils/SharedReflection.java @@ -0,0 +1,74 @@ +package utils; + +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.editor.Editor; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import system.Defs; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.lang.reflect.Method; + +/** + * Reflection bridge for IntelliJ Platform APIs that {@code verifyPlugin} flags as + * internal. + * + *

    APIs bridged reflectively

    + * + *

    {@link #getGutterArea} — gutter marker alignment

    + *
      + *
    • {@code com.intellij.openapi.diff.LineStatusMarkerDrawUtil} (class)
    • + *
    • {@code LineStatusMarkerDrawUtil.getGutterArea(Editor)} (static method)
    • + *
    • {@code LineStatusMarkerDrawUtil$IntPair.first} / {@code .second} + * (public int fields on the return type)
    • + *
    + */ +public final class SharedReflection { + + private static final Logger LOG = Defs.getLogger(SharedReflection.class); + + private static final @Nullable MethodHandle GUTTER_GET_AREA; + private static final @Nullable MethodHandle INT_PAIR_FIRST; + private static final @Nullable MethodHandle INT_PAIR_SECOND; + + static { + MethodHandle gutterArea = null; + MethodHandle pairFirst = null; + MethodHandle pairSecond = null; + try { + Class drawUtilClass = Class.forName("com.intellij.openapi.diff.LineStatusMarkerDrawUtil"); + Method m = drawUtilClass.getMethod("getGutterArea", Editor.class); + gutterArea = MethodHandles.publicLookup().unreflect(m) + .asType(MethodType.genericMethodType(1)); + Class intPairClass = m.getReturnType(); + pairFirst = MethodHandles.publicLookup().unreflectGetter(intPairClass.getField("first")) + .asType(MethodType.methodType(int.class, Object.class)); + pairSecond = MethodHandles.publicLookup().unreflectGetter(intPairClass.getField("second")) + .asType(MethodType.methodType(int.class, Object.class)); + } catch (Exception e) { + LOG.debug("SharedReflection: LineStatusMarkerDrawUtil.getGutterArea not available — " + e.getMessage()); + } + GUTTER_GET_AREA = gutterArea; + INT_PAIR_FIRST = pairFirst; + INT_PAIR_SECOND = pairSecond; + } + + private SharedReflection() {} + + public static int @Nullable [] getGutterArea(@NotNull Editor editor) { + if (GUTTER_GET_AREA == null || INT_PAIR_FIRST == null || INT_PAIR_SECOND == null) { + return null; + } + try { + Object intPair = GUTTER_GET_AREA.invoke(editor); + int x = (int) INT_PAIR_FIRST.invoke(intPair); + int endX = (int) INT_PAIR_SECOND.invoke(intPair); + return new int[]{x, endX}; + } catch (Throwable t) { + LOG.warn("SharedReflection: getGutterArea invocation failed", t); + return null; + } + } +} diff --git a/shared/src/main/resources/gitscope.shared.xml b/shared/src/main/resources/gitscope.shared.xml new file mode 100644 index 0000000..bba1001 --- /dev/null +++ b/shared/src/main/resources/gitscope.shared.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/main/java/implementation/lineStatusTracker/MyLineStatusTrackerImpl.java b/src/main/java/implementation/lineStatusTracker/MyLineStatusTrackerImpl.java deleted file mode 100644 index 846141e..0000000 --- a/src/main/java/implementation/lineStatusTracker/MyLineStatusTrackerImpl.java +++ /dev/null @@ -1,702 +0,0 @@ -package implementation.lineStatusTracker; - -import com.intellij.openapi.Disposable; -import com.intellij.openapi.application.ApplicationManager; -import com.intellij.openapi.application.ModalityState; -import com.intellij.openapi.diagnostic.Logger; -import com.intellij.openapi.editor.Document; -import com.intellij.openapi.editor.Editor; -import com.intellij.openapi.editor.EditorFactory; -import com.intellij.openapi.editor.EditorKind; -import com.intellij.openapi.editor.event.DocumentEvent; -import com.intellij.openapi.editor.event.DocumentListener; -import com.intellij.openapi.fileEditor.FileDocumentManager; -import com.intellij.openapi.fileEditor.FileEditorManager; -import com.intellij.openapi.fileEditor.FileEditorManagerListener; -import com.intellij.openapi.project.Project; -import com.intellij.openapi.util.Computable; -import com.intellij.openapi.util.Disposer; -import com.intellij.openapi.util.text.StringUtil; -import com.intellij.openapi.vcs.VcsApplicationSettings; -import com.intellij.openapi.vcs.VcsException; -import com.intellij.openapi.vcs.changes.Change; -import com.intellij.openapi.vfs.VirtualFile; -import com.intellij.util.concurrency.SequentialTaskExecutor; -import com.intellij.util.messages.MessageBus; -import com.intellij.util.messages.MessageBusConnection; -import implementation.gutter.Range; -import implementation.gutter.RangesBuilder; -import implementation.gutter.ScopeLineStatusMarkerRenderer; -import org.jetbrains.annotations.NotNull; -import system.Defs; - -import java.util.*; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicLong; - -/** - * Manages custom line status markers for scope changes in editor gutters. - * Uses custom gutter painting instead of modifying IntelliJ's internal line status tracker. - */ -public class MyLineStatusTrackerImpl implements Disposable { - private static final Logger LOG = Defs.getLogger(MyLineStatusTrackerImpl.class); - - private final Project project; - private MessageBusConnection messageBusConnection; - private final AtomicBoolean disposing = new AtomicBoolean(false); - private final ExecutorService updateExecutor = - SequentialTaskExecutor.createSequentialApplicationPoolExecutor("ScopeGutterUpdate"); - private final AtomicLong updateGeneration = new AtomicLong(0); - - // Lightweight disposable token to check disposal state without capturing 'this' - private static class DisposalToken { - volatile boolean disposed = false; - } - private final DisposalToken disposalToken = new DisposalToken(); - - // Track per-document renderers and base content - private final Map renderers = new HashMap<>(); - - private static final class RendererInfo { - volatile ScopeLineStatusMarkerRenderer renderer; - volatile String baseContent; // Scope base revision content (e.g., HEAD~2) - volatile String headContent; // HEAD content (cached for fast filtering) - volatile List scopeRanges; // diff(HEAD, scopeBase) — stable between keystrokes - volatile DocumentListener documentListener; - - RendererInfo(ScopeLineStatusMarkerRenderer renderer, String baseContent) { - this.renderer = renderer; - this.baseContent = baseContent; - } - } - - @Override - public void dispose() { - disposalToken.disposed = true; - updateExecutor.shutdownNow(); - releaseAll(); - } - - public MyLineStatusTrackerImpl(Project project, Disposable parentDisposable) { - this.project = project; - - MessageBus messageBus = project.getMessageBus(); - this.messageBusConnection = messageBus.connect(); - - // Listen to file open/close events - messageBusConnection.subscribe( - FileEditorManagerListener.FILE_EDITOR_MANAGER, - new FileEditorManagerListener() { - @Override - public void fileOpened(@NotNull FileEditorManager fileEditorManager, @NotNull VirtualFile virtualFile) { - // Renderers are created on-demand when changes are detected - } - - @Override - public void fileClosed(@NotNull FileEditorManager source, @NotNull VirtualFile file) { - Document doc = ApplicationManager.getApplication().runReadAction( - (Computable) () -> FileDocumentManager.getInstance().getDocument(file)); - if (doc != null) { - release(doc); - } - } - } - ); - - Disposer.register(parentDisposable, this); - } - - private boolean isDiffView(Editor editor) { - return editor.getEditorKind() == EditorKind.DIFF; - } - - /** - * Updates line status markers for all editors based on scope changes. - * - * @param scopeChangesMap Map of file path to Change (scope or merged changes) - * @param localChangesMap Map of file path to Change (local changes only, may be null) - */ - public void update(Map scopeChangesMap, Map localChangesMap) { - if (scopeChangesMap == null || disposing.get()) { - return; - } - - final DisposalToken token = this.disposalToken; - final long gen = updateGeneration.incrementAndGet(); - - updateExecutor.execute(() -> { - // Drop stale update if a newer one has been submitted while we were queued - if (token.disposed || updateGeneration.get() != gen) return; - - Editor[] editors = EditorFactory.getInstance().getAllEditors(); - - // Filter editors that need updates - List editorsToUpdate = new ArrayList<>(); - for (Editor editor : editors) { - if (isDiffView(editor)) continue; - - Document doc = editor.getDocument(); - VirtualFile file = FileDocumentManager.getInstance().getFile(doc); - if (file == null) continue; - - String filePath = file.getPath(); - - // Process if file has scope changes OR we already have a renderer for it (to clear) - if (scopeChangesMap.containsKey(filePath) || renderers.containsKey(doc)) { - editorsToUpdate.add(editor); - } - } - - if (editorsToUpdate.isEmpty()) return; - - // Process all editors in parallel - Map updates = new ConcurrentHashMap<>(); - CountDownLatch latch = new CountDownLatch(editorsToUpdate.size()); - - for (Editor editor : editorsToUpdate) { - ApplicationManager.getApplication().executeOnPooledThread(() -> { - try { - if (!token.disposed) { - UpdateInfo updateInfo = prepareUpdateForEditor(editor, scopeChangesMap, localChangesMap); - if (updateInfo != null) { - updates.put(updateInfo.document, updateInfo); - } - } - } finally { - latch.countDown(); - } - }); - } - - // Wait for all parallel tasks to complete - try { - latch.await(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return; - } - - // Batch all EDT updates into a single invokeLater call - if (!updates.isEmpty() && !token.disposed) { - List updateList = new ArrayList<>(updates.values()); - ApplicationManager.getApplication().invokeLater(() -> { - if (!token.disposed) { - applyBatchedUpdates(updateList); - } - }, ModalityState.defaultModalityState(), __ -> token.disposed); - } - }); - } - - private static class UpdateInfo { - final Document document; - final VirtualFile file; - final String baseContent; - final String headContent; // Cached HEAD content (avoids VCS calls on every keystroke) - final List precomputedRanges; - - UpdateInfo(Document document, VirtualFile file, String baseContent, String headContent, List ranges) { - this.document = document; - this.file = file; - this.baseContent = baseContent; - this.headContent = headContent; - this.precomputedRanges = ranges; - } - } - - private UpdateInfo prepareUpdateForEditor(Editor editor, Map scopeChangesMap, Map localChangesMap) { - if (editor == null || disposing.get()) return null; - - Document doc = editor.getDocument(); - VirtualFile file = FileDocumentManager.getInstance().getFile(doc); - if (file == null) return null; - - String filePath = file.getPath(); - Change changeForFile = scopeChangesMap.get(filePath); - - // Determine if file has local changes - we'll filter them out from ranges - boolean hasLocalChanges = (localChangesMap != null && localChangesMap.containsKey(filePath)); - - String baseContent; - 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")); - - // Extract base content (target revision - e.g., HEAD~2) - try { - baseContent = changeForFile.getBeforeRevision().getContent(); - } catch (VcsException e) { - LOG.warn("Error getting content for revision: " + filePath, e); - baseContent = null; - } - - if (baseContent == null) { - baseContent = ApplicationManager.getApplication().runReadAction( - (Computable) () -> doc.getCharsSequence().toString()); - } - - // Always use current document content for comparison - // This is what's actually displayed in the editor - currentContent = ApplicationManager.getApplication().runReadAction( - (Computable) () -> doc.getImmutableCharSequence().toString() - ); - - } else { - // No scope change for this file - clear markers by using current content as base - baseContent = ApplicationManager.getApplication().runReadAction( - (Computable) () -> doc.getCharsSequence().toString()); - currentContent = baseContent; // No diff - LOG.debug("MyLineStatusTrackerImpl - File: " + filePath + ", no scope change - clearing markers"); - } - - // Precompute ranges off EDT - 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); - - // Extract and cache HEAD content if we have local changes (for fast filtering) - String headContent = null; - if (hasLocalChanges) { - Change localChange = localChangesMap.get(filePath); - if (localChange != null && localChange.getBeforeRevision() != null) { - try { - headContent = localChange.getBeforeRevision().getContent(); - if (headContent != null) { - headContent = StringUtil.convertLineSeparators(headContent); - } - } catch (VcsException e) { - LOG.warn("MyLineStatusTrackerImpl - Error caching HEAD content: " + e.getMessage()); - } - } - } - - List ranges; - try { - if (headContent != null) { - // Use HEAD as bridge: diff(HEAD, scopeBase) gives exact VCS coords, - // then map to current space using diff(current, HEAD) - ranges = computeScopeRangesInCurrentSpace(headContent, normalizedBase, normalizedCurrent, filePath); - } else { - // No local change info available: direct diff against scope base - 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()); - } - } catch (Exception e) { - LOG.error("Error precomputing ranges for: " + filePath, e); - ranges = Collections.emptyList(); - } - - return new UpdateInfo(doc, file, normalizedBase, headContent, ranges); - } - - private void applyBatchedUpdates(List updates) { - for (UpdateInfo update : updates) { - if (disposing.get()) break; - updateRendererWithPrecomputedRanges(update.document, update.file, update.baseContent, update.headContent, update.precomputedRanges); - } - } - - /** - * Updates or creates the renderer with precomputed ranges. - * Called on EDT; ranges were already computed off EDT for performance. - */ - private synchronized void updateRendererWithPrecomputedRanges( - @NotNull Document document, @NotNull VirtualFile file, - @NotNull String baseContent, String headContent, @NotNull List ranges) { - if (disposing.get()) return; - - try { - RendererInfo info = renderers.get(document); - if (info == null) { - ScopeLineStatusMarkerRenderer renderer = new ScopeLineStatusMarkerRenderer( - project, document, file, this); - info = new RendererInfo(renderer, baseContent); - renderers.put(document, info); - - DocumentListener documentListener = createDocumentListener(document, info); - info.documentListener = documentListener; - document.addDocumentListener(documentListener, this); - } else { - info.baseContent = baseContent; - } - - info.headContent = headContent; - - // Pre-compute diff(HEAD, scopeBase) once — both are fixed git revisions and - // never change while the user types, so keystrokes only need diff(current, HEAD). - if (headContent != null) { - info.scopeRanges = RangesBuilder.INSTANCE.createRanges(headContent, baseContent); - } else { - info.scopeRanges = null; - } - - // Let the diff viewer access the VCS base content for popups/rollback - info.renderer.setVcsBaseContent(info.baseContent); - - info.renderer.updateRanges(ranges); - - } catch (Exception e) { - LOG.error("Error updating line status renderer", e); - } - } - - /** - * Creates a document listener that recalculates scope ranges on every document change. - * Runs off EDT so it does not block typing. - */ - private DocumentListener createDocumentListener(@NotNull Document document, @NotNull RendererInfo info) { - return new DocumentListener() { - @Override - public void documentChanged(@NotNull DocumentEvent event) { - if (disposing.get()) return; - if (info.baseContent == null) return; - - ApplicationManager.getApplication().executeOnPooledThread(() -> { - if (!disposing.get()) { - recalculateAndUpdateRangesAsync(document, info); - } - }); - } - }; - } - - /** - * Computes scope ranges in current-document coordinate space using HEAD as a bridge. - * - *

    Uses a three-step algorithm to maintain exact VCS coordinates while accounting - * for uncommitted local changes: - *

      - *
    1. diff(HEAD, scopeBase) → pure scope ranges with exact VCS coords - *
    2. diff(current, HEAD) → local change ranges (HEAD-space line numbers) - *
    3. Map scope ranges from HEAD space into current-doc space, suppressing or splitting - * any sub-segments that overlap with local changes - *
    - * - *

    This ensures: - *

      - *
    • VCS coordinates are always exact for non-split ranges - *
    • Current-doc line positions are exact (cumulative-delta, not proportional) - *
    • Scope and local changes are never conflated in the same diff block - *
    • Partial-overlap is handled correctly: a scope range covering lines 1-4 - * where the user edits only line 4 continues to show lines 1-3 in the gutter - *
    - */ - private List computeScopeRangesInCurrentSpace( - String headContent, - String normalizedBase, - String currentContent, - String filePath) { - - // Step 1: pure scope changes — diff(HEAD, scopeBase) - // line1/line2 = HEAD lines - // vcsLine1/vcsLine2 = scopeBase lines (EXACT VCS coordinates) - List scopeRanges = RangesBuilder.INSTANCE.createRanges(headContent, normalizedBase); - LOG.debug("computeScopeRangesInCurrentSpace [" + filePath + "] scope ranges: " + scopeRanges.size()); - - if (scopeRanges.isEmpty()) { - return Collections.emptyList(); - } - - // Step 2: local changes — diff(current, HEAD) - // line1/line2 = current-doc lines - // vcsLine1/vcsLine2 = HEAD lines - List localRanges = RangesBuilder.INSTANCE.createRanges(currentContent, headContent); - LOG.debug("computeScopeRangesInCurrentSpace [" + filePath + "] local ranges: " + localRanges.size()); - - if (localRanges.isEmpty()) { - // HEAD == current: scope ranges are already valid in current-doc space - return new ArrayList<>(scopeRanges); - } - - // Step 3: map scope ranges from HEAD space to current-doc space - return mapScopeRangesToCurrentSpace(scopeRanges, localRanges, filePath); - } - - /** - * Translates scope ranges from HEAD coordinate space into current-document space, - * splitting or suppressing any portions that overlap with local changes. - * - * @param scopeRanges from diff(HEAD, scopeBase): line1/2=HEAD, vcsLine1/2=scopeBase - * @param localRanges from diff(current, HEAD): line1/2=current, vcsLine1/2=HEAD - */ - private List mapScopeRangesToCurrentSpace( - List scopeRanges, - List localRanges, - String filePath) { - - List result = new ArrayList<>(); - int numLocals = localRanges.size(); - - // cumulativeDelta: for HEAD lines not inside any local change, - // currentLine = headLine + cumulativeDelta - int cumulativeDelta = 0; - int localIdx = 0; - - for (Range scope : scopeRanges) { - int headStart = scope.getLine1(); - int headEnd = scope.getLine2(); - int vcsStart = scope.getVcsLine1(); - int vcsEnd = scope.getVcsLine2(); - - // Advance cumulativeDelta past all locals that end completely before headStart - while (localIdx < numLocals && localRanges.get(localIdx).getVcsLine2() <= headStart) { - Range local = localRanges.get(localIdx); - cumulativeDelta += (local.getLine2() - local.getLine1()) - - (local.getVcsLine2() - local.getVcsLine1()); - localIdx++; - } - - // ── DELETED scope range (0 HEAD lines, >0 VCS lines) ───────────────── - // A point in HEAD where scopeBase lines were removed; rendered as a - // deletion marker between two gutter lines. - if (headStart == headEnd) { - boolean insideLocal = false; - for (int i = localIdx; i < numLocals; i++) { - Range local = localRanges.get(i); - if (local.getVcsLine1() > headStart) break; // sorted; done - if (local.getVcsLine2() > headStart) { insideLocal = true; break; } - } - if (!insideLocal) { - int pos = headStart + cumulativeDelta; - result.add(new Range(pos, pos, vcsStart, vcsEnd)); - LOG.debug("mapScopeRangesToCurrentSpace [" + filePath - + "] DELETED at current=" + pos + " vcs=[" + vcsStart + "-" + vcsEnd + "]"); - } - continue; - } - - // ── INSERTED / MODIFIED scope range ────────────────────────────────── - int tempLocalIdx = localIdx; - int headCursor; - int currentCursor; - - // If a local change straddles headStart (started before, ends after), - // the scope range's beginning falls inside a local edit — skip past it. - if (tempLocalIdx < numLocals) { - Range straddle = localRanges.get(tempLocalIdx); - if (straddle.getVcsLine1() < headStart && straddle.getVcsLine2() > headStart) { - headCursor = straddle.getVcsLine2(); - currentCursor = straddle.getLine2(); - tempLocalIdx++; - } else { - headCursor = headStart; - currentCursor = headStart + cumulativeDelta; - } - } else { - headCursor = headStart; - currentCursor = headStart + cumulativeDelta; - } - - while (headCursor < headEnd) { - // Find the next local change that starts before headEnd - Range nextLocal = null; - if (tempLocalIdx < numLocals) { - Range candidate = localRanges.get(tempLocalIdx); - if (candidate.getVcsLine1() < headEnd) nextLocal = candidate; - } - - if (nextLocal != null) { - int localHeadStart = nextLocal.getVcsLine1(); - int localHeadEnd = nextLocal.getVcsLine2(); - - // Emit the clean scope segment that precedes this local change - if (localHeadStart > headCursor) { - emitScopeSegment(result, - headCursor, localHeadStart, - currentCursor, - headStart, headEnd, vcsStart, vcsEnd, filePath); - currentCursor += (localHeadStart - headCursor); - } - - // Suppress the portion of the scope range overlapped by the local change - headCursor = Math.max(headCursor, localHeadEnd); - currentCursor = nextLocal.getLine2(); - tempLocalIdx++; - - } else { - // No more overlapping local changes — emit the rest of the scope range - emitScopeSegment(result, - headCursor, headEnd, - currentCursor, - headStart, headEnd, vcsStart, vcsEnd, filePath); - headCursor = headEnd; - } - } - } - - LOG.debug("mapScopeRangesToCurrentSpace [" + filePath - + "] " + scopeRanges.size() + " scope → " + result.size() + " result ranges"); - return result; - } - - /** - * Emits one (possibly split) sub-segment of a scope range into {@code result}. - * - *

    When the full scope range is emitted unmodified, the VCS coordinates are exact - * (inherited from diff(HEAD, scopeBase)). - * - *

    When a MODIFIED scope block is split by a local change, the VCS coordinates of - * the sub-segment are estimated proportionally within the block. This is a bounded - * approximation — the split point is correct at both block boundaries, and the error - * cannot exceed the VCS extent of the block. - */ - private void emitScopeSegment( - List result, - int headSegStart, int headSegEnd, // HEAD lines for this segment - int currentStart, // current-doc start (exact, delta-based) - int headBlockStart, int headBlockEnd, // full scope block HEAD extents - int vcsBlockStart, int vcsBlockEnd, // full scope block VCS extents - String filePath) { - - int currentEnd = currentStart + (headSegEnd - headSegStart); - int headBlockLen = headBlockEnd - headBlockStart; - - int segVcsStart; - int segVcsEnd; - - if (headBlockLen == 0) { - // INSERTED block: no HEAD lines, no splitting possible — use full VCS extent - segVcsStart = vcsBlockStart; - segVcsEnd = vcsBlockEnd; - } else { - long vcsLen = vcsBlockEnd - vcsBlockStart; - segVcsStart = vcsBlockStart + (int)(vcsLen * (headSegStart - headBlockStart) / headBlockLen); - segVcsEnd = vcsBlockStart + (int)(vcsLen * (headSegEnd - headBlockStart) / headBlockLen); - } - - if (currentStart < currentEnd || segVcsStart < segVcsEnd) { - result.add(new Range(currentStart, currentEnd, segVcsStart, segVcsEnd)); - LOG.debug("emitScopeSegment [" + filePath - + "] current=[" + currentStart + "-" + currentEnd - + "] vcs=[" + segVcsStart + "-" + segVcsEnd + "]"); - } - } - - /** - * Recalculates ranges asynchronously (off EDT) and updates renderer on EDT. - * Applies filtering/splitting if local changes exist to show only scope changes. - * Uses cached HEAD content for performance (no VCS calls). - */ - private void recalculateAndUpdateRangesAsync(@NotNull Document document, @NotNull RendererInfo info) { - try { - // Respect the IDE gutter setting: if it's disabled, clear any displayed markers - if (!VcsApplicationSettings.getInstance().SHOW_LST_GUTTER_MARKERS) { - ApplicationManager.getApplication().invokeLater(() -> { - if (!disposing.get() && info.renderer != null) { - info.renderer.updateRanges(Collections.emptyList()); - } - }, ModalityState.defaultModalityState()); - return; - } - - // Get current document content from any thread - String currentContent = ApplicationManager.getApplication().runReadAction( - (Computable) () -> document.getImmutableCharSequence().toString() - ); - String normalizedCurrent = StringUtil.convertLineSeparators(currentContent); - - VirtualFile file = FileDocumentManager.getInstance().getFile(document); - String filePath = (file != null) ? file.getPath() : "unknown"; - - List filteredRanges; - if (info.headContent != null && info.scopeRanges != null) { - // Fast path: diff(HEAD, scopeBase) is cached; only diff(current, HEAD) + mapping runs per keystroke. - List localRanges = RangesBuilder.INSTANCE.createRanges(normalizedCurrent, info.headContent); - if (localRanges.isEmpty()) { - filteredRanges = new ArrayList<>(info.scopeRanges); - } else { - filteredRanges = mapScopeRangesToCurrentSpace(info.scopeRanges, localRanges, filePath); - } - } else { - filteredRanges = RangesBuilder.INSTANCE.createRanges(normalizedCurrent, info.baseContent); - } - - // Update the renderer on EDT - final List rangesToApply = filteredRanges; - ApplicationManager.getApplication().invokeLater(() -> { - if (!disposing.get() && info.renderer != null) { - info.renderer.updateRanges(rangesToApply); - } - }, ModalityState.defaultModalityState()); - - } catch (Exception e) { - LOG.error("Error recalculating ranges", e); - } - } - - /** - * Release renderer for a specific document. - */ - private synchronized void release(@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); - } - } - - if (info.renderer != null) { - try { - info.renderer.dispose(); - } catch (Exception e) { - LOG.warn("Error disposing renderer for document", e); - } - } - } - } - - /** - * Release all renderers. - */ - public void releaseAll() { - if (!disposing.compareAndSet(false, true)) { - return; // already disposing - } - - if (messageBusConnection != null) { - messageBusConnection.disconnect(); - } - - Runnable release = () -> { - for (Map.Entry entry : renderers.entrySet()) { - RendererInfo info = entry.getValue(); - if (info != null && info.renderer != null) { - try { - info.renderer.dispose(); - } catch (Exception e) { - LOG.warn("Error disposing renderer", e); - } - } - } - renderers.clear(); - }; - - if (ApplicationManager.getApplication().isDispatchThread()) { - release.run(); - } else { - ApplicationManager.getApplication().invokeAndWait(release); - } - - // Null out references to prevent retention - messageBusConnection = null; - } -} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 82dea03..ddb29d1 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -1,4 +1,4 @@ - + Git Scope Git Scope WOELKIT, M.Wållberg @@ -28,81 +28,10 @@ ]]> auto - com.intellij.modules.platform - com.intellij.modules.lang - com.intellij.modules.vcs - Git4Idea + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + From 590ddb8f5c584ed22465813df43c2b8e5f97c01c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20W=C3=A5llberg?= Date: Thu, 18 Jun 2026 13:22:26 +0200 Subject: [PATCH 02/16] feat: Show in Project activates Project view and supports folders Add UtilRpcApi with NavigationCommand flow so Show in Project works in both monolith and split mode. The backend emits navigation commands via NavigationCommandService, and the frontend subscribes and activates the Project tool window with focus. Directory detection uses the tree node's FilePath to distinguish folder nodes from file nodes, navigating to the folder itself rather than picking an arbitrary child file. Closes #100 --- .../src/main/java/rpc/BackendUtilRpcImpl.kt | 24 ++++++++++ .../main/java/rpc/NavigationCommandService.kt | 15 ++++++ .../main/java/toolwindow/VcsTreeActions.java | 31 +++++++++--- .../src/main/resources/gitscope.backend.xml | 2 + .../main/java/rpc/FrontendGutterListeners.kt | 5 +- .../main/java/rpc/FrontendUtilListeners.kt | 47 +++++++++++++++++++ .../src/main/resources/gitscope.frontend.xml | 1 + shared/src/main/java/rpc/UtilRpcApi.kt | 23 +++++++++ 8 files changed, 140 insertions(+), 8 deletions(-) create mode 100644 backend/src/main/java/rpc/BackendUtilRpcImpl.kt create mode 100644 backend/src/main/java/rpc/NavigationCommandService.kt create mode 100644 frontend/src/main/java/rpc/FrontendUtilListeners.kt create mode 100644 shared/src/main/java/rpc/UtilRpcApi.kt diff --git a/backend/src/main/java/rpc/BackendUtilRpcImpl.kt b/backend/src/main/java/rpc/BackendUtilRpcImpl.kt new file mode 100644 index 0000000..39c2795 --- /dev/null +++ b/backend/src/main/java/rpc/BackendUtilRpcImpl.kt @@ -0,0 +1,24 @@ +package rpc + +import com.intellij.openapi.components.service +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.flow.Flow +import kotlinx.coroutines.flow.emptyFlow + +class BackendUtilRpcImpl : UtilRpcApi { + override suspend fun getNavigationCommands(projectId: ProjectId): Flow { + val project = projectId.findProjectOrNull() ?: return emptyFlow() + return project.service().commands + } +} + +class BackendUtilRpcProvider : RemoteApiProvider { + override fun RemoteApiProvider.Sink.remoteApis() { + remoteApi(remoteApiDescriptor()) { + BackendUtilRpcImpl() + } + } +} diff --git a/backend/src/main/java/rpc/NavigationCommandService.kt b/backend/src/main/java/rpc/NavigationCommandService.kt new file mode 100644 index 0000000..a03450a --- /dev/null +++ b/backend/src/main/java/rpc/NavigationCommandService.kt @@ -0,0 +1,15 @@ +package rpc + +import com.intellij.openapi.components.Service +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +@Service(Service.Level.PROJECT) +class NavigationCommandService { + private val _commands = MutableSharedFlow(extraBufferCapacity = 1) + val commands = _commands.asSharedFlow() + + fun selectInProject(filePath: String) { + _commands.tryEmit(NavigationCommand(filePath)) + } +} diff --git a/backend/src/main/java/toolwindow/VcsTreeActions.java b/backend/src/main/java/toolwindow/VcsTreeActions.java index 7beb8ea..38c87fa 100644 --- a/backend/src/main/java/toolwindow/VcsTreeActions.java +++ b/backend/src/main/java/toolwindow/VcsTreeActions.java @@ -35,21 +35,40 @@ public void actionPerformed(@NotNull AnActionEvent e) { Change[] changes = e.getData(VcsDataKeys.CHANGES); if (project != null && changes != null && changes.length > 0) { - Change change = changes[0]; VirtualFile file = null; - if (change.getAfterRevision() != null && change.getAfterRevision().getFile().getVirtualFile() != null) { - file = change.getAfterRevision().getFile().getVirtualFile(); - } else if (change.getBeforeRevision() != null && change.getBeforeRevision().getFile().getVirtualFile() != null) { - file = change.getBeforeRevision().getFile().getVirtualFile(); + // Check if the selected tree node is a directory + com.intellij.openapi.vcs.changes.ui.ChangesBrowserBase browser = + e.getData(com.intellij.openapi.vcs.changes.ui.ChangesBrowserBase.DATA_KEY); + if (browser != null) { + Object node = browser.getViewer().getLastSelectedPathComponent(); + if (node instanceof com.intellij.openapi.vcs.changes.ui.ChangesBrowserNode cbn) { + Object userObject = cbn.getUserObject(); + if (userObject instanceof com.intellij.openapi.vcs.FilePath fp && fp.isDirectory() && fp.getVirtualFile() != null) { + file = fp.getVirtualFile(); + } + } + } + + if (file == null) { + file = getFileFromChange(changes[0]); } if (file != null) { - ProjectView.getInstance(project).select(null, file, true); + project.getService(rpc.NavigationCommandService.class).selectInProject(file.getPath()); } } } + private VirtualFile getFileFromChange(Change change) { + if (change.getAfterRevision() != null && change.getAfterRevision().getFile().getVirtualFile() != null) { + return change.getAfterRevision().getFile().getVirtualFile(); + } else if (change.getBeforeRevision() != null && change.getBeforeRevision().getFile().getVirtualFile() != null) { + return change.getBeforeRevision().getFile().getVirtualFile(); + } + return null; + } + @Override public void update(@NotNull AnActionEvent e) { Change[] changes = e.getData(VcsDataKeys.CHANGES); diff --git a/backend/src/main/resources/gitscope.backend.xml b/backend/src/main/resources/gitscope.backend.xml index 087003c..1f507b9 100644 --- a/backend/src/main/resources/gitscope.backend.xml +++ b/backend/src/main/resources/gitscope.backend.xml @@ -47,12 +47,14 @@ + + diff --git a/frontend/src/main/java/rpc/FrontendGutterListeners.kt b/frontend/src/main/java/rpc/FrontendGutterListeners.kt index cf705ff..c490d20 100644 --- a/frontend/src/main/java/rpc/FrontendGutterListeners.kt +++ b/frontend/src/main/java/rpc/FrontendGutterListeners.kt @@ -18,8 +18,9 @@ class FrontendGutterSubscriptions( private val coroutineScope: CoroutineScope ) { init { - // Only subscribe via RPC in split mode — in monolithic mode, - // GutterRenderingService listens to GutterDataService directly. + // 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 { diff --git a/frontend/src/main/java/rpc/FrontendUtilListeners.kt b/frontend/src/main/java/rpc/FrontendUtilListeners.kt new file mode 100644 index 0000000..20ff433 --- /dev/null +++ b/frontend/src/main/java/rpc/FrontendUtilListeners.kt @@ -0,0 +1,47 @@ +package rpc + +import com.intellij.ide.projectView.ProjectView +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.openapi.startup.ProjectActivity +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.openapi.wm.ToolWindowId +import com.intellij.openapi.wm.ToolWindowManager +import com.intellij.platform.project.projectId +import fleet.rpc.client.durable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@Service(Service.Level.PROJECT) +class FrontendUtilSubscriptions( + private val project: Project, + private val coroutineScope: CoroutineScope +) { + init { + coroutineScope.launch { + durable { + UtilRpcApi.getInstance() + .getNavigationCommands(project.projectId()) + .collect { cmd -> handleNavigation(cmd) } + } + } + } + + private fun handleNavigation(cmd: NavigationCommand) { + ApplicationManager.getApplication().invokeLater { + val file = LocalFileSystem.getInstance().findFileByPath(cmd.filePath) ?: return@invokeLater + val tw = ToolWindowManager.getInstance(project).getToolWindow(ToolWindowId.PROJECT_VIEW) ?: return@invokeLater + tw.activate({ + ProjectView.getInstance(project).select(null, file, true) + }, true, true) + } + } +} + +class FrontendUtilSubscriptionsStartup : 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 093aec5..c3ec1e1 100644 --- a/frontend/src/main/resources/gitscope.frontend.xml +++ b/frontend/src/main/resources/gitscope.frontend.xml @@ -9,5 +9,6 @@ + diff --git a/shared/src/main/java/rpc/UtilRpcApi.kt b/shared/src/main/java/rpc/UtilRpcApi.kt new file mode 100644 index 0000000..f84727d --- /dev/null +++ b/shared/src/main/java/rpc/UtilRpcApi.kt @@ -0,0 +1,23 @@ +package rpc + +import com.intellij.platform.project.ProjectId +import com.intellij.platform.rpc.RemoteApiProviderService +import fleet.rpc.RemoteApi +import fleet.rpc.Rpc +import fleet.rpc.remoteApiDescriptor +import kotlinx.coroutines.flow.Flow +import kotlinx.serialization.Serializable + +@Serializable +data class NavigationCommand(val filePath: String) + +@Rpc +interface UtilRpcApi : RemoteApi { + suspend fun getNavigationCommands(projectId: ProjectId): Flow + + companion object { + suspend fun getInstance(): UtilRpcApi { + return RemoteApiProviderService.resolve(remoteApiDescriptor()) + } + } +} From 87884dbbdc00eeef6f7bd43cf4515533ec7aaf7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20W=C3=A5llberg?= Date: Mon, 22 Jun 2026 00:54:08 +0200 Subject: [PATCH 03/16] feat: route preview tab and file navigation through UtilRpcApi Replace NavigationCommandService with UtilCommandService using a sealed UtilCommand class (SelectInProject, OpenPreviewTab) for extensibility. Preview tab now opens via RPC on the frontend using OpenFileDescriptor.setUsePreviewTab(true), fixing split mode where the backend cannot open editor tabs directly. Send file URLs instead of paths to ensure proper VirtualFile resolution on the frontend in split mode. Skip UISettings.getOpenInPreviewTabIfPossible() check in split mode since that setting is a frontend concern not synced to backend. Fix PlatformApiReflection for Kotlin @JvmOverloads synthetic constructor (int defaultsMask + DefaultConstructorMarker params) in CLion 2026.1+. --- .../src/main/java/rpc/BackendUtilRpcImpl.kt | 4 +- .../main/java/rpc/NavigationCommandService.kt | 15 ------ .../src/main/java/rpc/UtilCommandService.kt | 20 ++++++++ .../main/java/toolwindow/VcsTreeActions.java | 2 +- .../elements/MySimpleChangesBrowser.java | 51 +++++++------------ .../src/main/resources/gitscope.backend.xml | 2 +- .../main/java/rpc/FrontendUtilListeners.kt | 36 ++++++++++--- .../src/main/resources/gitscope.frontend.xml | 1 + shared/src/main/java/rpc/UtilRpcApi.kt | 10 +++- 9 files changed, 80 insertions(+), 61 deletions(-) delete mode 100644 backend/src/main/java/rpc/NavigationCommandService.kt create mode 100644 backend/src/main/java/rpc/UtilCommandService.kt diff --git a/backend/src/main/java/rpc/BackendUtilRpcImpl.kt b/backend/src/main/java/rpc/BackendUtilRpcImpl.kt index 39c2795..c923186 100644 --- a/backend/src/main/java/rpc/BackendUtilRpcImpl.kt +++ b/backend/src/main/java/rpc/BackendUtilRpcImpl.kt @@ -9,9 +9,9 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow class BackendUtilRpcImpl : UtilRpcApi { - override suspend fun getNavigationCommands(projectId: ProjectId): Flow { + override suspend fun getCommands(projectId: ProjectId): Flow { val project = projectId.findProjectOrNull() ?: return emptyFlow() - return project.service().commands + return project.service().commands } } diff --git a/backend/src/main/java/rpc/NavigationCommandService.kt b/backend/src/main/java/rpc/NavigationCommandService.kt deleted file mode 100644 index a03450a..0000000 --- a/backend/src/main/java/rpc/NavigationCommandService.kt +++ /dev/null @@ -1,15 +0,0 @@ -package rpc - -import com.intellij.openapi.components.Service -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.asSharedFlow - -@Service(Service.Level.PROJECT) -class NavigationCommandService { - private val _commands = MutableSharedFlow(extraBufferCapacity = 1) - val commands = _commands.asSharedFlow() - - fun selectInProject(filePath: String) { - _commands.tryEmit(NavigationCommand(filePath)) - } -} diff --git a/backend/src/main/java/rpc/UtilCommandService.kt b/backend/src/main/java/rpc/UtilCommandService.kt new file mode 100644 index 0000000..660b848 --- /dev/null +++ b/backend/src/main/java/rpc/UtilCommandService.kt @@ -0,0 +1,20 @@ +package rpc + +import com.intellij.openapi.components.Service +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +@Service(Service.Level.PROJECT) +class UtilCommandService { + private val _commands = MutableSharedFlow(extraBufferCapacity = 1) + val commands = _commands.asSharedFlow() + + fun selectInProject(filePath: String) { + _commands.tryEmit(UtilCommand.SelectInProject(filePath)) + } + + @JvmOverloads + fun openPreviewTab(filePath: String, line: Int = -1) { + _commands.tryEmit(UtilCommand.OpenPreviewTab(filePath, line)) + } +} diff --git a/backend/src/main/java/toolwindow/VcsTreeActions.java b/backend/src/main/java/toolwindow/VcsTreeActions.java index 38c87fa..a807d2e 100644 --- a/backend/src/main/java/toolwindow/VcsTreeActions.java +++ b/backend/src/main/java/toolwindow/VcsTreeActions.java @@ -55,7 +55,7 @@ public void actionPerformed(@NotNull AnActionEvent e) { } if (file != null) { - project.getService(rpc.NavigationCommandService.class).selectInProject(file.getPath()); + project.getService(rpc.UtilCommandService.class).selectInProject(file.getUrl()); } } } diff --git a/backend/src/main/java/toolwindow/elements/MySimpleChangesBrowser.java b/backend/src/main/java/toolwindow/elements/MySimpleChangesBrowser.java index 6e95bad..bcc983c 100644 --- a/backend/src/main/java/toolwindow/elements/MySimpleChangesBrowser.java +++ b/backend/src/main/java/toolwindow/elements/MySimpleChangesBrowser.java @@ -30,7 +30,6 @@ import javax.swing.*; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; -import utils.PlatformApiReflection; import java.util.*; import java.util.concurrent.*; @@ -117,7 +116,8 @@ public void mouseClicked(MouseEvent e) { Change[] selectedChanges = getSelectedChanges().toArray(new Change[0]); - if (!uiSettings.getOpenInPreviewTabIfPossible()) { + if (!uiSettings.getOpenInPreviewTabIfPossible() + && com.intellij.platform.ide.productMode.IdeProductMode.isMonolith()) { return; } @@ -135,7 +135,7 @@ public void mouseClicked(MouseEvent e) { } private void openInPreviewTab(Project project, VirtualFile file) { - PlatformApiReflection.openInPreviewTab(project, file); + project.getService(rpc.UtilCommandService.class).openPreviewTab(file.getUrl(), -1); } /** @@ -182,38 +182,25 @@ public static CompletableFuture createAsync(@NotNull Pro * @param isPreview Whether to open in preview tab */ public void openAndScrollToChanges(Project project, VirtualFile file, int line, boolean isPreview) { - ApplicationManager.getApplication().invokeLater(() -> { - if (project.isDisposed()) return; - - FileEditor[] editors; - - if (isPreview) { - // For preview, we don't have a reliable fallback, so just use the reflection method - // which we already call in openInPreviewTab. Here we'll just use standard API. - editors = FileEditorManager.getInstance(project).openFile(file, true); - LOG.debug("Opened file (fallback to regular tab): " + file.getName()); - } else { - // Use standard API for regular tabs - editors = FileEditorManager.getInstance(project).openFile(file, true); - LOG.debug("Opened file in regular tab: " + file.getName()); - } - - // Scroll to specific line if provided - for (FileEditor fileEditor : editors) { - if (fileEditor instanceof TextEditor) { - Editor editor = ((TextEditor) fileEditor).getEditor(); + if (isPreview) { + project.getService(rpc.UtilCommandService.class).openPreviewTab(file.getUrl(), line); + } else { + ApplicationManager.getApplication().invokeLater(() -> { + if (project.isDisposed()) return; + FileEditor[] editors = FileEditorManager.getInstance(project).openFile(file, true); - // Move caret to the specific line if needed - if (line > 0) { - LogicalPosition pos = new LogicalPosition(line - 1, 0); - editor.getCaretModel().moveToLogicalPosition(pos); + for (FileEditor fileEditor : editors) { + if (fileEditor instanceof TextEditor) { + Editor editor = ((TextEditor) fileEditor).getEditor(); + if (line > 0) { + LogicalPosition pos = new LogicalPosition(line - 1, 0); + editor.getCaretModel().moveToLogicalPosition(pos); + } + editor.getScrollingModel().scrollToCaret(ScrollType.CENTER); } - - // Center the view on caret - editor.getScrollingModel().scrollToCaret(ScrollType.CENTER); } - } - }); + }); + } } /** diff --git a/backend/src/main/resources/gitscope.backend.xml b/backend/src/main/resources/gitscope.backend.xml index 1f507b9..2249465 100644 --- a/backend/src/main/resources/gitscope.backend.xml +++ b/backend/src/main/resources/gitscope.backend.xml @@ -47,7 +47,7 @@ - + handleNavigation(cmd) } + .getCommands(project.projectId()) + .collect { cmd -> handleCommand(cmd) } } } } - private fun handleNavigation(cmd: NavigationCommand) { + private fun handleCommand(cmd: UtilCommand) { ApplicationManager.getApplication().invokeLater { - val file = LocalFileSystem.getInstance().findFileByPath(cmd.filePath) ?: return@invokeLater - val tw = ToolWindowManager.getInstance(project).getToolWindow(ToolWindowId.PROJECT_VIEW) ?: return@invokeLater - tw.activate({ - ProjectView.getInstance(project).select(null, file, true) - }, true, true) + when (cmd) { + is UtilCommand.SelectInProject -> selectInProject(cmd.filePath) + is UtilCommand.OpenPreviewTab -> openPreviewTab(cmd.filePath, cmd.line) + } } } + + private fun selectInProject(filePath: String) { + val file = com.intellij.openapi.vfs.VirtualFileManager.getInstance().findFileByUrl(filePath) + ?: LocalFileSystem.getInstance().findFileByPath(filePath) + ?: return + val tw = ToolWindowManager.getInstance(project).getToolWindow(ToolWindowId.PROJECT_VIEW) ?: return + tw.activate({ + ProjectView.getInstance(project).select(null, file, true) + }, true, true) + } + + private fun openPreviewTab(filePath: String, line: Int) { + val file = com.intellij.openapi.vfs.VirtualFileManager.getInstance().findFileByUrl(filePath) + ?: LocalFileSystem.getInstance().findFileByPath(filePath) + ?: return + val descriptor = if (line >= 0) OpenFileDescriptor(project, file, line, 0) else OpenFileDescriptor(project, file) + descriptor.setUsePreviewTab(true) + FileEditorManager.getInstance(project).openEditor(descriptor, true) + } } class FrontendUtilSubscriptionsStartup : ProjectActivity { diff --git a/frontend/src/main/resources/gitscope.frontend.xml b/frontend/src/main/resources/gitscope.frontend.xml index c3ec1e1..7aa0a78 100644 --- a/frontend/src/main/resources/gitscope.frontend.xml +++ b/frontend/src/main/resources/gitscope.frontend.xml @@ -7,6 +7,7 @@ + diff --git a/shared/src/main/java/rpc/UtilRpcApi.kt b/shared/src/main/java/rpc/UtilRpcApi.kt index f84727d..3228738 100644 --- a/shared/src/main/java/rpc/UtilRpcApi.kt +++ b/shared/src/main/java/rpc/UtilRpcApi.kt @@ -9,11 +9,17 @@ import kotlinx.coroutines.flow.Flow import kotlinx.serialization.Serializable @Serializable -data class NavigationCommand(val filePath: String) +sealed class UtilCommand { + @Serializable + data class SelectInProject(val filePath: String) : UtilCommand() + + @Serializable + data class OpenPreviewTab(val filePath: String, val line: Int = -1) : UtilCommand() +} @Rpc interface UtilRpcApi : RemoteApi { - suspend fun getNavigationCommands(projectId: ProjectId): Flow + suspend fun getCommands(projectId: ProjectId): Flow companion object { suspend fun getInstance(): UtilRpcApi { From 3e8f91d02b3e3d2e8a9fc44fbe8c289adc71d200 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20W=C3=A5llberg?= Date: Mon, 22 Jun 2026 00:57:22 +0200 Subject: [PATCH 04/16] chore: disable unreleased section in changelog plugin --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 41004a0..47f5e40 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -55,7 +55,7 @@ changelog { version.set(properties("pluginVersion")) groups.set(emptyList()) headerParserRegex.set("""(\d{4}\.\d+(?:\.\d+)?)""".toRegex()) - keepUnreleasedSection.set(true) + keepUnreleasedSection.set(false) } dependencies { From 001e624c1ab28ef9f18b0cfd286afbe9aea8532b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20W=C3=A5llberg?= Date: Tue, 7 Jul 2026 20:48:24 +0200 Subject: [PATCH 05/16] Align gutter marker sizing and hover with IntelliJ IDE behavior - Use fixed JBUI.scale(3) for hover expansion, matching LineStatusMarkerDrawUtil.getHoveredMarkerExtraWidth() - Add editor font zoom scaling to fallback gutter width calculation via EditorImpl.getScale(), matching scaleWithEditor() in the IDE - Change renderer position from LEFT to CUSTOM to match the IDE's LineStatusGutterMarkerRenderer and avoid unintended left free painters area expansion - Use marker width as corner arc radius (pill shape) to match the IDE's RectanglePainter2D rendering style --- .../gutter/LineStatusGutterMarkerRenderer.kt | 23 +++++++++++-------- .../gutter/ScopeLineStatusMarkerRenderer.kt | 2 +- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/frontend/src/main/java/implementation/gutter/LineStatusGutterMarkerRenderer.kt b/frontend/src/main/java/implementation/gutter/LineStatusGutterMarkerRenderer.kt index 6b6f976..25d4d2d 100644 --- a/frontend/src/main/java/implementation/gutter/LineStatusGutterMarkerRenderer.kt +++ b/frontend/src/main/java/implementation/gutter/LineStatusGutterMarkerRenderer.kt @@ -8,9 +8,12 @@ import com.intellij.openapi.editor.LogicalPosition import com.intellij.openapi.editor.colors.EditorColors import com.intellij.openapi.editor.ex.EditorEx import com.intellij.openapi.editor.ex.EditorGutterComponentEx +import com.intellij.openapi.editor.impl.EditorImpl import com.intellij.openapi.editor.markup.ActiveGutterRenderer import com.intellij.openapi.editor.markup.LineMarkerRendererEx import com.intellij.ui.JBColor +import com.intellij.ui.paint.PaintUtil +import com.intellij.ui.scale.JBUIScale import com.intellij.util.ui.JBUI import settings.GitScopeSettings import java.awt.Graphics @@ -31,7 +34,8 @@ internal fun getGutterArea(editor: EditorEx): Pair { // Fallback: derive from public APIs (new UI only — old UI was removed in 2024.1) val gutter = editor.gutterComponentEx - val areaWidth = JBUI.scale(4) + val editorScale = if (editor is EditorImpl) editor.scale else 1.0f + val areaWidth = PaintUtil.RoundingMode.ROUND.round(JBUIScale.scale(4).toDouble() * editorScale) val endX = gutter.whitespaceSeparatorOffset return Pair(endX - areaWidth, endX) } @@ -74,7 +78,7 @@ abstract class LineStatusGutterMarkerRenderer : LineMarkerRendererEx, ActiveGutt paintDefault(editor as EditorEx, g, ranges) } - override fun getPosition() = LineMarkerRendererEx.Position.LEFT + override fun getPosition() = LineMarkerRendererEx.Position.CUSTOM /** * Default painting logic for line status markers. @@ -113,8 +117,9 @@ abstract class LineStatusGutterMarkerRenderer : LineMarkerRendererEx, ActiveGutt // (correct on both Windows and Linux without hardcoded pixel guesses). val gutterArea = getGutterArea(editor) val areaWidth = gutterArea.second - gutterArea.first - // Hover expands by (areaWidth - 1 px), matching the IDE's own VCS marker behaviour. - val hoverExpansion = maxOf(areaWidth - JBUI.scale(1), JBUI.scale(1)) + // Hover expands by a fixed JBUI.scale(3), matching the IDE's own VCS marker behaviour + // (see LineStatusMarkerDrawUtil.getHoveredMarkerExtraWidth()). + val hoverExpansion = JBUI.scale(3) val x: Int val width: Int @@ -134,11 +139,9 @@ abstract class LineStatusGutterMarkerRenderer : LineMarkerRendererEx, ActiveGutt width = gutterArea.second - x } - // Arc size for rounded corners - // Use larger arc for hovered state to make it more visually distinct - val normalArcSize = JBUI.scale(5) - val hoveredArcSize = JBUI.scale(7) // More rounded when hovered - val arcSize = if (isHovered) hoveredArcSize else normalArcSize + // Corner radius matches the IDE's RectanglePainter2D approach: use the marker width + // as the arc, producing a pill shape that scales naturally with the marker size. + val arcSize = width val bounds = rangeYBounds(editor, range) g2d.fillRoundRect(x, bounds.first, width, bounds.last - bounds.first, arcSize, arcSize) @@ -183,7 +186,7 @@ abstract class LineStatusGutterMarkerRenderer : LineMarkerRendererEx, ActiveGutt val settings = GitScopeSettings.getInstance() val gutterArea = getGutterArea(editorEx) val areaWidth = gutterArea.second - gutterArea.first - val hoverExpansion = maxOf(areaWidth - JBUI.scale(1), JBUI.scale(1)) + val hoverExpansion = JBUI.scale(3) if (settings.isSeparateGutterRendering) { val markerX = maxOf(gutter.annotationsAreaOffset, gutter.annotationsAreaOffset + gutter.annotationsAreaWidth - areaWidth) diff --git a/frontend/src/main/java/implementation/gutter/ScopeLineStatusMarkerRenderer.kt b/frontend/src/main/java/implementation/gutter/ScopeLineStatusMarkerRenderer.kt index fe796e7..9abc18f 100644 --- a/frontend/src/main/java/implementation/gutter/ScopeLineStatusMarkerRenderer.kt +++ b/frontend/src/main/java/implementation/gutter/ScopeLineStatusMarkerRenderer.kt @@ -144,7 +144,7 @@ class ScopeLineStatusMarkerRenderer( val gitScopeSettings = settings.GitScopeSettings.getInstance() val gutterArea = getGutterArea(editor) val areaWidth = gutterArea.second - gutterArea.first - val hoverExpansion = maxOf(areaWidth - JBUI.scale(1), JBUI.scale(1)) + val hoverExpansion = JBUI.scale(3) val inMarkerArea: Boolean if (gitScopeSettings.isSeparateGutterRendering) { From 7d316ccd54b330c4254a8120f1c012e45784aa8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20W=C3=A5llberg?= Date: Mon, 20 Jul 2026 22:56:18 +0200 Subject: [PATCH 06/16] fix: single-click open via canonical FileEditorManager, gated by preview-tab setting Open files through FileEditorManagerEx.openFile (the same per-client path Project View uses): full editor init, IDE + scope gutters, and no duplicate tab in split mode. Respect the 'Enable preview tab' setting in both modes: read UISettings directly in monolith; in split mode use the value the frontend pushes over a new setPreviewTabEnabled RPC, since the backend UISettings is not synced there. --- .../src/main/java/rpc/BackendUtilRpcImpl.kt | 5 ++ .../src/main/java/rpc/UtilCommandService.kt | 16 +++- .../elements/MySimpleChangesBrowser.java | 73 ++++++++++++++++--- .../main/java/rpc/FrontendUtilListeners.kt | 33 ++++++--- shared/src/main/java/rpc/UtilRpcApi.kt | 11 ++- 5 files changed, 109 insertions(+), 29 deletions(-) diff --git a/backend/src/main/java/rpc/BackendUtilRpcImpl.kt b/backend/src/main/java/rpc/BackendUtilRpcImpl.kt index c923186..756f3c3 100644 --- a/backend/src/main/java/rpc/BackendUtilRpcImpl.kt +++ b/backend/src/main/java/rpc/BackendUtilRpcImpl.kt @@ -13,6 +13,11 @@ class BackendUtilRpcImpl : UtilRpcApi { val project = projectId.findProjectOrNull() ?: return emptyFlow() return project.service().commands } + + override suspend fun setPreviewTabEnabled(projectId: ProjectId, enabled: Boolean) { + val project = projectId.findProjectOrNull() ?: return + project.service().setPreviewTabEnabled(enabled) + } } class BackendUtilRpcProvider : RemoteApiProvider { diff --git a/backend/src/main/java/rpc/UtilCommandService.kt b/backend/src/main/java/rpc/UtilCommandService.kt index 660b848..45faa74 100644 --- a/backend/src/main/java/rpc/UtilCommandService.kt +++ b/backend/src/main/java/rpc/UtilCommandService.kt @@ -3,18 +3,28 @@ package rpc import com.intellij.openapi.components.Service import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow +import java.util.concurrent.atomic.AtomicReference @Service(Service.Level.PROJECT) class UtilCommandService { private val _commands = MutableSharedFlow(extraBufferCapacity = 1) val commands = _commands.asSharedFlow() + /** + * The frontend's "Enable preview tab" setting, pushed over RPC. null = not yet reported. + * Used by single-click handling to decide whether to open a file, because in split mode the + * backend's own UISettings is not synced with the frontend. + */ + private val previewTabEnabled = AtomicReference(null) + fun selectInProject(filePath: String) { _commands.tryEmit(UtilCommand.SelectInProject(filePath)) } - @JvmOverloads - fun openPreviewTab(filePath: String, line: Int = -1) { - _commands.tryEmit(UtilCommand.OpenPreviewTab(filePath, line)) + fun setPreviewTabEnabled(enabled: Boolean) { + previewTabEnabled.set(enabled) } + + /** Returns the frontend-reported preview-tab setting, or null if it hasn't been reported yet. */ + fun getPreviewTabEnabled(): Boolean? = previewTabEnabled.get() } diff --git a/backend/src/main/java/toolwindow/elements/MySimpleChangesBrowser.java b/backend/src/main/java/toolwindow/elements/MySimpleChangesBrowser.java index bcc983c..471a29c 100644 --- a/backend/src/main/java/toolwindow/elements/MySimpleChangesBrowser.java +++ b/backend/src/main/java/toolwindow/elements/MySimpleChangesBrowser.java @@ -1,14 +1,16 @@ package toolwindow.elements; -import com.intellij.ide.ui.UISettings; import com.intellij.openapi.actionSystem.AnAction; import com.intellij.openapi.application.ApplicationManager; +import com.intellij.ide.ui.UISettings; import com.intellij.openapi.editor.Editor; import com.intellij.openapi.editor.LogicalPosition; import com.intellij.openapi.editor.ScrollType; import com.intellij.openapi.fileEditor.FileEditor; import com.intellij.openapi.fileEditor.FileEditorManager; import com.intellij.openapi.fileEditor.TextEditor; +import com.intellij.openapi.fileEditor.ex.FileEditorManagerEx; +import com.intellij.openapi.fileEditor.ex.FileEditorOpenRequest; import com.intellij.openapi.project.Project; import com.intellij.openapi.vcs.FilePath; import com.intellij.openapi.vcs.VcsException; @@ -37,7 +39,6 @@ public class MySimpleChangesBrowser extends SimpleAsyncChangesBrowser { private static final com.intellij.openapi.diagnostic.Logger LOG = Defs.getLogger(MySimpleChangesBrowser.class); private final Project myProject; - UISettings uiSettings = UISettings.getInstance(); // Instance-level actions to avoid static references that prevent plugin unloading // Use lazy initialization since super() constructor may call createToolbarActions() before field initialization @@ -116,16 +117,10 @@ public void mouseClicked(MouseEvent e) { Change[] selectedChanges = getSelectedChanges().toArray(new Change[0]); - if (!uiSettings.getOpenInPreviewTabIfPossible() - && com.intellij.platform.ide.productMode.IdeProductMode.isMonolith()) { - return; - } - if (selectedChanges.length > 0) { Change selectedChange = selectedChanges[0]; VirtualFile file = selectedChange.getVirtualFile(); - if (file != null) { - // Single click: try to open in preview tab, do nothing if it fails + if (file != null && isPreviewTabEnabled(myProject)) { openInPreviewTab(myProject, file); } } @@ -134,8 +129,64 @@ public void mouseClicked(MouseEvent e) { }); } + /** + * Whether single-click should open a file, based on the "Enable preview tab" setting. + * + * In monolith mode the backend's UISettings is the real user setting, so we read it directly. + * In split/remote mode the backend's UISettings is not synced with the frontend, so we use the + * value the frontend pushes over RPC (see FrontendUtilSubscriptions). If the frontend has not + * reported yet, we fall back to the backend's own setting as a best-effort default. + */ + private boolean isPreviewTabEnabled(Project project) { + if (com.intellij.platform.ide.productMode.IdeProductMode.isMonolith()) { + return UISettings.getInstance().getOpenInPreviewTabIfPossible(); + } + Boolean fromFrontend = project.getService(rpc.UtilCommandService.class).getPreviewTabEnabled(); + if (fromFrontend != null) { + return fromFrontend; + } + return UISettings.getInstance().getOpenInPreviewTabIfPossible(); + } + private void openInPreviewTab(Project project, VirtualFile file) { - project.getService(rpc.UtilCommandService.class).openPreviewTab(file.getUrl(), -1); + openInPreviewTab(project, file, -1); + } + + /** + * Opens the file through the platform's canonical FileEditorManager path. + * + * The Git Scope tool window runs on the backend under the connected client's ClientId (not + * local), so FileEditorManagerEx.openFile delegates to the same per-client FileEditorManager + * that Project View uses. This gives full editor initialization (Java/language parsing), the + * IDE VCS gutter and the plugin's own scope gutter (the backend fileOpened listener fires), and + * reuses the existing tab instead of creating a duplicate. + * + * usePreviewTab is requested for forward compatibility: in the current IDE, preview is decided + * by EditorWindow.shouldReservePreview(), whose first gate reads the backend's app-level + * UISettings.openInPreviewTabIfPossible (false, not synced from the frontend), so the tab is not + * a preview tab today. If a future IDE honors the flag over the client channel or consults the + * per-client setting, preview will start working with no code change here. + */ + private void openInPreviewTab(Project project, VirtualFile file, int line) { + ApplicationManager.getApplication().invokeLater(() -> { + if (project.isDisposed() || !file.isValid()) return; + FileEditorOpenRequest request = new FileEditorOpenRequest() + .withUsePreviewTab(true) + .withReuseOpen(true) + .withRequestFocus(false); + var composite = FileEditorManagerEx.getInstanceEx(project).openFile(file, request); + + if (line > 0) { + for (FileEditor fileEditor : composite.getAllEditors()) { + if (fileEditor instanceof TextEditor) { + Editor editor = ((TextEditor) fileEditor).getEditor(); + LogicalPosition pos = new LogicalPosition(line - 1, 0); + editor.getCaretModel().moveToLogicalPosition(pos); + editor.getScrollingModel().scrollToCaret(ScrollType.CENTER); + } + } + } + }); } /** @@ -183,7 +234,7 @@ public static CompletableFuture createAsync(@NotNull Pro */ public void openAndScrollToChanges(Project project, VirtualFile file, int line, boolean isPreview) { if (isPreview) { - project.getService(rpc.UtilCommandService.class).openPreviewTab(file.getUrl(), line); + openInPreviewTab(project, file, line); } else { ApplicationManager.getApplication().invokeLater(() -> { if (project.isDisposed()) return; diff --git a/frontend/src/main/java/rpc/FrontendUtilListeners.kt b/frontend/src/main/java/rpc/FrontendUtilListeners.kt index eec7caa..a369022 100644 --- a/frontend/src/main/java/rpc/FrontendUtilListeners.kt +++ b/frontend/src/main/java/rpc/FrontendUtilListeners.kt @@ -1,11 +1,11 @@ package rpc import com.intellij.ide.projectView.ProjectView +import com.intellij.ide.ui.UISettings +import com.intellij.ide.ui.UISettingsListener import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.components.Service import com.intellij.openapi.components.service -import com.intellij.openapi.fileEditor.FileEditorManager -import com.intellij.openapi.fileEditor.OpenFileDescriptor import com.intellij.openapi.project.Project import com.intellij.openapi.startup.ProjectActivity import com.intellij.openapi.vfs.LocalFileSystem @@ -29,13 +29,31 @@ class FrontendUtilSubscriptions( .collect { cmd -> handleCommand(cmd) } } } + + // Report the frontend's current "Enable preview tab" setting to the backend, and keep it + // updated. In split mode the backend cannot read this setting reliably (it is not synced), + // so it relies on this pushed value to decide whether single-click opens a file. + pushPreviewTabEnabled(UISettings.getInstance().openInPreviewTabIfPossible) + ApplicationManager.getApplication().messageBus.connect(coroutineScope) + .subscribe(UISettingsListener.TOPIC, UISettingsListener { settings -> + pushPreviewTabEnabled(settings.openInPreviewTabIfPossible) + }) + } + + private fun pushPreviewTabEnabled(enabled: Boolean) { + coroutineScope.launch { + try { + UtilRpcApi.getInstance().setPreviewTabEnabled(project.projectId(), enabled) + } catch (e: Throwable) { + // best-effort; backend falls back to its own default if never received + } + } } private fun handleCommand(cmd: UtilCommand) { ApplicationManager.getApplication().invokeLater { when (cmd) { is UtilCommand.SelectInProject -> selectInProject(cmd.filePath) - is UtilCommand.OpenPreviewTab -> openPreviewTab(cmd.filePath, cmd.line) } } } @@ -49,15 +67,6 @@ class FrontendUtilSubscriptions( ProjectView.getInstance(project).select(null, file, true) }, true, true) } - - private fun openPreviewTab(filePath: String, line: Int) { - val file = com.intellij.openapi.vfs.VirtualFileManager.getInstance().findFileByUrl(filePath) - ?: LocalFileSystem.getInstance().findFileByPath(filePath) - ?: return - val descriptor = if (line >= 0) OpenFileDescriptor(project, file, line, 0) else OpenFileDescriptor(project, file) - descriptor.setUsePreviewTab(true) - FileEditorManager.getInstance(project).openEditor(descriptor, true) - } } class FrontendUtilSubscriptionsStartup : ProjectActivity { diff --git a/shared/src/main/java/rpc/UtilRpcApi.kt b/shared/src/main/java/rpc/UtilRpcApi.kt index 3228738..1303337 100644 --- a/shared/src/main/java/rpc/UtilRpcApi.kt +++ b/shared/src/main/java/rpc/UtilRpcApi.kt @@ -12,15 +12,20 @@ import kotlinx.serialization.Serializable sealed class UtilCommand { @Serializable data class SelectInProject(val filePath: String) : UtilCommand() - - @Serializable - data class OpenPreviewTab(val filePath: String, val line: Int = -1) : UtilCommand() } @Rpc interface UtilRpcApi : RemoteApi { suspend fun getCommands(projectId: ProjectId): Flow + /** + * Reports the frontend's "Enable preview tab" (UISettings.openInPreviewTabIfPossible) value to + * the backend. In split/remote mode the backend's own UISettings is not synced with the + * frontend, so the backend caches this pushed value to decide whether single-click should open + * a file. + */ + suspend fun setPreviewTabEnabled(projectId: ProjectId, enabled: Boolean) + companion object { suspend fun getInstance(): UtilRpcApi { return RemoteApiProviderService.resolve(remoteApiDescriptor()) From d64aa492508afc547bb6a9539cff15c5dc7f0f61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20W=C3=A5llberg?= Date: Tue, 21 Jul 2026 00:18:01 +0200 Subject: [PATCH 07/16] build: migrate to JDK 25 / 2026.2 toolchain Move the build to JBR 25 for 2026.2 compatibility: - JDK 25 toolchain and JVM 25 target across all projects, including the root aggregator, so it can consume the JVM-25 modules - bump IntelliJ Platform Gradle Plugin 2.16.0 -> 2.18.1; rename custom run tasks and migrate off the deprecated 'by registering' syntax (Gradle 9.6) - Gradle wrapper 9.5.1 -> 9.6.1, platform 2026.1.2 -> 2026.1.4, gson -> 2.14.0 - raise plugin since-build floor to 261 (2026.1) --- backend/build.gradle.kts | 2 +- build.gradle.kts | 38 ++++++++++++++++-------- gradle.properties | 6 ++-- gradle/wrapper/gradle-wrapper.properties | 2 +- 4 files changed, 30 insertions(+), 18 deletions(-) diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index bccfa34..222f8f8 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -22,5 +22,5 @@ dependencies { } implementation(project(":shared")) - compileOnly("com.google.code.gson:gson:2.13.2") + compileOnly("com.google.code.gson:gson:2.14.0") } diff --git a/build.gradle.kts b/build.gradle.kts index 47f5e40..8392258 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,8 +10,8 @@ val platformType = properties("platformType") plugins { application id("java") - id("org.jetbrains.intellij.platform") version "2.16.0" - id("org.jetbrains.intellij.platform.module") version "2.16.0" apply false + id("org.jetbrains.intellij.platform") version "2.18.1" + id("org.jetbrains.intellij.platform.module") version "2.18.1" apply false id("org.jetbrains.changelog") version "2.5.0" id("org.jetbrains.kotlin.jvm") version "2.3.20" apply false id("org.jetbrains.kotlin.plugin.serialization") version "2.3.20" apply false @@ -21,15 +21,26 @@ plugins { group = properties("pluginGroup") version = properties("pluginVersion") -subprojects { +allprojects { + + // Set the Java toolchain to 25 for every project (including the root aggregator, which does + // not apply the Kotlin plugin). This drives the org.gradle.jvm.version attribute so the root + // can consume the JVM-25 subprojects. + plugins.withType().configureEach { + extensions.configure { + toolchain { + languageVersion.set(JavaLanguageVersion.of(25)) + } + } + } afterEvaluate { - extensions.findByType()?.jvmToolchain(21) + extensions.findByType()?.jvmToolchain(25) } tasks.withType { - sourceCompatibility = "21" - targetCompatibility = "21" + sourceCompatibility = "25" + targetCompatibility = "25" } } @@ -124,15 +135,16 @@ intellijPlatform { } // Split Mode run task — launches frontend + backend processes locally for remote dev testing. -// Usage: ./gradlew runIdeSplitMode -val runIdeSplitMode by intellijPlatformTesting.runIde.registering { - splitMode = true - pluginInstallationTarget = PluginInstallationTarget.BOTH -} +// Named to avoid colliding with the plugin's own auto-registered runIdeSplitMode task. +// Usage: ./gradlew runSplitMode +//val runSplitMode = intellijPlatformTesting.runIde.register("runSplitMode") { +// splitMode = true +// pluginInstallationTarget = PluginInstallationTarget.BOTH +//} // Monolith run task — single-process mode for everyday development. -// Usage: ./gradlew runIdeMonolith -val runIdeMonolith by intellijPlatformTesting.runIde.registering { +// Usage: ./gradlew runMonolith +val runMonolith = intellijPlatformTesting.runIde.register("runMonolith") { splitMode = false } diff --git a/gradle.properties b/gradle.properties index 2129c98..cf1a2a9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,14 +3,14 @@ pluginGroup=org.woelkit.plugins pluginName=Git Scope pluginRepositoryUrl=https://github.com/comod/git-scope-pro pluginVersion=2026.2 -pluginSinceBuild=253 +pluginSinceBuild=261 platformType=IU #platformVersion=LATEST-EAP-SNAPSHOT -platformVersion=2026.1.2 +platformVersion=2026.1.4 platformBundledPlugins=Git4Idea -gradleVersion=9.5.1 +gradleVersion=9.6.1 kotlin.stdlib.default.dependency=false org.gradle.configuration-cache=true org.gradle.caching = true diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index df6a6ad..a9db115 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 From 4b5bb68f27a530f5f083490a3e3098b5865c821c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20W=C3=A5llberg?= Date: Tue, 21 Jul 2026 01:03:27 +0200 Subject: [PATCH 08/16] build: target platform 2026.2 and declare split VCS modules 2026.2 splits the VCS APIs into finer content modules that are no longer pulled in transitively via the Git4Idea plugin. Declare them explicitly on the backend (vcs.impl, vcs.impl.shared, vcs.dvcs[.impl][.shared], vcs.log.impl) so the existing code compiles unchanged, and bump platformVersion 2026.1.4 -> 2026.2. --- backend/build.gradle.kts | 8 ++++++++ gradle.properties | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index 222f8f8..3846f31 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -19,6 +19,14 @@ dependencies { bundledModule("intellij.platform.backend") bundledModule("intellij.platform.kernel.backend") bundledModule("intellij.platform.rpc.backend") + // 2026.2 split VCS APIs into finer content modules that are no longer pulled in + // transitively via the Git4Idea plugin, so declare them explicitly. + bundledModule("intellij.platform.vcs.impl") + bundledModule("intellij.platform.vcs.impl.shared") + bundledModule("intellij.platform.vcs.dvcs") + bundledModule("intellij.platform.vcs.dvcs.impl") + bundledModule("intellij.platform.vcs.dvcs.impl.shared") + bundledModule("intellij.platform.vcs.log.impl") } implementation(project(":shared")) diff --git a/gradle.properties b/gradle.properties index cf1a2a9..421bd6f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,7 +7,7 @@ pluginSinceBuild=261 platformType=IU #platformVersion=LATEST-EAP-SNAPSHOT -platformVersion=2026.1.4 +platformVersion=2026.2 platformBundledPlugins=Git4Idea gradleVersion=9.6.1 From a19b413971dfc45ab3f767925e519e9af9a988aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20W=C3=A5llberg?= Date: Wed, 22 Jul 2026 02:16:10 +0200 Subject: [PATCH 09/16] =?UTF-8?q?feat:=20keyboard-driven=20scope=20review?= =?UTF-8?q?=20=E2=80=94=20change/file=20navigation=20and=20diff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add actions (bindable in Keymap, no default shortcuts) to review a scope without the mouse: - Next/Previous Change: step through changes; cross into the adjacent changed file at a file's ends; wrap at the global ends - Next/Previous Changed File: cycle to the first change of the adjacent file - Show Diff: open the scope diff for the current file as an editor tab, reusing the existing tab (rebuilding when the scope base revision changed); Close Diff closes the active diff tab Navigation and diff run on the backend (authoritative scope change set) via the canonical open path, so files open with full init and gutters, honor the preview-tab setting, and highlight in the Git Scope tree — working in both monolith and split/remote mode. Extract shared open logic into FileOpener and the scope diff producer into ScopeDiff. Document the review workflow and the preview-tab tip in the README. --- README.md | 105 +++-- .../src/main/java/rpc/BackendUtilRpcImpl.kt | 16 + .../java/service/ChangeNavigationService.java | 366 ++++++++++++++++++ .../elements/MySimpleChangesBrowser.java | 86 +--- backend/src/main/java/utils/FileOpener.java | 88 +++++ backend/src/main/java/utils/ScopeDiff.java | 64 +++ .../src/main/resources/gitscope.backend.xml | 1 + .../frontend/actions/ChangeNavActions.kt | 132 +++++++ .../src/main/resources/gitscope.frontend.xml | 28 ++ shared/src/main/java/rpc/UtilRpcApi.kt | 28 ++ 10 files changed, 815 insertions(+), 99 deletions(-) create mode 100644 backend/src/main/java/service/ChangeNavigationService.java create mode 100644 backend/src/main/java/utils/FileOpener.java create mode 100644 backend/src/main/java/utils/ScopeDiff.java create mode 100644 frontend/src/main/java/gitscope/frontend/actions/ChangeNavActions.kt diff --git a/README.md b/README.md index 870c655..6aa3485 100644 --- a/README.md +++ b/README.md @@ -42,8 +42,7 @@ In the "New*" tab you get a few different options to define the scope: 1. Select either a local or remote branch in the current repo. If the repo contains sub-repos, all repos will be listed with the main repo being the first repo in the list. 2. Alternatively, you can manually type the branch, tag or git reference and press Enter. A git reference can be any - valid - git reference such as `HEAD~2`, ``, ``, ... + valid git reference such as `HEAD~2`, ``, ``, ... 3. If you want to bind the scope to the common ancestor for `HEAD` and the current selected scope reference, you can check the common ancestor checkbox. This makes it possible to for example select `master` and your scope will be compared to the common ancestor of `master` and `HEAD`. And you are free to pull new changes onto `master` without @@ -62,52 +61,95 @@ Right click on any file will present a number of actions: ![](docs/context_menu.png) -- **Show Diff**: Opens a diff window showing the diff of the selected file(s) `..HEAD` +- **Show Diff**: Opens a diff window showing the diff of the selected file (s) `..HEAD` - **Show in Project**: Highlight this file in the Project tool window -- **Create Patch...**: Opens a dialog to save the selected file(s) scope diff as a `.patch` file -- **Copy as Patch to Clipboard**: Copies the selected file(s) scope diff as a unified patch directly to the clipboard -- **Rollback...**: Rollback the selected files(s) to `` version. Note that this will checkout the - selected scope version of the file(s), and will in many cases leave you with a modified file that no longer show up in - the Git Scope window since it is identical to the version pointed to by the scope. Commit the files using the +- **Create Patch...**: Opens a dialog to save the selected file (s) scope diff as a `.patch` file +- **Copy as Patch to Clipboard**: Copies the selected file (s) scope diff as a unified patch directly to the clipboard +- **Rollback...**: Rollback the selected files (s) to `` version. Note that this will checkout the + selected scope version of the file (s), and will in many cases leave you with a modified file that no longer show up + in the Git Scope window since it is identical to the version pointed to by the scope. Commit the files using the standard Commit tool window. -**Create Patch...**, **Copy as Patch to Clipboard**, and **Rollback...** all apply to the current selection, -so you can select multiple files (or all files via **Ctrl+A**) and the action will apply to all of them at once. +**Create Patch...**, **Copy as Patch to Clipboard**, and **Rollback...** all apply to the current selection, so you can +select multiple files (or all files via **Ctrl+A**) and the action will apply to all of them at once. ![](docs/icon.svg) **Line Status Gutter:** -Git Scope uses its own plugin-native gutter rendering system to show scope-aware diff markers directly -in the editor. This avoids the side-effects and interference with native IDE functionality that earlier -versions of the plugin experienced when hooking into the IDE's own gutter system. +Git Scope uses its own plugin-native gutter rendering system to show scope-aware diff markers directly in the editor. +This avoids the side-effects and interference with native IDE functionality that earlier versions of the plugin +experienced when hooking into the IDE's own gutter system. > **Note:** The Git Scope gutter markers are only visible when the IDE setting > **Version Control → Confirmation → Highlight modified lines in gutter** is enabled. -Each changed line is marked with a colored bar — inserted, modified, and deleted lines each get their -own dedicated color as defined by the active IDE theme — relative to the selected Git Scope target. -Clicking any marker opens a popup with the following actions: +Each changed line is marked with a colored bar — inserted, modified, and deleted lines each get their own dedicated +color as defined by the active IDE theme — relative to the selected Git Scope target. Clicking any marker opens a popup +with the following actions: - **↑ / ↓** — Navigate to the previous or next change - **↺** — Rollback this change to the scope base version - **Diff** — Open an inline diff showing exactly what changed - **Copy** — Copy the original scope-base content to the clipboard -An overview stripe on the right-hand scrollbar provides a full-file summary of scope changes, -making it easy to spot hotspots without scrolling. +An overview stripe on the right-hand scrollbar provides a full-file summary of scope changes, making it easy to spot +hotspots without scrolling. The gutter markers can be positioned in two ways, controlled via **Settings → Tools → Git Scope**: -**Merged mode** (default) — Scope markers appear inline with the IDE's native gutter markers. -Both sets of markers are visible at the same time: +**Merged mode** (default) — Scope markers appear inline with the IDE's native gutter markers. Both sets of markers are +visible at the same time: ![](docs/merged_gutter.png) -**Separate mode** — Scope markers are rendered in a dedicated column to the left of the line -numbers. This makes it immediately clear which changes are relative to the Git Scope target and -which are uncommitted local changes shown by the IDE's native HEAD diff markers: +**Separate mode** — Scope markers are rendered in a dedicated column to the left of the line numbers. This makes it +immediately clear which changes are relative to the Git Scope target and which are uncommitted local changes shown by +the IDE's native HEAD diff markers: ![](docs/separate_gutter.png) +![](docs/icon.svg) **Reviewing a Scope (Keyboard Navigation):** + +Git Scope is well suited for *reviewing* an entire scope — walking through every change on a branch, tag or commit range +the same way you would review a pull request, but directly in the editor with full language support (syntax +highlighting, navigation, inspections). + +To make this fast and mouse-free, the plugin adds four navigation actions plus two diff actions. They ship **without +default shortcuts** so they never clash with your keymap — assign your own under **Settings → Keymap** (search for "Git +Scope"): + +| Action | Suggested shortcut | Description | +|--------------------------------------|--------------------|---------------------------------------------------------------------------------------------------------------| +| **Git Scope: Next Change** | `Alt+Down` | Jump to the next change. At the last change in a file, moves on to the first change of the next changed file. | +| **Git Scope: Previous Change** | `Alt+Up` | Jump to the previous change. At the first change in a file, moves to the last change of the previous file. | +| **Git Scope: Next Changed File** | `Alt+Shift+Down` | Jump straight to the first change of the next changed file. | +| **Git Scope: Previous Changed File** | `Alt+Shift+Up` | Jump straight to the first change of the previous changed file. | +| **Git Scope: Show Diff** | `Alt+D` | Open the scope diff (`..working tree`) for the current file in an editor tab. | +| **Git Scope: Close Diff** | `Alt+Shift+D` | Close the active diff editor tab and return to the code. | + +![](docs/keymap_review.png) + +A typical review flow: + +1. Select the scope you want to review in the **Git Scope** tool window. +2. Press **Next Change** repeatedly to step through every change, across all files, in order. The file being reviewed is + automatically opened, the caret lands on each change, and the file is highlighted in the Git Scope tool window so you + always know where you are. +3. When the gutter markers aren't enough to understand a change, press **Show Diff** to open the full side-by-side scope + diff for that file, then **Close Diff** to jump back and keep moving. +4. Use **Next/Previous Changed File** to skip whole files when you only want a per-file overview. + +![](docs/review_navigation.png) + +> **Tip — enable the "Preview tab" feature to reduce tab clutter.** When reviewing, each file you +> step into would normally open a new editor tab. If you enable IntelliJ's preview-tab feature +> (**Settings → Editor → General → Editor Tabs → "Enable preview tab"**, or the "Open declaration +> source in the same tab" / preview toggle in the editor tabs settings), Git Scope opens each file +> in a single reused **preview tab** instead — so navigating through dozens of changed files leaves +> you with just one tab rather than dozens. Git Scope respects this setting: with it off, files open +> as normal (permanent) tabs; with it on, navigation reuses the preview tab. + +![](docs/preview_tab_setting.png) + ![](docs/icon.svg) **Scope:** Adds a custom *Scope* (used to do inspections, search/replace, etc), i.e. search results are filtered by **Git Scope**. @@ -134,8 +176,8 @@ and selecting "Reset Tab Name". ![](docs/icon.svg) **Use Commit as Git Scope** -In the Git panel, you can right-click on any commit and select "Use Commit as Git Scope" to automatically add the -commit as a new Git Scope. +In the Git panel, you can right-click on any commit and select "Use Commit as Git Scope" to automatically add the commit +as a new Git Scope. ![](docs/usescope.png) @@ -178,6 +220,19 @@ Plugin settings are available under **Settings → Tools → Git Scope**: |----------|--------------------------------------------------| | Alt+H | Toggle between HEAD and last Git Scope selection | +The following review-navigation actions are also added, but ship **without** default shortcuts — assign your own under +**Settings → Keymap** (search for "Git Scope"). See +[Reviewing a Scope](#) above for the recommended workflow: + +| Action | Description | +|----------------------------------|--------------------------------------------------------------------| +| Git Scope: Next Change | Next change; crosses into the next changed file at a file's end | +| Git Scope: Previous Change | Previous change; crosses into the previous changed file at the top | +| Git Scope: Next Changed File | First change of the next changed file | +| Git Scope: Previous Changed File | First change of the previous changed file | +| Git Scope: Show Diff | Open the scope diff for the current file in an editor tab | +| Git Scope: Close Diff | Close the active diff editor tab | + ## More Useful Shortcuts | Shortcut | Description | diff --git a/backend/src/main/java/rpc/BackendUtilRpcImpl.kt b/backend/src/main/java/rpc/BackendUtilRpcImpl.kt index 756f3c3..3fdc170 100644 --- a/backend/src/main/java/rpc/BackendUtilRpcImpl.kt +++ b/backend/src/main/java/rpc/BackendUtilRpcImpl.kt @@ -18,6 +18,22 @@ class BackendUtilRpcImpl : UtilRpcApi { val project = projectId.findProjectOrNull() ?: return project.service().setPreviewTabEnabled(enabled) } + + override suspend fun navigateChange( + projectId: ProjectId, + currentFilePath: String?, + caretLine: Int, + direction: ChangeNavDirection + ) { + val project = projectId.findProjectOrNull() ?: return + project.service() + .navigate(currentFilePath, caretLine, direction) + } + + override suspend fun showDiff(projectId: ProjectId, currentFilePath: String?) { + val project = projectId.findProjectOrNull() ?: return + project.service().showDiff(currentFilePath) + } } class BackendUtilRpcProvider : RemoteApiProvider { diff --git a/backend/src/main/java/service/ChangeNavigationService.java b/backend/src/main/java/service/ChangeNavigationService.java new file mode 100644 index 0000000..0c49a45 --- /dev/null +++ b/backend/src/main/java/service/ChangeNavigationService.java @@ -0,0 +1,366 @@ +package service; + +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.components.Service; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.editor.Document; +import com.intellij.openapi.fileEditor.FileDocumentManager; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.Computable; +import com.intellij.openapi.util.text.StringUtil; +import com.intellij.openapi.vcs.VcsException; +import com.intellij.openapi.vcs.changes.Change; +import com.intellij.openapi.vfs.LocalFileSystem; +import com.intellij.openapi.vfs.VirtualFile; +import implementation.gutter.Range; +import implementation.gutter.RangesBuilder; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import rpc.ChangeNavDirection; +import system.Defs; +import utils.FileOpener; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * Backend service that powers the "next/previous change" and "next/previous changed file" + * navigation actions. + * + *

    The set of changed files and their change ranges are computed from the active scope changes + * (the same source the gutter uses), so navigation follows the visible gutter markers. The + * authoritative change data lives on the backend, and file opening + caret placement go through the + * canonical {@link FileOpener} path (which honors the preview-tab setting), so this works correctly + * in both monolith and split/remote mode. + */ +@Service(Service.Level.PROJECT) +public final class ChangeNavigationService { + + private static final Logger LOG = Defs.getLogger(ChangeNavigationService.class); + + private final Project project; + + // Last position we navigated to. Used as a fallback when the frontend reports no focused + // editor (a transient race right after opening a file without focus), so navigation continues + // from where we were instead of resetting to the first file. + private volatile String lastNavigatedFile = null; + private volatile int lastNavigatedLine = -1; + + // Cache of the diff editor-tab file per changed-file path. Reusing the same + // ChainDiffVirtualFile instance means DiffEditorTabFilesManager focuses the already-open diff + // tab instead of opening a duplicate. Each entry is tagged with the scope base revision it was + // built from, so switching scope/target rebuilds the diff instead of focusing a stale one. + private final Map openDiffFiles = new java.util.concurrent.ConcurrentHashMap<>(); + + private record CachedDiff(com.intellij.diff.editor.ChainDiffVirtualFile file, String signature) { + } + + public ChangeNavigationService(Project project) { + this.project = project; + } + + /** + * Navigates relative to the caret in the currently focused editor. + * + * @param currentFilePath path of the focused editor's file, or null if none + * @param caretLine 0-based caret line in that editor + * @param direction navigation direction + */ + public void navigate(@Nullable String currentFilePath, int caretLine, @NotNull ChangeNavDirection direction) { + ViewService viewService = project.getService(ViewService.class); + if (viewService == null) return; + + Map scopeChanges = viewService.getScopeChangesMap(); + if (scopeChanges == null || scopeChanges.isEmpty()) { + LOG.debug("ChangeNavigation: no scope changes"); + return; + } + + // Ordered, stable list of changed files (path order, matching what the user scans). + List files = new ArrayList<>(scopeChanges.keySet()); + Collections.sort(files); + + // 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 + // next/previous presses moving forward instead of snapping back to the first file. + String fromFile = currentFilePath; + int fromLine = caretLine; + if (fromFile == null || !files.contains(fromFile)) { + if (lastNavigatedFile != null && files.contains(lastNavigatedFile)) { + fromFile = lastNavigatedFile; + fromLine = lastNavigatedLine; + } + } + + switch (direction) { + case NEXT_CHANGE -> navigateChange(files, scopeChanges, fromFile, fromLine, true); + case PREVIOUS_CHANGE -> navigateChange(files, scopeChanges, fromFile, fromLine, false); + case NEXT_FILE -> navigateFile(files, scopeChanges, fromFile, true); + case PREVIOUS_FILE -> navigateFile(files, scopeChanges, fromFile, false); + } + } + + /** + * Shows the Git Scope diff (base scope revision vs. current working content) for the focused + * file, as an editor tab. Falls back to the last navigated file, then the Git Scope tree + * selection, when no changed file is focused. + */ + public void showDiff(@Nullable String currentFilePath) { + ViewService viewService = project.getService(ViewService.class); + if (viewService == null) return; + + Map scopeChanges = viewService.getScopeChangesMap(); + if (scopeChanges == null || scopeChanges.isEmpty()) { + LOG.debug("ShowDiff: no scope changes"); + return; + } + + String targetPath = resolveDiffTarget(currentFilePath, scopeChanges); + if (targetPath == null) { + LOG.debug("ShowDiff: no target file"); + return; + } + Change change = scopeChanges.get(targetPath); + if (change == null) return; + + String scopeName = scopeDisplayName(viewService); + final String path = targetPath; + final String signature = changeSignature(change); + ApplicationManager.getApplication().invokeLater(() -> { + if (project.isDisposed()) return; + + var fem = com.intellij.openapi.fileEditor.FileEditorManager.getInstance(project); + var diffManager = com.intellij.diff.editor.DiffEditorTabFilesManager.getInstance(project); + + // Reuse (focus) the existing Git Scope diff tab only when it is still open AND was built + // for the same scope base revision. If the scope/target changed, the signature differs + // and we rebuild the diff for the new scope. + var cached = openDiffFiles.get(path); + if (cached != null && cached.signature().equals(signature) && fem.isFileOpen(cached.file())) { + diffManager.showDiffFile(cached.file(), true); + highlightTreeFor(path); + return; + } + // Stale (closed or different scope): drop it and, if a stale tab is still open, close it + // so we don't leave an outdated diff around. + if (cached != null && fem.isFileOpen(cached.file())) { + fem.closeFile(cached.file()); + } + openDiffFiles.remove(path); + + var producer = utils.ScopeDiff.buildProducer(project, change, scopeName); + if (producer == null) return; + var chain = com.intellij.diff.chains.SimpleDiffRequestChain.fromProducer(producer); + String title = new java.io.File(path).getName(); + var diffFile = new com.intellij.diff.editor.ChainDiffVirtualFile(chain, title); + openDiffFiles.put(path, new CachedDiff(diffFile, signature)); + diffManager.showDiffFile(diffFile, true); + highlightTreeFor(path); + }); + } + + /** Signature capturing the scope base revision a diff was built from (rebuild when it changes). */ + private static String changeSignature(Change change) { + var before = change.getBeforeRevision(); + if (before == null) return "none"; + try { + return before.getRevisionNumber().asString(); + } catch (Exception e) { + return "none"; + } + } + + private void highlightTreeFor(String path) { + VirtualFile vf = LocalFileSystem.getInstance().findFileByPath(path); + if (vf != null) highlightInToolWindow(vf); + } + + private @Nullable String resolveDiffTarget(@Nullable String currentFilePath, Map scopeChanges) { + if (currentFilePath != null && scopeChanges.containsKey(currentFilePath)) { + return currentFilePath; + } + // Fallback: the last file we navigated to (e.g. when a diff tab is currently focused, so the + // frontend has no underlying changed-file editor). + if (lastNavigatedFile != null && scopeChanges.containsKey(lastNavigatedFile)) { + return lastNavigatedFile; + } + return null; + } + + private String scopeDisplayName(ViewService viewService) { + try { + model.MyModel m = viewService.getCurrent(); + if (m != null) return m.getDisplayName(); + } catch (Exception ignored) { + } + return ""; + } + + // --- Change-level navigation (within a file, crossing file boundaries at the ends) --- + + private void navigateChange(List files, Map scopeChanges, + @Nullable String currentFilePath, int caretLine, boolean forward) { + int fileIdx = currentFilePath == null ? -1 : files.indexOf(currentFilePath); + + if (fileIdx >= 0) { + List lines = changeStartLines(currentFilePath, scopeChanges.get(currentFilePath)); + Integer target = forward ? firstLineAfter(lines, caretLine) : firstLineBefore(lines, caretLine); + if (target != null) { + open(currentFilePath, target); + return; + } + // Past the last/first change of this file -> move to the adjacent file. + int nextIdx = wrapIndex(fileIdx + (forward ? 1 : -1), files.size()); + openFileAtEdge(files, scopeChanges, nextIdx, forward); + return; + } + + // No current file (or caret not in a changed file): jump to the first/last change overall. + int startIdx = forward ? 0 : files.size() - 1; + openFileAtEdge(files, scopeChanges, startIdx, forward); + } + + // --- File-level navigation (cycle between changed files) --- + + private void navigateFile(List files, Map scopeChanges, + @Nullable String currentFilePath, boolean forward) { + int fileIdx = currentFilePath == null ? -1 : files.indexOf(currentFilePath); + int targetIdx; + if (fileIdx < 0) { + targetIdx = forward ? 0 : files.size() - 1; + } else { + targetIdx = wrapIndex(fileIdx + (forward ? 1 : -1), files.size()); + } + // Always land on the first change of the target file when cycling files. + openFileAtEdge(files, scopeChanges, targetIdx, true); + } + + /** + * Opens the file at {@code files[idx]} placing the caret on its first change (when moving + * forward) or last change (when moving backward). Falls back to line 0 if ranges can't be + * computed. + */ + private void openFileAtEdge(List files, Map scopeChanges, int idx, boolean firstChange) { + if (idx < 0 || idx >= files.size()) return; + String path = files.get(idx); + List lines = changeStartLines(path, scopeChanges.get(path)); + int line = 0; + if (!lines.isEmpty()) { + line = firstChange ? lines.get(0) : lines.get(lines.size() - 1); + } + open(path, line); + } + + private void open(String path, int line) { + VirtualFile file = LocalFileSystem.getInstance().findFileByPath(path); + if (file == null) { + LOG.debug("ChangeNavigation: could not resolve file " + path); + return; + } + 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); + } + + /** 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); + if (toolWindowService == null) return; + ApplicationManager.getApplication().invokeLater(() -> { + if (project.isDisposed()) return; + toolWindowService.selectFile(file); + }); + } + + // --- Range helpers --- + + /** + * Returns the sorted 0-based start lines of all changes in the given file. + * + *

    Prefers the ranges already cached by the gutter pipeline (exact match with the visible + * markers) for open files; otherwise computes base-vs-current ranges on demand so navigation + * works for files that have not been opened yet. + */ + private List changeStartLines(String path, @Nullable Change change) { + GutterDataService gds = project.getService(GutterDataService.class); + if (gds != null) { + GutterDataService.GutterFileData cached = gds.getData(path); + if (cached != null && cached.ranges != null && !cached.ranges.isEmpty()) { + return sortedStartLines(cached.ranges); + } + } + List computed = computeRanges(path, change); + return sortedStartLines(computed); + } + + private static List sortedStartLines(List ranges) { + List lines = new ArrayList<>(ranges.size()); + for (Range r : ranges) lines.add(r.getLine1()); + Collections.sort(lines); + return lines; + } + + /** + * Computes base-vs-current change ranges for a file not covered by cached gutter data. + * Runs off the EDT concerns by reading content under a read action. + */ + private List computeRanges(String path, @Nullable Change change) { + if (change == null || change.getBeforeRevision() == null) return Collections.emptyList(); + VirtualFile file = LocalFileSystem.getInstance().findFileByPath(path); + if (file == null) return Collections.emptyList(); + + String baseContent; + try { + baseContent = change.getBeforeRevision().getContent(); + } catch (VcsException e) { + LOG.warn("ChangeNavigation: error getting base content for " + path, e); + return Collections.emptyList(); + } + if (baseContent == null) return Collections.emptyList(); + + String currentContent = ApplicationManager.getApplication().runReadAction((Computable) () -> { + Document doc = FileDocumentManager.getInstance().getDocument(file); + return doc != null ? doc.getImmutableCharSequence().toString() : null; + }); + if (currentContent == null) return Collections.emptyList(); + + String normalizedBase = StringUtil.convertLineSeparators(baseContent); + String normalizedCurrent = StringUtil.convertLineSeparators(currentContent); + try { + return RangesBuilder.INSTANCE.createRanges(normalizedCurrent, normalizedBase); + } catch (Exception e) { + LOG.warn("ChangeNavigation: error computing ranges for " + path, e); + return Collections.emptyList(); + } + } + + // --- small utilities --- + + /** First line strictly greater than {@code line}, or null if none. */ + private static @Nullable Integer firstLineAfter(List sortedLines, int line) { + for (Integer l : sortedLines) { + if (l > line) return l; + } + return null; + } + + /** Last line strictly less than {@code line}, or null if none. */ + private static @Nullable Integer firstLineBefore(List sortedLines, int line) { + Integer result = null; + for (Integer l : sortedLines) { + if (l < line) result = l; + else break; + } + return result; + } + + private static int wrapIndex(int idx, int size) { + if (size == 0) return 0; + return ((idx % size) + size) % size; + } +} diff --git a/backend/src/main/java/toolwindow/elements/MySimpleChangesBrowser.java b/backend/src/main/java/toolwindow/elements/MySimpleChangesBrowser.java index 471a29c..0cfb7b0 100644 --- a/backend/src/main/java/toolwindow/elements/MySimpleChangesBrowser.java +++ b/backend/src/main/java/toolwindow/elements/MySimpleChangesBrowser.java @@ -2,15 +2,12 @@ import com.intellij.openapi.actionSystem.AnAction; import com.intellij.openapi.application.ApplicationManager; -import com.intellij.ide.ui.UISettings; import com.intellij.openapi.editor.Editor; import com.intellij.openapi.editor.LogicalPosition; import com.intellij.openapi.editor.ScrollType; import com.intellij.openapi.fileEditor.FileEditor; import com.intellij.openapi.fileEditor.FileEditorManager; import com.intellij.openapi.fileEditor.TextEditor; -import com.intellij.openapi.fileEditor.ex.FileEditorManagerEx; -import com.intellij.openapi.fileEditor.ex.FileEditorOpenRequest; import com.intellij.openapi.project.Project; import com.intellij.openapi.vcs.FilePath; import com.intellij.openapi.vcs.VcsException; @@ -131,21 +128,12 @@ public void mouseClicked(MouseEvent e) { /** * Whether single-click should open a file, based on the "Enable preview tab" setting. - * - * In monolith mode the backend's UISettings is the real user setting, so we read it directly. - * In split/remote mode the backend's UISettings is not synced with the frontend, so we use the - * value the frontend pushes over RPC (see FrontendUtilSubscriptions). If the frontend has not - * reported yet, we fall back to the backend's own setting as a best-effort default. + * Delegates to {@link utils.FileOpener#isPreviewTabEnabled(Boolean)} with the frontend-reported + * value (used in split mode, where the backend UISettings is not synced). */ private boolean isPreviewTabEnabled(Project project) { - if (com.intellij.platform.ide.productMode.IdeProductMode.isMonolith()) { - return UISettings.getInstance().getOpenInPreviewTabIfPossible(); - } Boolean fromFrontend = project.getService(rpc.UtilCommandService.class).getPreviewTabEnabled(); - if (fromFrontend != null) { - return fromFrontend; - } - return UISettings.getInstance().getOpenInPreviewTabIfPossible(); + return utils.FileOpener.isPreviewTabEnabled(fromFrontend); } private void openInPreviewTab(Project project, VirtualFile file) { @@ -153,40 +141,13 @@ private void openInPreviewTab(Project project, VirtualFile file) { } /** - * Opens the file through the platform's canonical FileEditorManager path. - * - * The Git Scope tool window runs on the backend under the connected client's ClientId (not - * local), so FileEditorManagerEx.openFile delegates to the same per-client FileEditorManager - * that Project View uses. This gives full editor initialization (Java/language parsing), the - * IDE VCS gutter and the plugin's own scope gutter (the backend fileOpened listener fires), and - * reuses the existing tab instead of creating a duplicate. - * - * usePreviewTab is requested for forward compatibility: in the current IDE, preview is decided - * by EditorWindow.shouldReservePreview(), whose first gate reads the backend's app-level - * UISettings.openInPreviewTabIfPossible (false, not synced from the frontend), so the tab is not - * a preview tab today. If a future IDE honors the flag over the client channel or consults the - * per-client setting, preview will start working with no code change here. + * Opens the file through the platform's canonical FileEditorManager path (see + * {@link utils.FileOpener}). {@code line} is 1-based here (historical call sites); -1 means no + * caret move. */ private void openInPreviewTab(Project project, VirtualFile file, int line) { - ApplicationManager.getApplication().invokeLater(() -> { - if (project.isDisposed() || !file.isValid()) return; - FileEditorOpenRequest request = new FileEditorOpenRequest() - .withUsePreviewTab(true) - .withReuseOpen(true) - .withRequestFocus(false); - var composite = FileEditorManagerEx.getInstanceEx(project).openFile(file, request); - - if (line > 0) { - for (FileEditor fileEditor : composite.getAllEditors()) { - if (fileEditor instanceof TextEditor) { - Editor editor = ((TextEditor) fileEditor).getEditor(); - LogicalPosition pos = new LogicalPosition(line - 1, 0); - editor.getCaretModel().moveToLogicalPosition(pos); - editor.getScrollingModel().scrollToCaret(ScrollType.CENTER); - } - } - } - }); + int zeroBasedLine = line > 0 ? line - 1 : -1; + utils.FileOpener.openAndGoToLine(project, file, zeroBasedLine); } /** @@ -263,17 +224,10 @@ public void openAndScrollToChanges(Project project, VirtualFile file, int line, @Override protected @Nullable ChangeDiffRequestChain.Producer getDiffRequestProducer(@NotNull Object userObject) { if (userObject instanceof Change change) { - // Replace afterRevision with CurrentContentRevision to show working tree content - FilePath filePath = ChangesUtil.getAfterPath(change); - if (filePath == null) { - filePath = ChangesUtil.getBeforePath(change); - } - if (filePath != null && filePath.getVirtualFile() != null) { - Change modifiedChange = new Change( - withScopeName(change.getBeforeRevision(), getScopeDisplayName()), - new CurrentContentRevision(filePath) - ); - return ChangeDiffRequestProducer.create(myProject, modifiedChange); + ChangeDiffRequestChain.Producer producer = + utils.ScopeDiff.buildProducer(myProject, change, getScopeDisplayName()); + if (producer != null) { + return producer; } } return super.getDiffRequestProducer(userObject); @@ -290,22 +244,6 @@ private String getScopeDisplayName() { return ""; } - @Nullable - private static ContentRevision withScopeName(@Nullable ContentRevision original, @NotNull String scopeName) { - if (original == null || scopeName.isEmpty()) return original; - return new ContentRevision() { - @Override public @Nullable String getContent() throws VcsException { return original.getContent(); } - @Override public @NotNull FilePath getFile() { return original.getFile(); } - @Override public @NotNull VcsRevisionNumber getRevisionNumber() { - VcsRevisionNumber base = original.getRevisionNumber(); - return new VcsRevisionNumber() { - @Override public @NotNull String asString() { return base.asString() + " (" + scopeName + ")"; } - @Override public int compareTo(@NotNull VcsRevisionNumber o) { return base.compareTo(o); } - }; - } - }; - } - @Override protected void onDoubleClick() { // Handle double-click to open in regular/permanent tab diff --git a/backend/src/main/java/utils/FileOpener.java b/backend/src/main/java/utils/FileOpener.java new file mode 100644 index 0000000..d674a4a --- /dev/null +++ b/backend/src/main/java/utils/FileOpener.java @@ -0,0 +1,88 @@ +package utils; + +import com.intellij.ide.ui.UISettings; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.editor.Editor; +import com.intellij.openapi.editor.LogicalPosition; +import com.intellij.openapi.editor.ScrollType; +import com.intellij.openapi.fileEditor.FileEditor; +import com.intellij.openapi.fileEditor.TextEditor; +import com.intellij.openapi.fileEditor.ex.FileEditorManagerEx; +import com.intellij.openapi.fileEditor.ex.FileEditorOpenRequest; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.vfs.VirtualFile; +import org.jetbrains.annotations.NotNull; + +/** + * Shared helper for opening a file through the platform's canonical FileEditorManager path and + * optionally placing the caret on a specific line. + * + *

    The Git Scope tool window runs on the backend under the connected client's ClientId (not + * local) in split mode, so {@link FileEditorManagerEx#openFile} delegates to the same per-client + * FileEditorManager that Project View uses: full editor initialization (language parsing), the IDE + * VCS gutter and the plugin's own scope gutter, and reuse of the existing tab instead of a + * duplicate. {@code usePreviewTab} is requested for forward compatibility (see the preview-tab + * notes in the single-click handler). + */ +public final class FileOpener { + + private FileOpener() { + } + + /** + * Opens the file (reusing an existing tab) and, when {@code line >= 0}, moves the caret to that + * 0-based line and scrolls it into view. Must be safe to call from any thread; the actual open + * is scheduled on the EDT. Does not request focus. + */ + public static void openAndGoToLine(@NotNull Project project, @NotNull VirtualFile file, int line) { + openAndGoToLine(project, file, line, false); + } + + /** + * Variant that can request focus for the opened editor. Navigation actions pass + * {@code requestFocus=true} so the editor becomes the focus owner and reports an accurate + * caret position for subsequent navigation keystrokes. Requesting focus does not promote a + * preview tab to a permanent one (only double-click / pin / modification does). + */ + public static void openAndGoToLine(@NotNull Project project, @NotNull VirtualFile file, int line, boolean requestFocus) { + ApplicationManager.getApplication().invokeLater(() -> { + if (project.isDisposed() || !file.isValid()) return; + + FileEditorOpenRequest request = new FileEditorOpenRequest() + .withUsePreviewTab(true) + .withReuseOpen(true) + .withRequestFocus(requestFocus); + var composite = FileEditorManagerEx.getInstanceEx(project).openFile(file, request); + + if (line >= 0) { + for (FileEditor fileEditor : composite.getAllEditors()) { + if (fileEditor instanceof TextEditor) { + Editor editor = ((TextEditor) fileEditor).getEditor(); + int lineCount = editor.getDocument().getLineCount(); + int target = Math.max(0, Math.min(line, Math.max(0, lineCount - 1))); + LogicalPosition pos = new LogicalPosition(target, 0); + editor.getCaretModel().moveToLogicalPosition(pos); + editor.getScrollingModel().scrollToCaret(ScrollType.CENTER); + } + } + } + }); + } + + /** + * Whether single-click / navigation should open a file in a preview tab, based on the + * "Enable preview tab" setting. In monolith mode the backend UISettings is authoritative; in + * split mode it is not synced, so the caller supplies the frontend-reported value. + * + * @param frontendReported the value pushed by the frontend, or null if not reported yet + */ + public static boolean isPreviewTabEnabled(Boolean frontendReported) { + if (com.intellij.platform.ide.productMode.IdeProductMode.isMonolith()) { + return UISettings.getInstance().getOpenInPreviewTabIfPossible(); + } + if (frontendReported != null) { + return frontendReported; + } + return UISettings.getInstance().getOpenInPreviewTabIfPossible(); + } +} diff --git a/backend/src/main/java/utils/ScopeDiff.java b/backend/src/main/java/utils/ScopeDiff.java new file mode 100644 index 0000000..0fb2adc --- /dev/null +++ b/backend/src/main/java/utils/ScopeDiff.java @@ -0,0 +1,64 @@ +package utils; + +import com.intellij.openapi.project.Project; +import com.intellij.openapi.vcs.FilePath; +import com.intellij.openapi.vcs.VcsException; +import com.intellij.openapi.vcs.changes.Change; +import com.intellij.openapi.vcs.changes.ChangesUtil; +import com.intellij.openapi.vcs.changes.ContentRevision; +import com.intellij.openapi.vcs.changes.CurrentContentRevision; +import com.intellij.openapi.vcs.changes.actions.diff.ChangeDiffRequestProducer; +import com.intellij.openapi.vcs.changes.ui.ChangeDiffRequestChain; +import com.intellij.openapi.vcs.history.VcsRevisionNumber; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Builds the Git Scope diff request producer for a change: base scope revision (decorated with the + * active scope name) on the left vs. the current working-tree content on the right. + * + *

    Shared by the changes browser (right-click "Show Diff") and the Show Diff keyboard action so + * both present exactly the same diff. + */ +public final class ScopeDiff { + + private ScopeDiff() { + } + + /** + * Builds a diff producer showing {@code change}'s base revision vs. the current working content. + * Returns null when the change has no usable file path. + */ + public static @Nullable ChangeDiffRequestChain.Producer buildProducer(@NotNull Project project, + @NotNull Change change, + @NotNull String scopeDisplayName) { + FilePath filePath = ChangesUtil.getAfterPath(change); + if (filePath == null) { + filePath = ChangesUtil.getBeforePath(change); + } + if (filePath == null || filePath.getVirtualFile() == null) { + return null; + } + Change modifiedChange = new Change( + withScopeName(change.getBeforeRevision(), scopeDisplayName), + new CurrentContentRevision(filePath) + ); + return ChangeDiffRequestProducer.create(project, modifiedChange); + } + + /** Decorates a revision's displayed number with the scope name, e.g. "abc1234 (master)". */ + public static @Nullable ContentRevision withScopeName(@Nullable ContentRevision original, @NotNull String scopeName) { + if (original == null || scopeName.isEmpty()) return original; + return new ContentRevision() { + @Override public @Nullable String getContent() throws VcsException { return original.getContent(); } + @Override public @NotNull FilePath getFile() { return original.getFile(); } + @Override public @NotNull VcsRevisionNumber getRevisionNumber() { + VcsRevisionNumber base = original.getRevisionNumber(); + return new VcsRevisionNumber() { + @Override public @NotNull String asString() { return base.asString() + " (" + scopeName + ")"; } + @Override public int compareTo(@NotNull VcsRevisionNumber o) { return base.compareTo(o); } + }; + } + }; + } +} diff --git a/backend/src/main/resources/gitscope.backend.xml b/backend/src/main/resources/gitscope.backend.xml index 2249465..4c4698a 100644 --- a/backend/src/main/resources/gitscope.backend.xml +++ b/backend/src/main/resources/gitscope.backend.xml @@ -46,6 +46,7 @@ + diff --git a/frontend/src/main/java/gitscope/frontend/actions/ChangeNavActions.kt b/frontend/src/main/java/gitscope/frontend/actions/ChangeNavActions.kt new file mode 100644 index 0000000..67cf6ac --- /dev/null +++ b/frontend/src/main/java/gitscope/frontend/actions/ChangeNavActions.kt @@ -0,0 +1,132 @@ +package gitscope.frontend.actions + +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.project.DumbAware +import com.intellij.platform.project.projectId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import rpc.ChangeNavDirection +import rpc.UtilRpcApi +import system.Defs + +/** + * Frontend actions for navigating Git Scope changes with keyboard shortcuts. + * + * The keystroke is handled on the frontend (where the editor and caret live). Each action reads the + * focused editor's file and caret line, then delegates to the backend over [UtilRpcApi.navigateChange]: + * the backend holds the authoritative scope change set and performs the canonical file open + caret + * placement (honoring the preview-tab setting), so the same code path works in monolith and split mode. + */ +sealed class ChangeNavAction(private val direction: ChangeNavDirection) : AnAction(), DumbAware { + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + val editor = e.getData(CommonDataKeys.EDITOR) + + var filePath: String? = null + var caretLine = 0 + if (editor != null) { + caretLine = editor.caretModel.logicalPosition.line + val file = FileDocumentManager.getInstance().getFile(editor.document) + filePath = file?.path + } + + val pid = project.projectId() + val cs = project.service().scope + cs.launch { + try { + UtilRpcApi.getInstance().navigateChange(pid, filePath, caretLine, direction) + } catch (t: Throwable) { + LOG.warn("ChangeNav: navigateChange failed", t) + } + } + } + + override fun update(e: AnActionEvent) { + // Available whenever a project is open; the backend decides whether there is anywhere to go. + e.presentation.isEnabled = e.project != null + } + + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT + + companion object { + private val LOG: Logger = Defs.getLogger(ChangeNavAction::class.java) + } +} + +class NextChangeAction : ChangeNavAction(ChangeNavDirection.NEXT_CHANGE) +class PreviousChangeAction : ChangeNavAction(ChangeNavDirection.PREVIOUS_CHANGE) +class NextChangedFileAction : ChangeNavAction(ChangeNavDirection.NEXT_FILE) +class PreviousChangedFileAction : ChangeNavAction(ChangeNavDirection.PREVIOUS_FILE) + +/** + * Shows the Git Scope diff (base scope revision vs. current working content) for the focused file + * as an editor tab — the same diff reachable from the tool window's right-click "Show Diff". The + * diff is built on the backend (which holds the scope change set); this action just reports the + * focused file and delegates. + */ +class ShowDiffAction : AnAction(), DumbAware { + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + val editor = e.getData(CommonDataKeys.EDITOR) + val filePath = editor?.let { FileDocumentManager.getInstance().getFile(it.document)?.path } + + val pid = project.projectId() + val cs = project.service().scope + cs.launch { + try { + UtilRpcApi.getInstance().showDiff(pid, filePath) + } catch (t: Throwable) { + LOG.warn("ShowDiff: showDiff failed", t) + } + } + } + + override fun update(e: AnActionEvent) { + e.presentation.isEnabled = e.project != null + } + + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT + + companion object { + private val LOG: Logger = Defs.getLogger(ShowDiffAction::class.java) + } +} + +/** + * Closes the active diff editor tab (only when the currently selected editor is a diff), leaving + * other diff tabs untouched. Runs entirely on the frontend, where the editor tabs live. + */ +class CloseDiffAction : AnAction(), DumbAware { + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + val fem = com.intellij.openapi.fileEditor.FileEditorManager.getInstance(project) + val selected = fem.selectedEditor?.file ?: return + if (selected is com.intellij.diff.editor.DiffContentVirtualFile) { + fem.closeFile(selected) + } + } + + override fun update(e: AnActionEvent) { + val project = e.project + val enabled = project != null && + com.intellij.openapi.fileEditor.FileEditorManager.getInstance(project) + .selectedEditor?.file is com.intellij.diff.editor.DiffContentVirtualFile + e.presentation.isEnabled = enabled + } + + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT +} + +/** + * Project-level coroutine scope holder for firing the navigation RPC off the EDT. The platform + * injects a scope tied to the project/plugin lifecycle. + */ +@com.intellij.openapi.components.Service(com.intellij.openapi.components.Service.Level.PROJECT) +class ChangeNavCoroutineScopeHolder(val scope: CoroutineScope) diff --git a/frontend/src/main/resources/gitscope.frontend.xml b/frontend/src/main/resources/gitscope.frontend.xml index 7aa0a78..8b6ca94 100644 --- a/frontend/src/main/resources/gitscope.frontend.xml +++ b/frontend/src/main/resources/gitscope.frontend.xml @@ -8,8 +8,36 @@ + + + + + + + + + + diff --git a/shared/src/main/java/rpc/UtilRpcApi.kt b/shared/src/main/java/rpc/UtilRpcApi.kt index 1303337..0e0a307 100644 --- a/shared/src/main/java/rpc/UtilRpcApi.kt +++ b/shared/src/main/java/rpc/UtilRpcApi.kt @@ -14,6 +14,15 @@ sealed class UtilCommand { data class SelectInProject(val filePath: String) : UtilCommand() } +/** Direction for change/file navigation actions. */ +@Serializable +enum class ChangeNavDirection { + NEXT_CHANGE, + PREVIOUS_CHANGE, + NEXT_FILE, + PREVIOUS_FILE +} + @Rpc interface UtilRpcApi : RemoteApi { suspend fun getCommands(projectId: ProjectId): Flow @@ -26,6 +35,25 @@ interface UtilRpcApi : RemoteApi { */ suspend fun setPreviewTabEnabled(projectId: ProjectId, enabled: Boolean) + /** + * Navigates to the next/previous change or changed file, relative to the given caret position + * in the currently focused editor. Runs on the backend, which holds the authoritative scope + * change set and performs the canonical file open + caret placement (honoring the preview-tab + * setting). [currentFilePath] is null when no editor is focused; [caretLine] is 0-based. + */ + suspend fun navigateChange( + projectId: ProjectId, + currentFilePath: String?, + caretLine: Int, + direction: ChangeNavDirection + ) + + /** + * Shows the Git Scope diff for the focused file as an editor tab (same diff as the tool + * window's right-click "Show Diff"). Runs on the backend, which holds the scope change set. + */ + suspend fun showDiff(projectId: ProjectId, currentFilePath: String?) + companion object { suspend fun getInstance(): UtilRpcApi { return RemoteApiProviderService.resolve(remoteApiDescriptor()) From 437c403180880f65c703df4049adbd3a57b7ce15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20W=C3=A5llberg?= Date: Wed, 22 Jul 2026 02:20:38 +0200 Subject: [PATCH 10/16] docs: drop redundant "Git Scope:" prefix from action names The actions already appear under the Git Scope group in Keymap, so the prefix is redundant. Rename the review actions (Next/Previous Change, Next/Previous Changed File, Show Diff, Close Diff) and the Toggle Head action, and update the README tables to match. --- README.md | 24 +++++++++---------- .../src/main/resources/gitscope.backend.xml | 2 +- .../src/main/resources/gitscope.frontend.xml | 12 +++++----- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 6aa3485..184e6fe 100644 --- a/README.md +++ b/README.md @@ -119,12 +119,12 @@ Scope"): | Action | Suggested shortcut | Description | |--------------------------------------|--------------------|---------------------------------------------------------------------------------------------------------------| -| **Git Scope: Next Change** | `Alt+Down` | Jump to the next change. At the last change in a file, moves on to the first change of the next changed file. | -| **Git Scope: Previous Change** | `Alt+Up` | Jump to the previous change. At the first change in a file, moves to the last change of the previous file. | -| **Git Scope: Next Changed File** | `Alt+Shift+Down` | Jump straight to the first change of the next changed file. | -| **Git Scope: Previous Changed File** | `Alt+Shift+Up` | Jump straight to the first change of the previous changed file. | -| **Git Scope: Show Diff** | `Alt+D` | Open the scope diff (`..working tree`) for the current file in an editor tab. | -| **Git Scope: Close Diff** | `Alt+Shift+D` | Close the active diff editor tab and return to the code. | +| **Next Change** | `Alt+Down` | Jump to the next change. At the last change in a file, moves on to the first change of the next changed file. | +| **Previous Change** | `Alt+Up` | Jump to the previous change. At the first change in a file, moves to the last change of the previous file. | +| **Next Changed File**| `Alt+Shift+Down` | Jump straight to the first change of the next changed file. | +| **Previous Changed File** | `Alt+Shift+Up` | Jump straight to the first change of the previous changed file. | +| **Show Diff** | `Alt+D` | Open the scope diff (`..working tree`) for the current file in an editor tab. | +| **Close Diff** | `Alt+Shift+D` | Close the active diff editor tab and return to the code. | ![](docs/keymap_review.png) @@ -226,12 +226,12 @@ The following review-navigation actions are also added, but ship **without** def | Action | Description | |----------------------------------|--------------------------------------------------------------------| -| Git Scope: Next Change | Next change; crosses into the next changed file at a file's end | -| Git Scope: Previous Change | Previous change; crosses into the previous changed file at the top | -| Git Scope: Next Changed File | First change of the next changed file | -| Git Scope: Previous Changed File | First change of the previous changed file | -| Git Scope: Show Diff | Open the scope diff for the current file in an editor tab | -| Git Scope: Close Diff | Close the active diff editor tab | +| Next Change | Next change; crosses into the next changed file at a file's end | +| Previous Change | Previous change; crosses into the previous changed file at the top | +| Next Changed File | First change of the next changed file | +| Previous Changed File | First change of the previous changed file | +| Show Diff | Open the scope diff for the current file in an editor tab | +| Close Diff | Close the active diff editor tab | ## More Useful Shortcuts diff --git a/backend/src/main/resources/gitscope.backend.xml b/backend/src/main/resources/gitscope.backend.xml index 4c4698a..8b611ad 100644 --- a/backend/src/main/resources/gitscope.backend.xml +++ b/backend/src/main/resources/gitscope.backend.xml @@ -59,7 +59,7 @@ - + diff --git a/frontend/src/main/resources/gitscope.frontend.xml b/frontend/src/main/resources/gitscope.frontend.xml index 8b6ca94..16c12ce 100644 --- a/frontend/src/main/resources/gitscope.frontend.xml +++ b/frontend/src/main/resources/gitscope.frontend.xml @@ -17,27 +17,27 @@ From 817bf0c4b39963bf15811ae632692a5bae62588f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20W=C3=A5llberg?= Date: Thu, 23 Jul 2026 19:34:01 +0200 Subject: [PATCH 11/16] refactor: drop obsolete FileEditorOpenRequest reflection Remove the FileEditorOpenRequest / openInPreviewTab reflection from PlatformApiReflection. It was superseded by FileOpener calling the FileEditorManagerEx.openFile(FileEditorOpenRequest) API directly, and the reflective openInPreviewTab method had no remaining callers. Also ignore the .kotlin build-session cache. --- .gitignore | 3 +- .../java/utils/PlatformApiReflection.java | 61 ------------------- 2 files changed, 2 insertions(+), 62 deletions(-) diff --git a/.gitignore b/.gitignore index f0412ba..7dd2d69 100644 --- a/.gitignore +++ b/.gitignore @@ -50,4 +50,5 @@ gradle-app.setting # End of https://www.gitignore.io/api/java,gradle gradle.properties -.intellijPlatform \ No newline at end of file +.intellijPlatform +.kotlin/ \ No newline at end of file diff --git a/backend/src/main/java/utils/PlatformApiReflection.java b/backend/src/main/java/utils/PlatformApiReflection.java index 814529f..339e327 100644 --- a/backend/src/main/java/utils/PlatformApiReflection.java +++ b/backend/src/main/java/utils/PlatformApiReflection.java @@ -1,10 +1,7 @@ package utils; import com.intellij.openapi.diagnostic.Logger; -import com.intellij.openapi.fileEditor.FileEditorManager; -import com.intellij.openapi.project.Project; import com.intellij.openapi.vcs.changes.Change; -import com.intellij.openapi.vfs.VirtualFile; import git4idea.GitCommit; import git4idea.GitReference; import git4idea.repo.GitRepository; @@ -15,7 +12,6 @@ import java.lang.invoke.MethodHandle; import java.lang.invoke.MethodHandles; import java.lang.invoke.MethodType; -import java.lang.reflect.Constructor; import java.lang.reflect.Method; import java.util.Collection; import java.util.Collections; @@ -38,10 +34,6 @@ * {@code @ApiStatus.Experimental} annotation *

  • {@link #findTagByName} — tag lookup via the newer {@code getTagsHolder()} * (2026.1+) or legacy {@code getTagHolder()} (older IDEs)
  • - *
  • {@link #openInPreviewTab} — open a file using {@code FileEditorOpenRequest} - * via the {@code @ApiStatus.Experimental} {@code FileEditorManagerEx.openFile} - * overload (the older {@code FileEditorOpenOptions} overload is {@code @Internal} - * in 2026.1+ and JetBrains directs external plugins to the Request overload)
  • * * *

    See also {@link utils.SharedReflection} for shared-module bridges (gutter area, @@ -66,17 +58,6 @@ public final class PlatformApiReflection { private static final @Nullable MethodHandle REPO_GET_TAG_HOLDER; private static final @Nullable MethodHandle TAG_HOLDER_GET_TAG; // (Object, Object name) -> Object - // ── Preview tab (FileEditorOpenRequest, @ApiStatus.Experimental) ────────── - // The old (Editor, FileEditorOpenOptions) path is @ApiStatus.Internal in 2026.1; - // JetBrains directs external plugins to the FileEditorOpenRequest overload instead - // (see FileEditorManagerEx.openFile javadoc). That overload is @ApiStatus.Experimental, - // so we still route through reflection to keep verifyPlugin clean. - // Primary constructor: (EditorWindow?, FileEditorOpenMode?, selectAsCurrent, reuseOpen, - // usePreviewTab, requestFocus, pin) - private static final @Nullable MethodHandle OPEN_REQUEST_CTOR; - // FileEditorManagerEx.openFile(VirtualFile, FileEditorOpenRequest) -> FileEditorComposite - private static final @Nullable MethodHandle OPEN_FILE_WITH_REQUEST; - static { // ── GitCommit.getChanges() ───────────────────────────────────────── COMMIT_GET_CHANGES = resolvePublicVirtual(GitCommit.class, "getChanges"); @@ -93,28 +74,6 @@ public final class PlatformApiReflection { // ── Legacy tag path ──────────────────────────────────────────────── REPO_GET_TAG_HOLDER = resolvePublicVirtual(GitRepository.class, "getTagHolder"); TAG_HOLDER_GET_TAG = resolveByClassName("git4idea.repo.GitTagHolder", "getTag", String.class); - - // ── Preview tab (FileEditorOpenRequest + FileEditorManagerEx.openFile) ─ - MethodHandle openReqCtor = null; - MethodHandle openFileWithReq = null; - try { - Class editorWindowClass = Class.forName("com.intellij.openapi.fileEditor.impl.EditorWindow"); - Class openModeClass = Class.forName("com.intellij.openapi.fileEditor.ex.FileEditorOpenMode"); - Class openRequestClass = Class.forName("com.intellij.openapi.fileEditor.ex.FileEditorOpenRequest"); - Class femExClass = Class.forName("com.intellij.openapi.fileEditor.ex.FileEditorManagerEx"); - - Constructor c = openRequestClass.getDeclaredConstructor( - editorWindowClass, openModeClass, - boolean.class, boolean.class, boolean.class, boolean.class, boolean.class); - openReqCtor = MethodHandles.publicLookup().unreflectConstructor(c); - - Method m = femExClass.getMethod("openFile", VirtualFile.class, openRequestClass); - openFileWithReq = MethodHandles.publicLookup().unreflect(m); - } catch (Exception e) { - LOG.debug("PlatformApiReflection: FileEditorOpenRequest API not available — " + e.getMessage()); - } - OPEN_REQUEST_CTOR = openReqCtor; - OPEN_FILE_WITH_REQUEST = openFileWithReq; } private PlatformApiReflection() {} @@ -231,24 +190,4 @@ public static GitReference findTagByName(@NotNull GitRepository repo, @NotNull S } return null; } - - /** - * Opens a file in the editor's preview tab via the {@code @ApiStatus.Experimental} - * {@code FileEditorManagerEx.openFile(VirtualFile, FileEditorOpenRequest)} overload. - * Does nothing if the API is unavailable. - * - * @param project the current project - * @param file the file to open - */ - public static void openInPreviewTab(@NotNull Project project, @NotNull VirtualFile file) { - if (OPEN_REQUEST_CTOR == null || OPEN_FILE_WITH_REQUEST == null) return; - try { - // (targetWindow=null, openMode=null, selectAsCurrent=true, reuseOpen=true, - // usePreviewTab=true, requestFocus=true, pin=false) - Object request = OPEN_REQUEST_CTOR.invoke(null, null, true, true, true, true, false); - OPEN_FILE_WITH_REQUEST.invoke(FileEditorManager.getInstance(project), file, request); - } catch (Throwable t) { - LOG.debug("PlatformApiReflection: openInPreviewTab failed", t); - } - } } From dbf25c327261d8ebf65e4a54206e7418c15dc79f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20W=C3=A5llberg?= Date: Fri, 24 Jul 2026 13:19:31 +0200 Subject: [PATCH 12/16] fix: don't open a file when clicking a directory in the changes tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single- and double-click handlers used getSelectedChanges(), which for a directory node aggregates all changes beneath it — so clicking a folder opened its first file. Resolve the actual clicked/selected node instead and only open when it is a file/change leaf. For a directory, double-click now toggles expand/collapse (the platform's double-click handler reports the event as handled, suppressing the tree's default toggle, so we do it explicitly). --- .../elements/MySimpleChangesBrowser.java | 62 +++++++++++++++---- 1 file changed, 50 insertions(+), 12 deletions(-) diff --git a/backend/src/main/java/toolwindow/elements/MySimpleChangesBrowser.java b/backend/src/main/java/toolwindow/elements/MySimpleChangesBrowser.java index 0cfb7b0..11d040e 100644 --- a/backend/src/main/java/toolwindow/elements/MySimpleChangesBrowser.java +++ b/backend/src/main/java/toolwindow/elements/MySimpleChangesBrowser.java @@ -106,17 +106,19 @@ public void mouseClicked(MouseEvent e) { } if (e.getClickCount() == 1) { - + // Check if Shift or Ctrl is pressed - if so, skip opening file if (e.isShiftDown() || e.isControlDown()) { return; // Let the default selection behavior handle it } - Change[] selectedChanges = getSelectedChanges().toArray(new Change[0]); - - if (selectedChanges.length > 0) { - Change selectedChange = selectedChanges[0]; - VirtualFile file = selectedChange.getVirtualFile(); + // Only open when the clicked node is an actual file/change leaf. Clicking a + // directory (or any grouping node) must not open a file: getSelectedChanges() + // aggregates all changes under a directory, so using it here would open the + // directory's first file. Resolve the node under the click point instead. + Change clickedChange = getClickedChange(e); + if (clickedChange != null) { + VirtualFile file = clickedChange.getVirtualFile(); if (file != null && isPreviewTabEnabled(myProject)) { openInPreviewTab(myProject, file); } @@ -126,6 +128,25 @@ public void mouseClicked(MouseEvent e) { }); } + /** + * Returns the {@link Change} for the tree node directly under the click point, but only when + * that node is a file/change leaf. Returns null for directory or grouping nodes (whose user + * object is not a Change), so clicking a directory does not open a file. + */ + private @Nullable Change getClickedChange(MouseEvent e) { + if (!(getViewer() instanceof javax.swing.JTree tree)) return null; + javax.swing.tree.TreePath path = tree.getPathForLocation(e.getX(), e.getY()); + if (path == null) return null; + Object node = path.getLastPathComponent(); + if (node instanceof com.intellij.openapi.vcs.changes.ui.ChangesBrowserNode cbn) { + Object userObject = cbn.getUserObject(); + if (userObject instanceof Change change) { + return change; + } + } + return null; + } + /** * Whether single-click should open a file, based on the "Enable preview tab" setting. * Delegates to {@link utils.FileOpener#isPreviewTabEnabled(Boolean)} with the frontend-reported @@ -246,16 +267,33 @@ private String getScopeDisplayName() { @Override protected void onDoubleClick() { - // Handle double-click to open in regular/permanent tab - Change[] selectedChanges = getSelectedChanges().toArray(new Change[0]); - if (selectedChanges.length > 0) { - Change selectedChange = selectedChanges[0]; - VirtualFile file = selectedChange.getVirtualFile(); + if (!(getViewer() instanceof javax.swing.JTree tree)) return; + javax.swing.tree.TreePath path = tree.getSelectionPath(); + if (path == null) return; + + Object node = path.getLastPathComponent(); + Change change = null; + if (node instanceof com.intellij.openapi.vcs.changes.ui.ChangesBrowserNode cbn + && cbn.getUserObject() instanceof Change c) { + change = c; + } + + if (change != null) { + // File/change leaf: open in a regular (permanent) tab. + VirtualFile file = change.getVirtualFile(); if (file != null) { - // Double-click: open in regular (permanent) tab openAndScrollToChanges(myProject, file, -1, false); LOG.debug("Double-click: opened in permanent tab: " + file.getName()); } + } else { + // Directory / grouping node: toggle expand/collapse. The platform's double-click + // handler always reports the event as handled, which suppresses the tree's default + // expand/collapse, so we do it explicitly here. + if (tree.isExpanded(path)) { + tree.collapsePath(path); + } else { + tree.expandPath(path); + } } } } \ No newline at end of file From 3da41117ac382fc1371a50938aa70933b6c50113 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20W=C3=A5llberg?= Date: Fri, 24 Jul 2026 13:19:37 +0200 Subject: [PATCH 13/16] remove: Close Diff action (use the built-in Close Editor shortcut) The custom Close Diff action didn't work reliably in split mode (the keystroke was consumed as editor input rather than routed to the action), and it is redundant: the standard Close Tab / Close Editor shortcut closes a diff tab and always works. Remove the action, its registration, and the README references. --- README.md | 4 +-- .../frontend/actions/ChangeNavActions.kt | 25 ------------------- .../src/main/resources/gitscope.frontend.xml | 4 --- 3 files changed, 1 insertion(+), 32 deletions(-) diff --git a/README.md b/README.md index 184e6fe..5e2ff68 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,6 @@ Scope"): | **Next Changed File**| `Alt+Shift+Down` | Jump straight to the first change of the next changed file. | | **Previous Changed File** | `Alt+Shift+Up` | Jump straight to the first change of the previous changed file. | | **Show Diff** | `Alt+D` | Open the scope diff (`..working tree`) for the current file in an editor tab. | -| **Close Diff** | `Alt+Shift+D` | Close the active diff editor tab and return to the code. | ![](docs/keymap_review.png) @@ -135,7 +134,7 @@ A typical review flow: automatically opened, the caret lands on each change, and the file is highlighted in the Git Scope tool window so you always know where you are. 3. When the gutter markers aren't enough to understand a change, press **Show Diff** to open the full side-by-side scope - diff for that file, then **Close Diff** to jump back and keep moving. + diff for that file. Close it with the standard Close Tab / Close Editor shortcut to jump back and keep moving. 4. Use **Next/Previous Changed File** to skip whole files when you only want a per-file overview. ![](docs/review_navigation.png) @@ -231,7 +230,6 @@ The following review-navigation actions are also added, but ship **without** def | Next Changed File | First change of the next changed file | | Previous Changed File | First change of the previous changed file | | Show Diff | Open the scope diff for the current file in an editor tab | -| Close Diff | Close the active diff editor tab | ## More Useful Shortcuts diff --git a/frontend/src/main/java/gitscope/frontend/actions/ChangeNavActions.kt b/frontend/src/main/java/gitscope/frontend/actions/ChangeNavActions.kt index 67cf6ac..28038bf 100644 --- a/frontend/src/main/java/gitscope/frontend/actions/ChangeNavActions.kt +++ b/frontend/src/main/java/gitscope/frontend/actions/ChangeNavActions.kt @@ -99,31 +99,6 @@ class ShowDiffAction : AnAction(), DumbAware { } } -/** - * Closes the active diff editor tab (only when the currently selected editor is a diff), leaving - * other diff tabs untouched. Runs entirely on the frontend, where the editor tabs live. - */ -class CloseDiffAction : AnAction(), DumbAware { - override fun actionPerformed(e: AnActionEvent) { - val project = e.project ?: return - val fem = com.intellij.openapi.fileEditor.FileEditorManager.getInstance(project) - val selected = fem.selectedEditor?.file ?: return - if (selected is com.intellij.diff.editor.DiffContentVirtualFile) { - fem.closeFile(selected) - } - } - - override fun update(e: AnActionEvent) { - val project = e.project - val enabled = project != null && - com.intellij.openapi.fileEditor.FileEditorManager.getInstance(project) - .selectedEditor?.file is com.intellij.diff.editor.DiffContentVirtualFile - e.presentation.isEnabled = enabled - } - - override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT -} - /** * Project-level coroutine scope holder for firing the navigation RPC off the EDT. The platform * injects a scope tied to the project/plugin lifecycle. diff --git a/frontend/src/main/resources/gitscope.frontend.xml b/frontend/src/main/resources/gitscope.frontend.xml index 16c12ce..ee3ef56 100644 --- a/frontend/src/main/resources/gitscope.frontend.xml +++ b/frontend/src/main/resources/gitscope.frontend.xml @@ -35,9 +35,5 @@ class="gitscope.frontend.actions.ShowDiffAction" text="Show Diff" description="Show the Git Scope diff for the current file in an editor tab"/> - From 0101fe03eff344e2b26114f31b0879847cbfec3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20W=C3=A5llberg?= Date: Fri, 24 Jul 2026 13:59:43 +0200 Subject: [PATCH 14/16] feat: include local changes in next/previous change navigation Change navigation traversed only scope changes. Local changes (the IDE's own gutter markers, which we exclude from our scope painting) were computed in the gutter pipeline purely for exclusion and then discarded, so navigation skipped them. Publish those local ranges via GutterFileData and traverse the union of scope and local changes: the file list is now the union of both change sets, and the per-file stop lines are the de-duplicated union of scope and local marker start lines. Open files reuse the cached gutter ranges (exact match with the visible markers); unopened files compute both range sets on demand. --- .../MyLineStatusTrackerImpl.java | 14 ++- .../java/service/ChangeNavigationService.java | 102 ++++++++++-------- .../main/java/service/GutterDataService.java | 17 +++ 3 files changed, 87 insertions(+), 46 deletions(-) diff --git a/backend/src/main/java/implementation/lineStatusTracker/MyLineStatusTrackerImpl.java b/backend/src/main/java/implementation/lineStatusTracker/MyLineStatusTrackerImpl.java index 4c9691d..b8a540e 100644 --- a/backend/src/main/java/implementation/lineStatusTracker/MyLineStatusTrackerImpl.java +++ b/backend/src/main/java/implementation/lineStatusTracker/MyLineStatusTrackerImpl.java @@ -155,14 +155,16 @@ private static class UpdateInfo { final String headContent; final List precomputedRanges; final List scopeRanges; + final List localRanges; UpdateInfo(String filePath, String baseContent, String headContent, - List ranges, List scopeRanges) { + List ranges, List scopeRanges, List localRanges) { this.filePath = filePath; this.baseContent = baseContent; this.headContent = headContent; this.precomputedRanges = ranges; this.scopeRanges = scopeRanges; + this.localRanges = localRanges; } } @@ -233,10 +235,15 @@ private UpdateInfo prepareUpdateForEditor(Editor editor, Map sco List ranges; List scopeRanges = null; + List localRanges = null; try { if (headContent != null) { ranges = computeScopeRangesInCurrentSpace(headContent, normalizedBase, normalizedCurrent, filePath); scopeRanges = RangesBuilder.INSTANCE.createRanges(headContent, normalizedBase); + // Local changes = current document vs. HEAD, in current-document space. These are the + // markers the IDE paints in its own gutter (we exclude them from our scope painting); + // publish them so change navigation can also stop on them. + localRanges = RangesBuilder.INSTANCE.createRanges(normalizedCurrent, headContent); } else { ranges = RangesBuilder.INSTANCE.createRanges(normalizedCurrent, normalizedBase); } @@ -251,14 +258,15 @@ private UpdateInfo prepareUpdateForEditor(Editor editor, Map sco ranges = Collections.emptyList(); } - return new UpdateInfo(filePath, normalizedBase, headContent, ranges, scopeRanges); + return new UpdateInfo(filePath, normalizedBase, headContent, ranges, scopeRanges, localRanges); } private void publishBatchedUpdates(List updates) { for (UpdateInfo update : updates) { if (disposing.get()) break; GutterDataService.GutterFileData data = new GutterDataService.GutterFileData( - update.precomputedRanges, update.baseContent, update.headContent, update.scopeRanges); + update.precomputedRanges, update.baseContent, update.headContent, + update.scopeRanges, update.localRanges); gutterDataService.publish(update.filePath, data); publishedFiles.add(update.filePath); } diff --git a/backend/src/main/java/service/ChangeNavigationService.java b/backend/src/main/java/service/ChangeNavigationService.java index 0c49a45..d067cc9 100644 --- a/backend/src/main/java/service/ChangeNavigationService.java +++ b/backend/src/main/java/service/ChangeNavigationService.java @@ -73,14 +73,21 @@ public void navigate(@Nullable String currentFilePath, int caretLine, @NotNull C if (viewService == null) return; Map scopeChanges = viewService.getScopeChangesMap(); - if (scopeChanges == null || scopeChanges.isEmpty()) { - LOG.debug("ChangeNavigation: no scope changes"); + Map localChanges = viewService.getLocalChangesTowardsHeadMap(); + if (scopeChanges == null) scopeChanges = Collections.emptyMap(); + if (localChanges == null) localChanges = Collections.emptyMap(); + if (scopeChanges.isEmpty() && localChanges.isEmpty()) { + LOG.debug("ChangeNavigation: no scope or local changes"); return; } - // Ordered, stable list of changed files (path order, matching what the user scans). - List files = new ArrayList<>(scopeChanges.keySet()); - Collections.sort(files); + // 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 + // 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); // 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 @@ -95,10 +102,10 @@ public void navigate(@Nullable String currentFilePath, int caretLine, @NotNull C } switch (direction) { - case NEXT_CHANGE -> navigateChange(files, scopeChanges, fromFile, fromLine, true); - case PREVIOUS_CHANGE -> navigateChange(files, scopeChanges, fromFile, fromLine, false); - case NEXT_FILE -> navigateFile(files, scopeChanges, fromFile, true); - case PREVIOUS_FILE -> navigateFile(files, scopeChanges, fromFile, false); + case NEXT_CHANGE -> navigateChange(files, scopeChanges, localChanges, fromFile, fromLine, true); + case PREVIOUS_CHANGE -> navigateChange(files, scopeChanges, localChanges, fromFile, fromLine, false); + case NEXT_FILE -> navigateFile(files, scopeChanges, localChanges, fromFile, true); + case PREVIOUS_FILE -> navigateFile(files, scopeChanges, localChanges, fromFile, false); } } @@ -201,11 +208,13 @@ private String scopeDisplayName(ViewService viewService) { // --- Change-level navigation (within a file, crossing file boundaries at the ends) --- private void navigateChange(List files, Map scopeChanges, + Map localChanges, @Nullable String currentFilePath, int caretLine, boolean forward) { int fileIdx = currentFilePath == null ? -1 : files.indexOf(currentFilePath); if (fileIdx >= 0) { - List lines = changeStartLines(currentFilePath, scopeChanges.get(currentFilePath)); + List lines = changeStartLines(currentFilePath, scopeChanges.get(currentFilePath), + localChanges.get(currentFilePath)); Integer target = forward ? firstLineAfter(lines, caretLine) : firstLineBefore(lines, caretLine); if (target != null) { open(currentFilePath, target); @@ -213,18 +222,19 @@ private void navigateChange(List files, Map scopeChanges } // Past the last/first change of this file -> move to the adjacent file. int nextIdx = wrapIndex(fileIdx + (forward ? 1 : -1), files.size()); - openFileAtEdge(files, scopeChanges, nextIdx, forward); + openFileAtEdge(files, scopeChanges, localChanges, nextIdx, forward); return; } // No current file (or caret not in a changed file): jump to the first/last change overall. int startIdx = forward ? 0 : files.size() - 1; - openFileAtEdge(files, scopeChanges, startIdx, forward); + openFileAtEdge(files, scopeChanges, localChanges, startIdx, forward); } // --- File-level navigation (cycle between changed files) --- private void navigateFile(List files, Map scopeChanges, + Map localChanges, @Nullable String currentFilePath, boolean forward) { int fileIdx = currentFilePath == null ? -1 : files.indexOf(currentFilePath); int targetIdx; @@ -234,7 +244,7 @@ private void navigateFile(List files, Map scopeChanges, targetIdx = wrapIndex(fileIdx + (forward ? 1 : -1), files.size()); } // Always land on the first change of the target file when cycling files. - openFileAtEdge(files, scopeChanges, targetIdx, true); + openFileAtEdge(files, scopeChanges, localChanges, targetIdx, true); } /** @@ -242,10 +252,11 @@ private void navigateFile(List files, Map scopeChanges, * forward) or last change (when moving backward). Falls back to line 0 if ranges can't be * computed. */ - private void openFileAtEdge(List files, Map scopeChanges, int idx, boolean firstChange) { + private void openFileAtEdge(List files, Map scopeChanges, + Map localChanges, int idx, boolean firstChange) { if (idx < 0 || idx >= files.size()) return; String path = files.get(idx); - List lines = changeStartLines(path, scopeChanges.get(path)); + List lines = changeStartLines(path, scopeChanges.get(path), localChanges.get(path)); int line = 0; if (!lines.isEmpty()) { line = firstChange ? lines.get(0) : lines.get(lines.size() - 1); @@ -280,39 +291,51 @@ private void highlightInToolWindow(@NotNull VirtualFile file) { // --- Range helpers --- /** - * Returns the sorted 0-based start lines of all changes in the given file. + * Returns the sorted, de-duplicated 0-based start lines of all changes in the given file — + * the union of scope changes and local (working-tree vs HEAD) changes, so navigation stops on + * every visible gutter marker. * *

    Prefers the ranges already cached by the gutter pipeline (exact match with the visible - * markers) for open files; otherwise computes base-vs-current ranges on demand so navigation - * works for files that have not been opened yet. + * markers) for open files: {@code cached.ranges} are the scope markers and + * {@code cached.localRanges} are the local markers, both in current-document space. For files + * with no cached data (not opened yet) it computes both range sets on demand. */ - private List changeStartLines(String path, @Nullable Change change) { + private List changeStartLines(String path, @Nullable Change scopeChange, @Nullable Change localChange) { + java.util.TreeSet lines = new java.util.TreeSet<>(); + GutterDataService gds = project.getService(GutterDataService.class); - if (gds != null) { - GutterDataService.GutterFileData cached = gds.getData(path); - if (cached != null && cached.ranges != null && !cached.ranges.isEmpty()) { - return sortedStartLines(cached.ranges); + GutterDataService.GutterFileData cached = gds != null ? gds.getData(path) : null; + if (cached != null) { + if (cached.ranges != null) { + for (Range r : cached.ranges) lines.add(r.getLine1()); + } + if (cached.localRanges != null) { + for (Range r : cached.localRanges) lines.add(r.getLine1()); } + if (!lines.isEmpty()) return new ArrayList<>(lines); } - List computed = computeRanges(path, change); - return sortedStartLines(computed); - } - private static List sortedStartLines(List ranges) { - List lines = new ArrayList<>(ranges.size()); - for (Range r : ranges) lines.add(r.getLine1()); - Collections.sort(lines); - return lines; + // No cached gutter data (file not open): compute scope and local ranges on demand. + VirtualFile file = LocalFileSystem.getInstance().findFileByPath(path); + String currentContent = file == null ? null : ApplicationManager.getApplication().runReadAction( + (Computable) () -> { + Document doc = FileDocumentManager.getInstance().getDocument(file); + return doc != null ? doc.getImmutableCharSequence().toString() : null; + }); + if (currentContent == null) return new ArrayList<>(lines); + String normalizedCurrent = StringUtil.convertLineSeparators(currentContent); + + for (Range r : computeRanges(path, scopeChange, normalizedCurrent)) lines.add(r.getLine1()); + for (Range r : computeRanges(path, localChange, normalizedCurrent)) lines.add(r.getLine1()); + return new ArrayList<>(lines); } /** - * Computes base-vs-current change ranges for a file not covered by cached gutter data. - * Runs off the EDT concerns by reading content under a read action. + * Computes change ranges (before-revision vs. current content) for a file not covered by cached + * gutter data. Returns ranges in current-document coordinate space (line1/line2 are current-side). */ - private List computeRanges(String path, @Nullable Change change) { + private List computeRanges(String path, @Nullable Change change, String normalizedCurrent) { if (change == null || change.getBeforeRevision() == null) return Collections.emptyList(); - VirtualFile file = LocalFileSystem.getInstance().findFileByPath(path); - if (file == null) return Collections.emptyList(); String baseContent; try { @@ -323,14 +346,7 @@ private List computeRanges(String path, @Nullable Change change) { } if (baseContent == null) return Collections.emptyList(); - String currentContent = ApplicationManager.getApplication().runReadAction((Computable) () -> { - Document doc = FileDocumentManager.getInstance().getDocument(file); - return doc != null ? doc.getImmutableCharSequence().toString() : null; - }); - if (currentContent == null) return Collections.emptyList(); - String normalizedBase = StringUtil.convertLineSeparators(baseContent); - String normalizedCurrent = StringUtil.convertLineSeparators(currentContent); try { return RangesBuilder.INSTANCE.createRanges(normalizedCurrent, normalizedBase); } catch (Exception e) { diff --git a/shared/src/main/java/service/GutterDataService.java b/shared/src/main/java/service/GutterDataService.java index f9450b9..f0412b5 100644 --- a/shared/src/main/java/service/GutterDataService.java +++ b/shared/src/main/java/service/GutterDataService.java @@ -33,15 +33,32 @@ public static class GutterFileData { public final @NotNull String baseContent; public final @Nullable String headContent; public final @Nullable List scopeRanges; + /** + * Local-change ranges (current document vs. HEAD) in current-document coordinate space — + * i.e. the changes the IDE paints in its own gutter, which we deliberately exclude from our + * scope painting. Published so change navigation can also stop on local changes. May be + * null when there are no local changes or when the data was reconstructed on the frontend + * (navigation runs on the backend, so the frontend does not need this field). + */ + public final @Nullable List localRanges; public GutterFileData(@NotNull List ranges, @NotNull String baseContent, @Nullable String headContent, @Nullable List scopeRanges) { + this(ranges, baseContent, headContent, scopeRanges, null); + } + + public GutterFileData(@NotNull List ranges, + @NotNull String baseContent, + @Nullable String headContent, + @Nullable List scopeRanges, + @Nullable List localRanges) { this.ranges = ranges; this.baseContent = baseContent; this.headContent = headContent; this.scopeRanges = scopeRanges; + this.localRanges = localRanges; } } From b2de33eb8501709094ed5771431ff7bd0aa0f31a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20W=C3=A5llberg?= Date: Mon, 27 Jul 2026 21:40:47 +0200 Subject: [PATCH 15/16] Changelog update for 2026.2 release --- CHANGELOG.md | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3b8a3f..3dbc1c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,23 @@ ## [2026.2] +Remote Development support, built on a split of the plugin into separate frontend and backend modules. Local IDE +behavior, scope tabs and settings are unchanged. Given the size of the refactoring, +please [report any regression](https://github.com/comod/git-scope-pro/issues) with your IDE version and whether you run +locally or over Remote Development. + ### Added - Added [support for remote development](https://github.com/comod/git-scope-pro/issues/91) + - Git access, scope resolution and change collection run on the host; the client handles gutter markers, the diff + popup and change navigation. + - Only the computed line ranges cross the network, so gutter markers stay responsive, and the client re-syncs + automatically after a dropped connection. +- Added [actions/shortcuts to step between files and scope](https://github.com/comod/git-scope-pro/issues/99) + +### Fixes + +- Fixed [Right-click -> Show in Project doesn't switch to Project window](https://github.com/comod/git-scope-pro/issues/100) +- Fixed [Right-click -> Show in Project for folder selects the first changed file rather than the folder](https://github.com/comod/git-scope-pro/issues/101) ## [2026.1.4] @@ -33,12 +48,11 @@ ## [2026.1] -This release replaces the previous approach of hooking into the IDE's native gutter system — which -caused several unwanted side-effects and interfered with standard IDE functionality — with a -fully plugin-native gutter rendering system. The new markers can be placed either to the left of -the line numbers on their own, or inline with the IDE's native gutter markers, in which case both -work side by side. Clicking a marker opens a popup with navigation (prev/next), inline diff, -rollback, and copy actions. +This release replaces the previous approach of hooking into the IDE's native gutter system — which caused several +unwanted side-effects and interfered with standard IDE functionality — with a fully plugin-native gutter rendering +system. The new markers can be placed either to the left of the line numbers on their own, or inline with the IDE's +native gutter markers, in which case both work side by side. Clicking a marker opens a popup with navigation +(prev/next), inline diff, rollback, and copy actions. ### Added From c167e67a5116d8878a9bc29e483929f6c5a4e1e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20W=C3=A5llberg?= Date: Mon, 27 Jul 2026 22:59:41 +0200 Subject: [PATCH 16/16] ci: build with JBR 25 --- .github/workflows/build.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0e0cbaf..3c7de05 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,5 +1,5 @@ -# Build GitScope Pro using JBR 21 -name: Build IntelliJ Plugin with JBR 21 +# Build GitScope Pro using JBR 25 +name: Build IntelliJ Plugin with JBR 25 on: push: @@ -15,13 +15,13 @@ jobs: steps: - uses: actions/checkout@v6 - - name: Download JetBrains Runtime (JBR 21) + - name: Download JetBrains Runtime (JBR 25) run: | - wget https://cache-redirector.jetbrains.com/intellij-jbr/jbr_jcef-21.0.10-linux-x64-b1163.110.tar.gz -O jbr.tar.gz + wget https://cache-redirector.jetbrains.com/intellij-jbr/jbr_jcef-25.0.3-linux-x64-b508.16.tar.gz -O jbr.tar.gz mkdir -p jbr tar -xzf jbr.tar.gz -C jbr --strip-components=1 - - name: Set JAVA_HOME to JBR 21 + - name: Set JAVA_HOME to JBR 25 run: echo "JAVA_HOME=${{ github.workspace }}/jbr" >> $GITHUB_ENV - name: Verify Java version