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 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/CHANGELOG.md b/CHANGELOG.md index 86aee39..3dbc1c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,24 @@ +## [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] ### Added @@ -27,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 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/README.md b/README.md index 870c655..5e2ff68 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,94 @@ 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 | +|--------------------------------------|--------------------|---------------------------------------------------------------------------------------------------------------| +| **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. | + +![](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. 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) + +> **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 +175,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 +219,18 @@ 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 | +|----------------------------------|--------------------------------------------------------------------| +| 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 | + ## More Useful Shortcuts | Shortcut | Description | 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..3846f31 --- /dev/null +++ b/backend/build.gradle.kts @@ -0,0 +1,34 @@ +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") + // 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")) + compileOnly("com.google.code.gson:gson:2.14.0") +} 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..b8a540e --- /dev/null +++ b/backend/src/main/java/implementation/lineStatusTracker/MyLineStatusTrackerImpl.java @@ -0,0 +1,419 @@ +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; + final List localRanges; + + UpdateInfo(String filePath, String baseContent, String headContent, + List ranges, List scopeRanges, List localRanges) { + this.filePath = filePath; + this.baseContent = baseContent; + this.headContent = headContent; + this.precomputedRanges = ranges; + this.scopeRanges = scopeRanges; + this.localRanges = localRanges; + } + } + + 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; + 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); + } + + 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, 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.localRanges); + 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/backend/src/main/java/rpc/BackendUtilRpcImpl.kt b/backend/src/main/java/rpc/BackendUtilRpcImpl.kt new file mode 100644 index 0000000..3fdc170 --- /dev/null +++ b/backend/src/main/java/rpc/BackendUtilRpcImpl.kt @@ -0,0 +1,45 @@ +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 getCommands(projectId: ProjectId): Flow { + 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) + } + + 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 { + override fun RemoteApiProvider.Sink.remoteApis() { + remoteApi(remoteApiDescriptor()) { + BackendUtilRpcImpl() + } + } +} diff --git a/backend/src/main/java/rpc/UtilCommandService.kt b/backend/src/main/java/rpc/UtilCommandService.kt new file mode 100644 index 0000000..45faa74 --- /dev/null +++ b/backend/src/main/java/rpc/UtilCommandService.kt @@ -0,0 +1,30 @@ +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)) + } + + 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/service/ChangeNavigationService.java b/backend/src/main/java/service/ChangeNavigationService.java new file mode 100644 index 0000000..d067cc9 --- /dev/null +++ b/backend/src/main/java/service/ChangeNavigationService.java @@ -0,0 +1,382 @@ +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(); + 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: 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 + // 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, 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); + } + } + + /** + * 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, + 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), + localChanges.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, 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, 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; + 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, localChanges, 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, + 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), localChanges.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, 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: {@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 scopeChange, @Nullable Change localChange) { + java.util.TreeSet lines = new java.util.TreeSet<>(); + + GutterDataService gds = project.getService(GutterDataService.class); + 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); + } + + // 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 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, String normalizedCurrent) { + if (change == null || change.getBeforeRevision() == 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 normalizedBase = StringUtil.convertLineSeparators(baseContent); + 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/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 79% rename from src/main/java/toolwindow/VcsTreeActions.java rename to backend/src/main/java/toolwindow/VcsTreeActions.java index 7beb8ea..a807d2e 100644 --- a/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.UtilCommandService.class).selectInProject(file.getUrl()); } } } + 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/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 65% rename from src/main/java/toolwindow/elements/MySimpleChangesBrowser.java rename to backend/src/main/java/toolwindow/elements/MySimpleChangesBrowser.java index 6e95bad..11d040e 100644 --- a/src/main/java/toolwindow/elements/MySimpleChangesBrowser.java +++ b/backend/src/main/java/toolwindow/elements/MySimpleChangesBrowser.java @@ -1,6 +1,5 @@ package toolwindow.elements; -import com.intellij.ide.ui.UISettings; import com.intellij.openapi.actionSystem.AnAction; import com.intellij.openapi.application.ApplicationManager; import com.intellij.openapi.editor.Editor; @@ -30,7 +29,6 @@ import javax.swing.*; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; -import utils.PlatformApiReflection; import java.util.*; import java.util.concurrent.*; @@ -38,7 +36,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 @@ -109,23 +106,20 @@ 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 (!uiSettings.getOpenInPreviewTabIfPossible()) { - 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 + // 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); } } @@ -134,8 +128,47 @@ 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 + * value (used in split mode, where the backend UISettings is not synced). + */ + private boolean isPreviewTabEnabled(Project project) { + Boolean fromFrontend = project.getService(rpc.UtilCommandService.class).getPreviewTabEnabled(); + return utils.FileOpener.isPreviewTabEnabled(fromFrontend); + } + private void openInPreviewTab(Project project, VirtualFile file) { - PlatformApiReflection.openInPreviewTab(project, file); + openInPreviewTab(project, file, -1); + } + + /** + * 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) { + int zeroBasedLine = line > 0 ? line - 1 : -1; + utils.FileOpener.openAndGoToLine(project, file, zeroBasedLine); } /** @@ -182,38 +215,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; + if (isPreview) { + openInPreviewTab(project, file, line); + } else { + ApplicationManager.getApplication().invokeLater(() -> { + if (project.isDisposed()) return; + FileEditor[] editors = FileEditorManager.getInstance(project).openFile(file, true); - 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(); - - // 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); } - } - }); + }); + } } /** @@ -225,17 +245,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); @@ -252,34 +265,35 @@ 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 - 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 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/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/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 51% rename from src/main/java/utils/PlatformApiReflection.java rename to backend/src/main/java/utils/PlatformApiReflection.java index aa7fe0a..339e327 100644 --- a/src/main/java/utils/PlatformApiReflection.java +++ b/backend/src/main/java/utils/PlatformApiReflection.java @@ -1,11 +1,7 @@ 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; -import com.intellij.openapi.vfs.VirtualFile; import git4idea.GitCommit; import git4idea.GitReference; import git4idea.repo.GitRepository; @@ -16,13 +12,10 @@ 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; 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 +34,10 @@ * {@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
  • * + * + *

    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,23 +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 - // ── 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); - static { // ── GitCommit.getChanges() ───────────────────────────────────────── COMMIT_GET_CHANGES = resolvePublicVirtual(GitCommit.class, "getChanges"); @@ -99,52 +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); - - // ── 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; - 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); - } catch (Exception e) { - LOG.debug("PlatformApiReflection: FileEditorOpenOptions 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; } private PlatformApiReflection() {} @@ -214,28 +143,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. * @@ -283,78 +190,4 @@ public static GitReference findTagByName(@NotNull GitRepository repo, @NotNull S } return null; } - - /** - * Opens a file in the editor's preview tab using the internal - * {@code FileEditorOpenOptions} API. 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; - } - 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); - } 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/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 new file mode 100644 index 0000000..8b611ad --- /dev/null +++ b/backend/src/main/resources/gitscope.backend.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/build.gradle.kts b/build.gradle.kts index eaed095..8392258 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,54 @@ 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") 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 + id("rpc") version "2.3.20-0.1" apply false } group = properties("pluginGroup") version = properties("pluginVersion") +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(25) + } + + tasks.withType { + sourceCompatibility = "25" + targetCompatibility = "25" + } +} + +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,9 +65,8 @@ 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) + keepUnreleasedSection.set(false) } dependencies { @@ -46,15 +74,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 +112,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 +134,20 @@ intellijPlatform { } +// Split Mode run task — launches frontend + backend processes locally for remote dev testing. +// 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 runMonolith +val runMonolith = intellijPlatformTesting.runIde.register("runMonolith") { + splitMode = false +} + gradle.taskGraph.whenReady { val isRelease = hasTask(":signPlugin") || hasTask(":publishPlugin") || hasTask(":verifyPlugin") tasks.named("buildSearchableOptions") { enabled = isRelease } @@ -115,7 +162,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 +179,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/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..28038bf --- /dev/null +++ b/frontend/src/main/java/gitscope/frontend/actions/ChangeNavActions.kt @@ -0,0 +1,107 @@ +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) + } +} + +/** + * 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/src/main/java/implementation/gutter/LineStatusGutterMarkerRenderer.kt b/frontend/src/main/java/implementation/gutter/LineStatusGutterMarkerRenderer.kt similarity index 90% rename from src/main/java/implementation/gutter/LineStatusGutterMarkerRenderer.kt rename to frontend/src/main/java/implementation/gutter/LineStatusGutterMarkerRenderer.kt index 2d11561..25d4d2d 100644 --- a/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 @@ -26,12 +29,13 @@ 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) 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/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 99% rename from src/main/java/implementation/gutter/ScopeLineStatusMarkerRenderer.kt rename to frontend/src/main/java/implementation/gutter/ScopeLineStatusMarkerRenderer.kt index fe796e7..9abc18f 100644 --- a/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) { diff --git a/frontend/src/main/java/rpc/FrontendGutterListeners.kt b/frontend/src/main/java/rpc/FrontendGutterListeners.kt new file mode 100644 index 0000000..c490d20 --- /dev/null +++ b/frontend/src/main/java/rpc/FrontendGutterListeners.kt @@ -0,0 +1,60 @@ +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 to gutter updates via RPC in split mode — in monolith, + // the frontend GutterDataService IS the backend GutterDataService (same instance), + // so re-publishing would cause an infinite loop. + if (!com.intellij.platform.ide.productMode.IdeProductMode.isMonolith) { + coroutineScope.launch { + durable { + GutterRpcApi.getInstance() + .getGutterUpdates(project.projectId()) + .collect { event -> handleEvent(event) } + } + } + } + } + + 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/java/rpc/FrontendUtilListeners.kt b/frontend/src/main/java/rpc/FrontendUtilListeners.kt new file mode 100644 index 0000000..a369022 --- /dev/null +++ b/frontend/src/main/java/rpc/FrontendUtilListeners.kt @@ -0,0 +1,76 @@ +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.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() + .getCommands(project.projectId()) + .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) + } + } + } + + 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) + } +} + +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 new file mode 100644 index 0000000..ee3ef56 --- /dev/null +++ b/frontend/src/main/resources/gitscope.frontend.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gradle.properties b/gradle.properties index df1a77c..421bd6f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,15 +2,15 @@ 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=261 platformType=IU #platformVersion=LATEST-EAP-SNAPSHOT -platformVersion=2026.1 +platformVersion=2026.2 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 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/rpc/UtilRpcApi.kt b/shared/src/main/java/rpc/UtilRpcApi.kt new file mode 100644 index 0000000..0e0a307 --- /dev/null +++ b/shared/src/main/java/rpc/UtilRpcApi.kt @@ -0,0 +1,62 @@ +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 UtilCommand { + @Serializable + 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 + + /** + * 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) + + /** + * 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()) + } + } +} diff --git a/shared/src/main/java/service/GutterDataService.java b/shared/src/main/java/service/GutterDataService.java new file mode 100644 index 0000000..f0412b5 --- /dev/null +++ b/shared/src/main/java/service/GutterDataService.java @@ -0,0 +1,141 @@ +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; + /** + * 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; + } + } + + 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 +