1
0
mirror of https://github.com/chylex/IntelliJ-IdeaVim.git synced 2026-06-14 17:02:31 +02:00

Compare commits

..

73 Commits

Author SHA1 Message Date
6711486812 Set plugin version to 58 2026-05-08 19:35:21 +02:00
e295270cc2 Disable IdeaVim in popup editors 2026-05-08 19:35:21 +02:00
e05be6da9c Fix AltGr not triggering Ctrl-Alt bindings on Windows 2026-05-08 19:27:40 +02:00
e4ec2abaef Add 'isactionenabled' function 2026-05-08 19:27:40 +02:00
07a1ea1a34 Fix Ex commands not working 2026-05-08 19:27:40 +02:00
3807bc619f Preserve visual mode after executing IDE action 2026-05-08 19:27:40 +02:00
38d1caf795 Make g0/g^/g$ work with soft wraps 2026-05-08 19:27:40 +02:00
c22f7ec8a8 Make gj/gk jump over soft wraps 2026-05-08 19:27:40 +02:00
42ea7fa70d Make camelCase motions adjust based on direction of visual selection 2026-05-08 19:27:40 +02:00
8a5fb3920d Make search highlights temporary & use different color for nearby results 2026-05-08 19:27:40 +02:00
920529e112 Do not switch to normal mode after inserting a live template 2026-05-08 19:27:40 +02:00
c07ce16a19 Exit insert mode after refactoring 2026-05-08 19:27:39 +02:00
f0a6e85895 Add action to run last macro in all opened files 2026-05-08 19:27:39 +02:00
be817241d5 Stop macro execution after a failed search 2026-05-08 19:27:39 +02:00
ab34f0bcdc Revert per-caret registers 2026-05-08 19:27:39 +02:00
6dda6b6772 Apply scrolloff after executing native IDEA actions 2026-05-08 19:27:39 +02:00
8e1bfe6d01 Automatically add unambiguous imports after running a macro 2026-05-08 19:27:39 +02:00
7ff4d45d1c Fix(VIM-3986): Exception when pasting register contents containing new line 2026-05-08 19:27:39 +02:00
874eeb59a2 Fix(VIM-3179): Respect virtual space below editor (imperfectly) 2026-05-08 19:27:39 +02:00
5c9b787f30 Fix(VIM-3178): Workaround to support "Jump to Source" action mapping 2026-05-08 19:27:39 +02:00
ecc649ea7b Update search register when using f/t 2026-05-08 19:27:39 +02:00
03bcec7fe0 Add support for count for visual and line motion surround 2026-05-08 19:27:39 +02:00
9e64c6d36b Fix vim-surround not working with multiple cursors
Fixes multiple cursors with vim-surround commands `cs, ds, S` (but not `ys`).
2026-05-08 19:27:39 +02:00
b812ecbd29 Fix(VIM-696): Restore visual mode after undo/redo, and disable incompatible actions 2026-05-08 19:27:39 +02:00
cb5e88d8cf Respect count with <Action> mappings 2026-05-08 19:27:39 +02:00
f9b071bb47 Change matchit plugin to use HTML patterns in unrecognized files 2026-05-08 19:27:39 +02:00
8d41d01d6e Fix ex command panel causing Undock tool window to hide 2026-05-08 19:27:39 +02:00
9665ebd1b7 Reset insert mode when switching active editor 2026-05-08 19:27:38 +02:00
6ef6425037 Remove notifications about configuration options 2026-05-08 19:27:38 +02:00
3bc9a7816f Remove AI 2026-05-08 19:27:38 +02:00
5c2f67faca Set custom plugin version 2026-05-08 19:27:38 +02:00
735dfcbd4c Revert "Fix(VIM-4108): Use default ANTLR output directory for Gradle 9+ compatibility"
This reverts commit a476583ea3.
2026-05-08 19:27:38 +02:00
cda49601c0 Revert "Upgrade Gradle wrapper to 9.2.1"
This reverts commit 517bda93
2026-05-08 19:27:38 +02:00
a8893067c6 Revert "Fix(VIM-4109): Configure test source sets for Gradle 9+ compatibility"
This reverts commit 5c0d9569d9.
2026-05-08 19:27:38 +02:00
Alex Plate
8b74fa25a5 Register dev.ckob.lazygit in plugin compatibility checks
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 16:07:14 +03:00
Alex Plate
7db9b0b58b Switch compatibility verifier download to GitHub Releases
Move from the JetBrains Space-hosted custom verifier jar to a release
asset on the AlexPl292/intellij-plugin-verifier fork, and document the
refresh workflow (pull upstream, re-apply the dev-channel patch, run
publish-verifier-cli.yml, bump the URL).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 15:48:43 +03:00
Alex Plate
0527ad3359 Remove TeamCity TypeScript scripts smoke test
The smoke test served its purpose of verifying TypeScript scripts
can run on TeamCity. Other scripts-ts/ automation is unaffected.
2026-05-08 12:34:21 +03:00
claude[bot]
4f95756351 refactor(PlaybackRegisterAction): replace arrayOf workaround with idiomatic Kotlin
The `arrayOf(false)` pattern was a Java-style workaround for capturing a
mutable variable, unnecessary here since there are no lambdas involved.
Replace with a `var`, use `when` as an expression with a direct `return`,
and swap the manual while-loop counter for a `for` range.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 12:10:30 +03:00
Alex Plate
c17bb06f8a Remove trailing whitespace in Graphemes.kt 2026-05-08 11:41:57 +03:00
claude[bot]
7f23a06447 Update changelog: g; g, changelist navigation, tab command completion, VIM-4226 fix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 06:16:26 +00:00
1grzyb1
5bb18a2cb7 feat(VIM-519): cycle through recent edits with g; and g,
Implements g; and g, to walk through the per-buffer change list, mirroring Neovim's b_changelist semantics. The list is fed by the backend's RecentPlacesListener over an RPC topic (the same pattern as the existing jump service), so it works in both monolith and split mode.
2026-05-06 08:37:14 +02:00
1grzyb1
7d6315af2d feat(VIM-258): tab command completion
Add command completion on tab press in ex panel. Now we support both file name completions and command completions
2026-05-05 10:24:14 +02:00
1grzyb1
ff7b392e67 fix(VIM-4226): check if editor is disposed on focus
There were race condition when user opened ex panel and after closing editor might be disposed for some reason which resulted in unhandled exception and complete loss of focus
2026-05-05 09:36:29 +02:00
claude[bot]
2e5c5d815c Update changelog: mode() function, NERDTree fix, e flag fix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 06:09:10 +00:00
1grzyb1
e8c12a5526 fix(VIM-4224): respect e flag in search patterns 2026-05-04 12:33:56 +02:00
1grzyb1
4fc912eee7 feat(VIM-3975): support vim mode() function 2026-05-04 12:18:02 +02:00
1grzyb1
f4418b833f fix(VIM-4196): restore file selection after esc in nerdtree 2026-05-04 11:40:27 +02:00
1grzyb1
925cdbd8ac fix(VIM-4211): commit window work with conventional commits plugin
Conventioal Commits plugin was replacing `Dummy.txt` commit window file with it's own. In this commit we redesign checking if editor is commit window by getting editor key for commit window
2026-05-04 10:10:46 +02:00
claude[bot]
bc5ac2bcf4 Update changelog: VIM-4221 incsearch error sound fix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 06:15:32 +00:00
dependabot[bot]
9278425a5d Bump org.jetbrains.kotlin:kotlin-stdlib from 2.3.10 to 2.3.21
Bumps [org.jetbrains.kotlin:kotlin-stdlib](https://github.com/JetBrains/kotlin) from 2.3.10 to 2.3.21.
- [Release notes](https://github.com/JetBrains/kotlin/releases)
- [Changelog](https://github.com/JetBrains/kotlin/blob/master/ChangeLog.md)
- [Commits](https://github.com/JetBrains/kotlin/compare/v2.3.10...v2.3.21)

---
updated-dependencies:
- dependency-name: org.jetbrains.kotlin:kotlin-stdlib
  dependency-version: 2.3.21
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-29 15:38:43 +00:00
dependabot[bot]
26b9515279 Bump gradle-wrapper from 9.4.1 to 9.5.0
Bumps [gradle-wrapper](https://github.com/gradle/gradle) from 9.4.1 to 9.5.0.
- [Release notes](https://github.com/gradle/gradle/releases)
- [Commits](https://github.com/gradle/gradle/compare/v9.4.1...v9.5.0)

---
updated-dependencies:
- dependency-name: gradle-wrapper
  dependency-version: 9.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-29 15:36:48 +00:00
1grzyb1
a41f197bc5 VIM-3948: document VimEverywhere plugin
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 13:15:05 +02:00
1grzyb1
e551bfe535 VIM-3948: rebind Toggle Hints to avoid AI Assistant conflict
Ctrl+\ collides with the JetBrains AI Assistant's "Ask AI in Editor"
binding. Move the hints overlay to Ctrl+Shift+\ on the default keymap
and Ctrl+Cmd+\ on macOS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 13:15:05 +02:00
1grzyb1
093244c07d FIX(VIM-4221) Don't make angry sounds on search
When incsearch was true, we made the error sound on ech keypress if regex was invalid
2026-04-29 09:15:43 +02:00
claude[bot]
a08c253e6c Update changelog: VIM-4202 C/C++ comment space fix, VIM-4219 null check
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 06:14:58 +00:00
1grzyb1
073d3b40d5 VIM-4202 Add space after c langauges comments
normally space is added by rider/clion backend which we cannot directlly execute as it is not running on JVM. Workaround is to specify space flag for comment handler
2026-04-28 13:13:56 +02:00
1grzyb1
281039c76e FIX(VIM-4219) check for in VimPLugin is not null
sometimes where the plugin was being unloaded, getInstance might return null
2026-04-28 12:38:51 +02:00
1grzyb1
ec06a522da Wrap split mode tests with xvfb-run
The agent has Xvfb installed but no X server is actually running on
:99, so the frontend IDE crashes at startup with
'Can't connect to X11 window server using :99'. Use `xvfb-run -a` to
spin up an Xvfb on a free display for the duration of the build, and
let it manage DISPLAY itself (drop the static env.DISPLAY=:99 param).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 10:21:42 +02:00
1grzyb1
c6ca4428ba Drop Xvfb startup from split mode tests script
Xvfb is already running on the agent, so the build step doesn't need
to start it. Reduce the step to just the gradle invocation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 09:29:29 +02:00
1grzyb1
a440bcb108 Re-enable split mode tests in TeamCity
Xvfb is now installed on the TeamCity agent, so the VCS trigger and
the install step can be removed. Reverts the disable from 34196bc0d.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 09:00:26 +02:00
claude[bot]
4be7a68ebd Update changelog: VIM-4209 Esc in Rider popup, VIM-4211 commit window fix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 06:16:05 +00:00
1grzyb1
ec14d06ecd fix: make ideavim work in commit window 2026-04-27 13:14:32 +02:00
1grzyb1
a2b0e7b273 Fix(VIM-4209): handle esc in rider before popup
Popup from ctrl+space where handling esc key before everything and it never reached our rider esc handler
2026-04-27 13:12:50 +02:00
1grzyb1
4e9f3a5e11 Repair tests broken by octopus handler removal 2026-04-27 12:51:17 +02:00
1grzyb1
057e3f6d19 fix CommandParserTest after removing octopus
in VimTestCase there was check for octopus to pass enter/esc directly do editor but it was removed so we check now if we are in cmd line mode
2026-04-27 12:51:17 +02:00
Alex Plate
b5d2f9c3d8 Fix(VIM-2974): Remove remaining octopus.handler references
Step 5/N of removing the octopus handler. Cleanup pass over build config,
CI workflows, and stale comments. No runtime behavior change.

- build.gradle.kts: drop systemProperty("octopus.handler", ...) from
  runIde, runPycharm, runWebstorm, runClion, and runIdeForUiTests.
  The runIde { } block becomes empty and is removed entirely.
- Delete .github/workflows/runUiOctopusTests.yml: this workflow ran the
  UI test suite with -Doctopus.handler=false to verify behavior without
  octopus. That is now the only behavior, so the workflow is redundant
  with runUiTestsIJ.yml.
- IdeaSpecifics.LookupTopicListener and RiderEscLookupListener: update
  comments that justified Rider/CLion Nova-specific handling as "octopus
  is disabled (VIM-3815)". The actual reason is unrelated to octopus:
  these IDEs' popup manager consumes Escape before the action system
  runs. The Rider/CLion Nova gating remains correct.
2026-04-24 17:41:48 +03:00
Alex Plate
705ce474f1 Fix(VIM-2974): Delete VimEnterHandler.kt
Step 4/N of removing the octopus handler. Deletes the file that held
all octopus infrastructure (both abstract base and the concrete
handlers). Its XML registrations were removed in step 3, so the classes
had been unreachable code.

- Delete VimEnterHandler.kt entirely: OctopusHandler, VimKeyHandler,
  VimEnterHandler, VimEscHandler, VimEscForRiderHandler,
  VimEscLoggerHandler, VimEnterLoggerHandler, CaretShapeEnterEditorHandler,
  StartNewLineDetector, StartNewLineBeforeCurrentDetector,
  isOctopusEnabled(KeyStroke, Editor), enableOctopus, commandContinuation.
- ChangeGroup.processEnter(editor, caret, context): delete. It existed
  only to continue execution into the next octopus EditorActionHandler
  via commandContinuation when inside the octopus chain. With octopus
  gone, InsertEnterAction and SelectEnterAction call the editor-level
  processEnter(editor, context) which dispatches through the IJ action
  system as usual.
- VimChangeGroup: drop the per-caret processEnter declaration.
- InsertEnterActionTest: drop the @BeforeEach that set up three octopus
  handler variants via ExtensionTestUtil.maskExtensions (existed to test
  around IDEA-300030). Convert @RepeatedTest(3) to @Test - there are no
  longer three configurations to exercise.
2026-04-24 17:41:48 +03:00
Alex Plate
4869fe68e5 Fix(VIM-2974): Remove octopus editorActionHandler registrations
Step 3/N of removing the octopus handler. Takes the 9 octopus handlers
out of IntelliJ's editorActionHandler chain for EditorEnter, EditorEscape,
EditorStartNewLine, and EditorStartNewLineBefore.

- IdeaVIM.ideavim-frontend.xml: remove registrations for VimEnterHandler,
  CaretShapeEnterEditorHandler, VimEscHandler, VimEscLoggerHandler,
  VimEnterLoggerHandler, StartNewLineDetector, StartNewLineBeforeCurrentDetector.
- IdeaVIM.ideavim-rider.xml and IdeaVIM.ideavim-clion-nova.xml: remove
  Rider-specific VimEscForRiderHandler registration.

At this point Enter and Esc flow exclusively through VimShortcutKeyAction
(as they already did on Rider, CLion Nova, and JetBrains Client for the
last 14+ months). The handler classes themselves remain in
VimEnterHandler.kt as unreachable code; they are deleted in step 4.
2026-04-24 17:41:47 +03:00
Alex Plate
852ea2feb0 Fix(VIM-2974): Delete isOctopusEnabled() and octopus-support files
Step 2/N of removing the octopus handler. Removes the flag and three
support files whose entire purpose was supporting the octopus migration.

- VimApplication interface: drop isOctopusEnabled() method.
- IjVimApplication: drop the override.
- VimEnterHandler.enableOctopus: now a const false, decoupled from the
  deleted interface method. Octopus handler classes (still in this file)
  continue to compile but early-return and pass through to nextHandler.
- Delete KeymapChecker.kt: checked that the keymap had Esc bound to
  ACTION_EDITOR_ESCAPE because octopus owned EditorEscape. No longer
  meaningful with VimShortcutKeyAction handling Esc directly.
- Delete CopilotKeymapCorrector.kt (VIM-3206): removed Copilot's Esc
  shortcut because octopus intercepted EditorEscape. Rider / CLion Nova /
  JBClient have run with octopus disabled for 14+ months without needing
  this workaround.
- Delete EditorHandlersChainLogger.kt: debug logger for the
  editorActionHandler chain, useful only during the octopus era.
- NotificationService / VimNotifications: drop notifyKeymapIssues (only
  caller was KeymapChecker).
- VimListenerManager: drop correctorRequester / keyCheckRequests kicks
  from turnOn / turnOff (flows lived inside the deleted files).
- IdeaVIM.ideavim-frontend.xml: remove postStartupActivity entries for
  the three deleted classes and their keymap listener registrations.

Octopus handler classes in VimEnterHandler.kt and their XML registrations
are still present but now fully unreachable at runtime. They are removed
in subsequent steps.
2026-04-24 17:41:47 +03:00
Alex Plate
3dc0ebd2d1 Fix(VIM-2974): Collapse callers of isOctopusEnabled
Step 1/N of removing the octopus handler. With isOctopusEnabled()
hardcoded to false, the octopus branches in callers are unreachable.
This commit removes them, keeping only the non-octopus path.

- VimShortcutKeyAction: remove the early-return that skipped Enter/Esc
  when octopus was active.
- KeyGroup / VimKeyGroupBase: always register Enter/Esc in
  requiredShortcutKeys (the filter existed only to hand them to octopus).
- InsertEnterAction / SelectEnterAction: drop the forEachNativeCaret
  branch that worked around IDEA-300030 inside the octopus chain; the
  non-octopus processEnter(editor, context) call handles all carets.
- VimTestCase: dispatch Enter/Esc like any other key via
  VimShortcutKeyAction.
- InsertEnterActionTest: remove per-repetition extension masking that
  set up different octopus handler variants.

Octopus handler classes and their XML registrations are still present
but now unused; they are removed in subsequent steps.
2026-04-24 17:41:47 +03:00
Alex Plate
c33b0928dd Fix(VIM-2974): Hardcode isOctopusEnabled to false
Step 0/N of removing the octopus handler. Disables octopus at runtime
so subsequent steps can safely remove callers and handlers without
introducing a window where both octopus and VimShortcutKeyAction
compete for Enter/Esc.

The octopus handler chain remains registered but becomes a pass-through
(OctopusHandler.doExecute falls through to nextHandler when
isThisHandlerEnabled() returns false).
2026-04-24 17:41:47 +03:00
1grzyb1
02b4b4969a FIX(VIM-4184): clipboad=unnamed paste bugs on wayland
Use in-memory register when PRIMARY is unavailable due to wayland focus loss and bypass the live PRIMARY read during cisual paste to avoid automatic selection tracking overwriiting the yanked text
2026-04-24 16:03:49 +02:00
Alex Plate
f1d971c239 Document Fix(VIM-XXX) commit format in CLAUDE.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:07:53 +03:00
77 changed files with 1776 additions and 1547 deletions

View File

@@ -1,182 +0,0 @@
name: Run Non Octopus UI Tests macOS
on:
workflow_dispatch:
schedule:
- cron: '0 12 * * *'
jobs:
build-for-ui-test-mac-os:
if: github.repository == 'JetBrains/ideavim'
runs-on: macos-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: zulu
java-version: 21
- name: Setup FFmpeg
run: brew install ffmpeg
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
- name: Build Plugin
run: gradle :buildPlugin
- name: Run Idea
run: |
mkdir -p build/reports
gradle runIdeForUiTests -Doctopus.handler=false > build/reports/idea.log &
- name: List available capture devices
run: ffmpeg -f avfoundation -list_devices true -i "" 2>&1 || true
continue-on-error: true
- name: Start screen recording
run: |
mkdir -p build/reports/ci-screen-recording
ffmpeg -f avfoundation -capture_cursor 1 -i "0:none" -r 30 -vcodec libx264 -pix_fmt yuv420p build/reports/ci-screen-recording/screen-recording.mp4 &
echo $! > /tmp/ffmpeg_pid.txt
continue-on-error: true
- name: Auto-click Allow button for screen recording permission
run: |
sleep 3
brew install cliclick || true
for coords in "512:367" "960:540" "640:400" "800:450"; do
x=$(echo $coords | cut -d: -f1)
y=$(echo $coords | cut -d: -f2)
echo "Trying coordinates: $x,$y"
cliclick c:$x,$y 2>/dev/null && echo "cliclick succeeded" && break
sleep 0.5
osascript -e "tell application \"System Events\" to click at {$x, $y}" 2>/dev/null && echo "AppleScript succeeded" && break
sleep 1
done
continue-on-error: true
- name: Wait for Idea started
uses: jtalk/url-health-check-action@v3
with:
url: http://127.0.0.1:8082
max-attempts: 20
retry-delay: 10s
- name: Tests
run: gradle :tests:ui-ij-tests:testUi
- name: Stop screen recording
if: always()
run: |
if [ -f /tmp/ffmpeg_pid.txt ]; then
kill $(cat /tmp/ffmpeg_pid.txt) || true
sleep 2
fi
continue-on-error: true
- name: Move sandbox logs
if: always()
run: mv build/idea-sandbox/IU-*/log_runIdeForUiTests idea-sandbox-log
- name: AI Analysis of Test Failures
if: failure()
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
claude_args: '--allowed-tools "Read,Write,Edit,Glob,Grep,Bash(ffmpeg:*),Bash(ffprobe:*),Bash(mkdir:*),Bash(touch:*),Bash(echo:*),Bash(ls:*),Bash(cat:*),Bash(grep:*),Bash(find:*),Bash(cd:*),Bash(rm:*),Bash(git:*),Bash(gh:*),Bash(gradle:*),Bash(./gradlew:*),Bash(java:*),Bash(which:*)"'
prompt: |
## Task: Analyze UI Test Failures
Please analyze the UI test failures in the current directory.
Key information:
- Test reports are located in: build/reports and tests/ui-ij-tests/build/reports
- There is a CI screen recording at build/reports/ci-screen-recording/screen-recording.mp4 that shows what happened during the entire test run - this video is usually very useful for understanding what went wrong visually
- There is also a single screenshot at tests/ui-ij-tests/build/reports/ideaVimTest.png showing the state when the test failed
- IDE sandbox logs are in the idea-sandbox-log directory
- ffmpeg is already installed and available. Useful commands for video analysis:
* Extract frame at specific time: `ffmpeg -i video.mp4 -ss 00:01:30 -vframes 1 output.png`
* Extract multiple frames: `ffmpeg -i video.mp4 -vf fps=1/10 frame_%04d.png` (1 frame every 10 seconds)
* Get video duration: `ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 video.mp4`
* Extract last N seconds: `ffmpeg -sseof -10 -i video.mp4 -update 1 last_frame.png` (last 10 seconds)
* Create thumbnail grid: `ffmpeg -i video.mp4 -vf "select='not(mod(n\,300))',scale=160:120,tile=5x5" -frames:v 1 grid.png`
Special troubleshooting for timeout failures:
- If the test fails because it waited for something to appear, but according to the video and screenshot the element actually appeared on screen, check the UI hierarchy file at build/reports/hierarchy-ideaVimTest.html
- This hierarchy file shows the actual structure of UI elements and their properties at the time of failure
- The failure may be caused by a renamed property or changed class name in the UI element
- If you find this is the case, suggest a new query or selector that matches the current element structure
Please provide:
1. A detailed analysis of what went wrong
2. The root cause of the failure
3. Potential fixes or suggestions
Write your analysis to build/reports/ai-analysis.txt
## UI Test Best Practices
When fixing UI tests, follow these principles:
**Cause-Effect Over Timeouts**: UI tests should wait for specific conditions rather than arbitrary timeouts.
- ✅ GOOD: Wait for a button to become visible or enabled before clicking it
- ✅ GOOD: Wait for specific text to appear in a component
- ✅ GOOD: Wait for a dialog to be present with a specific accessible name
- ❌ BAD: Use Thread.sleep() or fixed delays
- ❌ BAD: Wait for arbitrary timeouts without checking for specific conditions
Only use timeouts as a maximum wait duration with explicit condition checks (e.g., "wait up to 30 seconds for dialog to appear").
Tests should be deterministic and based on observable state changes in the UI, not time-based assumptions.
IMPORTANT: If you have a concrete suggestion for fixing the test, ALWAYS proceed with creating a branch and PR. Never ask for permission - just do it.
If you have a concrete suggestion for fixing the test:
1. Create a new branch with a descriptive name (e.g., fix/ui-test-accessible-name)
2. Apply your suggested fix to the codebase
3. CRITICAL: When staging changes, NEVER use `git add -A` or `git add .`. Always add modified files explicitly by path (e.g., `git add path/to/file.kt path/to/other.kt`). This prevents accidentally staging unrelated files.
4. MANDATORY: Verify compilation succeeds with your changes: `gradle compileKotlin compileTestKotlin`
5. MANDATORY: Run the specific failing test to verify the fix improves or resolves the issue
- For Non-Octopus UI tests: `gradle :tests:ui-ij-tests:testUi --tests "YourTestClassName.yourTestMethod"`
- To run all Non-Octopus UI tests: `gradle :tests:ui-ij-tests:testUi`
- The test MUST either pass completely or show clear improvement (e.g., progressing further before failure)
6. If the test passes or shows improvement with your fix, create a PR with:
- Clear title describing the fix
- Description explaining the root cause and solution
- Test results showing the fix works
- Reference to the failing CI run
7. Use the base branch 'master' for the PR
- name: Save report
if: always()
uses: actions/upload-artifact@v4
with:
name: ui-test-fails-report-mac
path: |
build/reports
tests/ui-ij-tests/build/reports
idea-sandbox-log
# build-for-ui-test-linux:
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@v2
# - name: Setup Java
# uses: actions/setup-java@v2.1.0
# with:
# distribution: zulu
# java-version: 11
# - name: Build Plugin
# run: gradle :buildPlugin
# - name: Run Idea
# run: |
# export DISPLAY=:99.0
# Xvfb -ac :99 -screen 0 1920x1080x16 &
# mkdir -p build/reports
# gradle :runIdeForUiTests #> build/reports/idea.log
# - name: Wait for Idea started
# uses: jtalk/url-health-check-action@1.5
# with:
# url: http://127.0.0.1:8082
# max-attempts: 15
# retry-delay: 30s
# - name: Tests
# run: gradle :testUi
# - name: Save fails report
# if: ${{ failure() }}
# uses: actions/upload-artifact@v2
# with:
# name: ui-test-fails-report-linux
# path: |
# ui-test-example/build/reports

View File

@@ -17,7 +17,6 @@ import _Self.buildTypes.RandomOrderTests
import _Self.buildTypes.SplitModeTests
import _Self.buildTypes.TestingBuildType
import _Self.buildTypes.TypeScriptTest
import _Self.subprojects.Releases
import _Self.vcsRoots.ReleasesVcsRoot
import jetbrains.buildServer.configs.kotlin.v2019_2.BuildType
@@ -44,9 +43,6 @@ object Project : Project({
buildType(Nvim)
buildType(PluginVerifier)
buildType(Compatibility)
// TypeScript scripts test
buildType(TypeScriptTest)
})
// Agent size configurations (CPU count)

View File

@@ -33,40 +33,47 @@ object Compatibility : IdeaVimBuildType({
name = "Load Verifier"
scriptContent = """
mkdir verifier1
curl -f -L -o verifier1/verifier-cli-dev-all-2.jar "https://packages.jetbrains.team/files/p/ideavim/plugin-verifier/verifier-cli-dev-all-2.jar"
curl -f -L -o verifier1/verifier-cli-ideavim.jar "https://github.com/AlexPl292/intellij-plugin-verifier/releases/download/cli-3/verifier-cli-1.403-ideavim-3-all.jar"
""".trimIndent()
}
script {
name = "Check"
scriptContent = """
# We use a custom build of verifier that downloads IdeaVim from dev channel
# To create a custom build: Download plugin verifier repo, add an if that switches to dev channel for IdeaVim repo
# At the moment it's com.jetbrains.pluginverifier.repository.repositories.marketplace.MarketplaceRepository#getLastCompatibleVersionOfPlugin
# Build using gradlew :intellij-plugin-verifier:verifier-cli:shadowJar
# Upload verifier-cli-dev-all.jar artifact to the repo in IdeaVim space repo
# We use a custom build of plugin-verifier that resolves IdeaVim from the dev channel.
# The fork lives at https://github.com/AlexPl292/intellij-plugin-verifier — the patch is in
# com.jetbrains.pluginverifier.repository.repositories.marketplace.MarketplaceRepository#getLastCompatibleVersionOfPlugin
# (switches the marketplace channel to "dev" when pluginId is org.jetbrains.IdeaVim).
#
# To refresh against upstream:
# 1. In the fork, pull from upstream and re-apply the dev-channel patch.
# 2. Run the "Publish verifier-cli" workflow:
# https://github.com/AlexPl292/intellij-plugin-verifier/actions/workflows/publish-verifier-cli.yml
# It builds the shadow jar and attaches it to a new GitHub Release.
# 3. Update the release URL in the "Load Verifier" step above to point at the new jar.
java --version
java -jar verifier1/verifier-cli-dev-all-2.jar check-plugin '${'$'}org.jetbrains.IdeaVim-EasyMotion' [latest-IU] -team-city
java -jar verifier1/verifier-cli-dev-all-2.jar check-plugin '${'$'}eu.theblob42.idea.whichkey' [latest-IU] -team-city
java -jar verifier1/verifier-cli-dev-all-2.jar check-plugin '${'$'}IdeaVimExtension' [latest-IU] -team-city
# Outdated java -jar verifier/verifier-cli-dev-all.jar check-plugin '${'$'}github.zgqq.intellij-enhance' [latest-IU] -team-city
# java -jar verifier1/verifier-cli-dev-all-2.jar check-plugin '${'$'}com.github.copilot' [latest-IU] -team-city
java -jar verifier1/verifier-cli-dev-all-2.jar check-plugin '${'$'}com.github.dankinsoid.multicursor' [latest-IU] -team-city
java -jar verifier1/verifier-cli-dev-all-2.jar check-plugin '${'$'}com.joshestein.ideavim-quickscope' [latest-IU] -team-city
java -jar verifier1/verifier-cli-dev-all-2.jar check-plugin '${'$'}com.julienphalip.ideavim.peekaboo' [latest-IU] -team-city
java -jar verifier1/verifier-cli-dev-all-2.jar check-plugin '${'$'}com.julienphalip.ideavim.switch' [latest-IU] -team-city
java -jar verifier1/verifier-cli-dev-all-2.jar check-plugin '${'$'}com.julienphalip.ideavim.functiontextobj' [latest-IU] -team-city
java -jar verifier1/verifier-cli-dev-all-2.jar check-plugin '${'$'}com.miksuki.HighlightCursor' [latest-IU] -team-city
java -jar verifier1/verifier-cli-dev-all-2.jar check-plugin '${'$'}com.ugarosa.idea.edgemotion' [latest-IU] -team-city
java -jar verifier1/verifier-cli-dev-all-2.jar check-plugin '${'$'}cn.mumukehao.plugin' [latest-IU] -team-city
java -jar verifier1/verifier-cli-dev-all-2.jar check-plugin '${'$'}com.magidc.ideavim.dial' [latest-IU] -team-city
java -jar verifier1/verifier-cli-dev-all-2.jar check-plugin '${'$'}dev.ghostflyby.ideavim.toggleIME' [latest-IU] -team-city
java -jar verifier1/verifier-cli-dev-all-2.jar check-plugin '${'$'}com.magidc.ideavim.anyObject' [latest-IU] -team-city
java -jar verifier1/verifier-cli-dev-all-2.jar check-plugin '${'$'}com.yelog.ideavim.cmdfloat' [latest-IU] -team-city
java -jar verifier1/verifier-cli-dev-all-2.jar check-plugin '${'$'}gg.ninetyfive' [latest-IU] -team-city
java -jar verifier1/verifier-cli-dev-all-2.jar check-plugin '${'$'}com.github.pooryam92.vimcoach' [latest-IU] -team-city
java -jar verifier1/verifier-cli-dev-all-2.jar check-plugin '${'$'}lazyideavim.whichkeylazy' [latest-IU] -team-city
java -jar verifier1/verifier-cli-dev-all-2.jar check-plugin '${'$'}com.github.vimkeysuggest' [latest-IU] -team-city
java -jar verifier1/verifier-cli-ideavim.jar check-plugin '${'$'}org.jetbrains.IdeaVim-EasyMotion' [latest-IU] -team-city
java -jar verifier1/verifier-cli-ideavim.jar check-plugin '${'$'}eu.theblob42.idea.whichkey' [latest-IU] -team-city
java -jar verifier1/verifier-cli-ideavim.jar check-plugin '${'$'}IdeaVimExtension' [latest-IU] -team-city
# Outdated java -jar verifier/verifier-cli-ideavim.jar check-plugin '${'$'}github.zgqq.intellij-enhance' [latest-IU] -team-city
# java -jar verifier1/verifier-cli-ideavim.jar check-plugin '${'$'}com.github.copilot' [latest-IU] -team-city
java -jar verifier1/verifier-cli-ideavim.jar check-plugin '${'$'}com.github.dankinsoid.multicursor' [latest-IU] -team-city
java -jar verifier1/verifier-cli-ideavim.jar check-plugin '${'$'}com.joshestein.ideavim-quickscope' [latest-IU] -team-city
java -jar verifier1/verifier-cli-ideavim.jar check-plugin '${'$'}com.julienphalip.ideavim.peekaboo' [latest-IU] -team-city
java -jar verifier1/verifier-cli-ideavim.jar check-plugin '${'$'}com.julienphalip.ideavim.switch' [latest-IU] -team-city
java -jar verifier1/verifier-cli-ideavim.jar check-plugin '${'$'}com.julienphalip.ideavim.functiontextobj' [latest-IU] -team-city
java -jar verifier1/verifier-cli-ideavim.jar check-plugin '${'$'}com.miksuki.HighlightCursor' [latest-IU] -team-city
java -jar verifier1/verifier-cli-ideavim.jar check-plugin '${'$'}com.ugarosa.idea.edgemotion' [latest-IU] -team-city
java -jar verifier1/verifier-cli-ideavim.jar check-plugin '${'$'}cn.mumukehao.plugin' [latest-IU] -team-city
java -jar verifier1/verifier-cli-ideavim.jar check-plugin '${'$'}com.magidc.ideavim.dial' [latest-IU] -team-city
java -jar verifier1/verifier-cli-ideavim.jar check-plugin '${'$'}dev.ghostflyby.ideavim.toggleIME' [latest-IU] -team-city
java -jar verifier1/verifier-cli-ideavim.jar check-plugin '${'$'}com.magidc.ideavim.anyObject' [latest-IU] -team-city
java -jar verifier1/verifier-cli-ideavim.jar check-plugin '${'$'}com.yelog.ideavim.cmdfloat' [latest-IU] -team-city
java -jar verifier1/verifier-cli-ideavim.jar check-plugin '${'$'}gg.ninetyfive' [latest-IU] -team-city
java -jar verifier1/verifier-cli-ideavim.jar check-plugin '${'$'}com.github.pooryam92.vimcoach' [latest-IU] -team-city
java -jar verifier1/verifier-cli-ideavim.jar check-plugin '${'$'}lazyideavim.whichkeylazy' [latest-IU] -team-city
java -jar verifier1/verifier-cli-ideavim.jar check-plugin '${'$'}com.github.vimkeysuggest' [latest-IU] -team-city
java -jar verifier1/verifier-cli-ideavim.jar check-plugin '${'$'}dev.ckob.lazygit' [latest-IU] -team-city
""".trimIndent()
}
}

View File

@@ -13,6 +13,7 @@ import _Self.IdeaVimBuildType
import jetbrains.buildServer.configs.kotlin.v2019_2.CheckoutMode
import jetbrains.buildServer.configs.kotlin.v2019_2.DslContext
import jetbrains.buildServer.configs.kotlin.v2019_2.buildSteps.script
import jetbrains.buildServer.configs.kotlin.v2019_2.triggers.vcs
object SplitModeTests : IdeaVimBuildType({
name = "Split mode tests"
@@ -27,7 +28,6 @@ object SplitModeTests : IdeaVimBuildType({
params {
param("env.ORG_GRADLE_PROJECT_downloadIdeaSources", "false")
param("env.ORG_GRADLE_PROJECT_instrumentPluginCode", "false")
param("env.DISPLAY", ":99")
}
vcs {
@@ -39,43 +39,16 @@ object SplitModeTests : IdeaVimBuildType({
steps {
script {
name = "Start Xvfb and run split mode tests"
scriptContent = """
# Kill any leftover Xvfb from previous runs
pkill -f 'Xvfb :99' || true
Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp &
XVFB_PID=${'$'}!
# Wait until the display is ready
for i in $(seq 1 30); do
if xdpyinfo -display :99 >/dev/null 2>&1; then
echo "Xvfb is ready on :99"
break
fi
sleep 1
done
if ! xdpyinfo -display :99 >/dev/null 2>&1; then
echo "ERROR: Xvfb failed to start on :99"
exit 1
fi
./gradlew :tests:split-mode-tests:testSplitMode --console=plain --build-cache --configuration-cache --stacktrace
TEST_EXIT=${'$'}?
kill ${'$'}XVFB_PID 2>/dev/null || true
exit ${'$'}TEST_EXIT
""".trimIndent()
name = "Run split mode tests"
scriptContent = "xvfb-run -a -s '-screen 0 1920x1080x24' ./gradlew :tests:split-mode-tests:testSplitMode --console=plain --build-cache --configuration-cache --stacktrace"
}
}
// VCS trigger disabled until Xvfb is installed on the TeamCity agent
// triggers {
// vcs {
// branchFilter = "+:<default>"
// }
// }
triggers {
vcs {
branchFilter = "+:<default>"
}
}
requirements {
// Use a larger agent for split-mode tests — they launch two full IDE instances

View File

@@ -1,45 +0,0 @@
package _Self.buildTypes
import _Self.IdeaVimBuildType
import jetbrains.buildServer.configs.kotlin.v2019_2.CheckoutMode
import jetbrains.buildServer.configs.kotlin.v2019_2.DslContext
import jetbrains.buildServer.configs.kotlin.v2019_2.buildSteps.script
object TypeScriptTest : IdeaVimBuildType({
id("IdeaVimTests_TypeScript")
name = "TypeScript Scripts Test"
description = "Test that TypeScript scripts can run on TeamCity"
vcs {
root(DslContext.settingsRoot)
branchFilter = "+:<default>"
checkoutMode = CheckoutMode.AUTO
}
steps {
script {
name = "Set up Node.js"
scriptContent = """
wget https://nodejs.org/dist/v20.18.1/node-v20.18.1-linux-x64.tar.xz
tar xf node-v20.18.1-linux-x64.tar.xz
export PATH="${"$"}PWD/node-v20.18.1-linux-x64/bin:${"$"}PATH"
node --version
npm --version
""".trimIndent()
}
script {
name = "Run TypeScript test"
scriptContent = """
export PATH="${"$"}PWD/node-v20.18.1-linux-x64/bin:${"$"}PATH"
cd scripts-ts
npm install
npx tsx src/teamcityTest.ts
""".trimIndent()
}
}
requirements {
equals("teamcity.agent.os.family", "Linux")
}
})

View File

@@ -37,6 +37,9 @@ usual beta standards.
* [VIM-566](https://youtrack.jetbrains.com/issue/VIM-566) Added support for `zf` command - create fold from selection or motion
* [VIM-566](https://youtrack.jetbrains.com/issue/VIM-566) Added support for `:set foldlevel` option - control fold visibility level
* [VIM-1158](https://youtrack.jetbrains.com/issue/VIM-1158) Added `gw` command - reformat code like `gq` but preserving the cursor position
* [VIM-3975](https://youtrack.jetbrains.com/issue/VIM-3975) Added `mode()` VimScript function - returns the current editing mode (e.g., `'n'` for normal, `'i'` for insert, `'v'` for visual, `'R'` for replace)
* [VIM-519](https://youtrack.jetbrains.com/issue/VIM-519) Added `g;` and `g,` commands - navigate the change list to jump to previous (`g;`) or next (`g,`) edit location
* [VIM-258](https://youtrack.jetbrains.com/issue/VIM-258) Added command name completion in ex commands - press `<Tab>` to cycle through matching command names (e.g., `:e<Tab>` shows `:edit`, `:earlier`, etc.)
### Fixes:
* [VIM-4197](https://youtrack.jetbrains.com/issue/VIM-4197) Fixed Vim features (e.g., `f`, `w`, text objects) not working in Java files decompiled from Kotlin class files
@@ -79,8 +82,29 @@ usual beta standards.
* [VIM-4202](https://youtrack.jetbrains.com/issue/VIM-4202) Fixed `<S-Tab>` being intercepted by IdeaVim - users can now remap `<S-Tab>` to other IntelliJ actions
* [VIM-4202](https://youtrack.jetbrains.com/issue/VIM-4202) Fixed `gcc`/`gc{motion}` commentary leaving editor in incorrect mode in Rider/CLion split mode
* [VIM-4115](https://youtrack.jetbrains.com/issue/VIM-4115) Fixed NullPointerException in `CommandKeyConsumer` when pressing Esc after disabling and re-enabling IdeaVim with an open command line
* [VIM-4209](https://youtrack.jetbrains.com/issue/VIM-4209) Fixed `<Esc>` not exiting insert mode in Rider/CLion when a `<C-Space>` completion popup intercepts the key before IdeaVim
* [VIM-4211](https://youtrack.jetbrains.com/issue/VIM-4211) Fixed IdeaVim not working in the Git commit window
* [VIM-4202](https://youtrack.jetbrains.com/issue/VIM-4202) Fixed `gcc`/`gc{motion}` commentary not adding space after `//` prefix in C/C++/C# files in Rider/CLion split mode
* [VIM-4219](https://youtrack.jetbrains.com/issue/VIM-4219) Fixed NullPointerException when IdeaVim is being disabled/unloaded
* [VIM-4221](https://youtrack.jetbrains.com/issue/VIM-4221) Fixed error sound being played on each keypress when `incsearch` is enabled and the typed pattern is an invalid regex
* [VIM-4196](https://youtrack.jetbrains.com/issue/VIM-4196) Fixed NERDTree file selection not being restored after pressing `<Esc>` to cancel a `/` speed search
* [VIM-4211](https://youtrack.jetbrains.com/issue/VIM-4211) Fixed IdeaVim not working in Git commit window when the Conventional Commits plugin is installed
* [VIM-4224](https://youtrack.jetbrains.com/issue/VIM-4224) Fixed `:s` `e` flag now properly suppresses "Pattern not found" errors - e.g., `%s/\s\+$//e` no longer errors when there is no trailing whitespace
* [VIM-4226](https://youtrack.jetbrains.com/issue/VIM-4226) Fixed race condition crash when the editor is disposed while the ex panel is open
### Merged PRs:
* [1747](https://github.com/JetBrains/ideavim/pull/1747) by [1grzyb1](https://github.com/1grzyb1): feat(VIM-519): cycle through recent edits with g; and g,
* [1745](https://github.com/JetBrains/ideavim/pull/1745) by [1grzyb1](https://github.com/1grzyb1): feat(VIM-258): tab command completion
* [1744](https://github.com/JetBrains/ideavim/pull/1744) by [1grzyb1](https://github.com/1grzyb1): fix(VIM-4226): check if editor is disposed on focus
* [1741](https://github.com/JetBrains/ideavim/pull/1741) by [1grzyb1](https://github.com/1grzyb1): fix(VIM-4224): respect e flag in search patterns
* [1740](https://github.com/JetBrains/ideavim/pull/1740) by [1grzyb1](https://github.com/1grzyb1): feat(VIM-3975): support vim mode() function
* [1739](https://github.com/JetBrains/ideavim/pull/1739) by [1grzyb1](https://github.com/1grzyb1): fix(VIM-4196): restore file selection after esc in nerdtree
* [1738](https://github.com/JetBrains/ideavim/pull/1738) by [1grzyb1](https://github.com/1grzyb1): fix(VIM-4211): commit window work with conectional commits plugin
* [1730](https://github.com/JetBrains/ideavim/pull/1730) by [1grzyb1](https://github.com/1grzyb1): FIX(VIM-4221) Don't make angry sounds on search
* [1728](https://github.com/JetBrains/ideavim/pull/1728) by [1grzyb1](https://github.com/1grzyb1): VIM-4202 Add space after c langauges comments
* [1727](https://github.com/JetBrains/ideavim/pull/1727) by [1grzyb1](https://github.com/1grzyb1): FIX(VIM-4219) check for in VimPLugin is not null
* [1720](https://github.com/JetBrains/ideavim/pull/1720) by [1grzyb1](https://github.com/1grzyb1): fix: make ideavim work in commit window
* [1717](https://github.com/JetBrains/ideavim/pull/1717) by [1grzyb1](https://github.com/1grzyb1): Fix(VIM-4209): handle esc in rider before popup
* [1704](https://github.com/JetBrains/ideavim/pull/1704) by [1grzyb1](https://github.com/1grzyb1): VIM-4202 Don't intercept all <S-Tab>
* [1703](https://github.com/JetBrains/ideavim/pull/1703) by [1grzyb1](https://github.com/1grzyb1): VIM-4202 Fix state after commentary action
* [1700](https://github.com/JetBrains/ideavim/pull/1700) by [1grzyb1](https://github.com/1grzyb1): VIM-4139 Compute nesting depth for fold regions

View File

@@ -209,7 +209,6 @@ tasks {
// Note that this will run the plugin installed in the IDE specified in dependencies. To run in a different IDE, use
// a custom task (see below)
runIde {
systemProperty("octopus.handler", System.getProperty("octopus.handler") ?: true)
systemProperty("idea.trust.all.projects", "true")
}
@@ -229,25 +228,16 @@ tasks {
val runPycharm by intellijPlatformTesting.runIde.registering {
type = IntelliJPlatformType.PyCharmProfessional
version = "2026.1"
task {
systemProperty("octopus.handler", System.getProperty("octopus.handler") ?: true)
}
}
val runWebstorm by intellijPlatformTesting.runIde.registering {
type = IntelliJPlatformType.WebStorm
version = "2025.3.2"
task {
systemProperty("octopus.handler", System.getProperty("octopus.handler") ?: true)
}
}
val runClion by intellijPlatformTesting.runIde.registering {
type = IntelliJPlatformType.CLion
version = "2026.1"
task {
systemProperty("octopus.handler", System.getProperty("octopus.handler") ?: true)
}
}
val runIdeForUiTests by intellijPlatformTesting.runIde.registering {
@@ -260,7 +250,6 @@ tasks {
"-Djb.privacy.policy.text=<!--999.999-->",
"-Djb.consents.confirmation.enabled=false",
"-Dide.show.tips.on.startup.default.value=false",
"-Doctopus.handler=" + (System.getProperty("octopus.handler") ?: true),
)
}
}
@@ -303,7 +292,7 @@ tasks {
}
val runCLionSplitMode by intellijPlatformTesting.runIde.registering {
type = IntelliJPlatformType.CLion
version = "2025.3.2"
version = "2026.1"
splitMode = true
splitModeTarget = SplitModeAware.SplitModeTarget.BOTH
@@ -485,6 +474,9 @@ intellijPlatform {
* <a href="https://youtrack.jetbrains.com/issue/VIM-566">VIM-566</a> Added support for <code>zf</code> command - create fold from selection or motion<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-566">VIM-566</a> Added support for <code>:set foldlevel</code> option - control fold visibility level<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-1158">VIM-1158</a> Added <code>gw</code> command - reformat code like <code>gq</code> but preserving the cursor position<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-3975">VIM-3975</a> Added <code>mode()</code> VimScript function - returns the current editing mode (e.g., <code>'n'</code> for normal, <code>'i'</code> for insert, <code>'v'</code> for visual, <code>'R'</code> for replace)<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-519">VIM-519</a> Added <code>g;</code> and <code>g,</code> commands - navigate the change list to jump to previous (<code>g;</code>) or next (<code>g,</code>) edit location<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-258">VIM-258</a> Added command name completion in ex commands - press <code>&lt;Tab&gt;</code> to cycle through matching command names (e.g., <code>:e&lt;Tab&gt;</code> shows <code>:edit</code>, <code>:earlier</code>, etc.)<br>
<br>
<b>Fixes:</b><br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4197">VIM-4197</a> Fixed Vim features (e.g., <code>f</code>, <code>w</code>, text objects) not working in Java files decompiled from Kotlin class files<br>
@@ -527,8 +519,29 @@ intellijPlatform {
* <a href="https://youtrack.jetbrains.com/issue/VIM-4202">VIM-4202</a> Fixed <code>&lt;S-Tab&gt;</code> being intercepted by IdeaVim - users can now remap <code>&lt;S-Tab&gt;</code> to other IntelliJ actions<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4202">VIM-4202</a> Fixed <code>gcc</code>/<code>gc{motion}</code> commentary leaving editor in incorrect mode in Rider/CLion split mode<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4115">VIM-4115</a> Fixed NullPointerException in <code>CommandKeyConsumer</code> when pressing Esc after disabling and re-enabling IdeaVim with an open command line<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4209">VIM-4209</a> Fixed <code>&lt;Esc&gt;</code> not exiting insert mode in Rider/CLion when a <code>&lt;C-Space&gt;</code> completion popup intercepts the key before IdeaVim<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4211">VIM-4211</a> Fixed IdeaVim not working in the Git commit window<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4202">VIM-4202</a> Fixed <code>gcc</code>/<code>gc{motion}</code> commentary not adding space after <code>//</code> prefix in C/C++/C# files in Rider/CLion split mode<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4219">VIM-4219</a> Fixed NullPointerException when IdeaVim is being disabled/unloaded<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4221">VIM-4221</a> Fixed error sound being played on each keypress when <code>incsearch</code> is enabled and the typed pattern is an invalid regex<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4196">VIM-4196</a> Fixed NERDTree file selection not being restored after pressing <code>&lt;Esc&gt;</code> to cancel a <code>/</code> speed search<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4211">VIM-4211</a> Fixed IdeaVim not working in Git commit window when the Conventional Commits plugin is installed<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4224">VIM-4224</a> Fixed <code>:s</code> <code>e</code> flag now properly suppresses "Pattern not found" errors - e.g., <code>%s/\s\+$//e</code> no longer errors when there is no trailing whitespace<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4226">VIM-4226</a> Fixed race condition crash when the editor is disposed while the ex panel is open<br>
<br>
<b>Merged PRs:</b><br>
* <a href="https://github.com/JetBrains/ideavim/pull/1747">1747</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: feat(VIM-519): cycle through recent edits with g; and g,<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1745">1745</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: feat(VIM-258): tab command completion<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1744">1744</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: fix(VIM-4226): check if editor is disposed on focus<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1741">1741</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: fix(VIM-4224): respect e flag in search patterns<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1740">1740</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: feat(VIM-3975): support vim mode() function<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1739">1739</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: fix(VIM-4196): restore file selection after esc in nerdtree<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1738">1738</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: fix(VIM-4211): commit window work with conectional commits plugin<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1730">1730</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: FIX(VIM-4221) Don't make angry sounds on search<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1728">1728</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: VIM-4202 Add space after c langauges comments<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1727">1727</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: FIX(VIM-4219) check for in VimPLugin is not null<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1720">1720</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: fix: make ideavim work in commit window<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1717">1717</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: Fix(VIM-4209): handle esc in rider before popup<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1704">1704</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: VIM-4202 Don't intercept all &lt;S-Tab&gt;<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1703">1703</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: VIM-4202 Fix state after commentary action<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1700">1700</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: VIM-4139 Compute nesting depth for fold regions<br>

View File

@@ -618,6 +618,34 @@ https://github.com/kana/vim-textobj-entire/blob/master/doc/textobj-entire.txt
</details>
<details>
<summary><h2>VimEverywhere: Keyboard-driven IDE navigation outside the editor</h2></summary>
### Summary:
Brings vim-style keyboard navigation to the rest of the IDE. Enabling `VimEverywhere` turns on three
behaviors:
- **Hints overlay.** Press `Ctrl+Shift+\` (`Ctrl+Cmd+\` on macOS) to display hint labels over
interactive UI components — buttons, tool window tabs, tree nodes, text fields, scroll panes, and
so on. Type the letters next to a target to focus or click it without touching the mouse.
- **NERDTree-style mappings everywhere.** NERDTree file-opening mappings (`o`, `t`, `T`, `s`, `i`,
`go`, `gs`, `gi`) work in any focused tree, not just the Project tool window.
- **Tool window navigation.** Vim-style window-motion keys work inside tool windows, so you can move
between split panes without leaving the keyboard.
### Setup:
- Install the [AceJump](https://plugins.jetbrains.com/plugin/7086-acejump/) plugin.
- Add the following command to `~/.ideavimrc`: `set VimEverywhere`
### Instructions
Press `Ctrl+Shift+\` (`Ctrl+Cmd+\` on macOS) to toggle the hints overlay. Type the letters shown
next to a target to activate it, or press `Esc` to dismiss the overlay without activating anything.
NERDTree-style and window-nav mappings are active automatically whenever the corresponding
component has focus.
</details>
<details>
<summary><h2>Which-Key: Displays available keybindings in popup</h2></summary>

Binary file not shown.

2
gradlew vendored
View File

@@ -57,7 +57,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.

31
gradlew.bat vendored Executable file → Normal file
View File

@@ -23,8 +23,8 @@
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@@ -51,7 +51,7 @@ echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
@@ -65,7 +65,7 @@ echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
@@ -73,21 +73,10 @@ goto fail
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%

View File

@@ -0,0 +1,89 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package com.maddyhome.idea.vim.group.changelist
import com.intellij.openapi.editor.Document
import com.intellij.openapi.editor.EditorFactory
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.impl.IdeDocumentHistoryImpl.PlaceInfo
import com.intellij.openapi.fileEditor.impl.IdeDocumentHistoryImpl.RecentPlacesListener
import com.intellij.openapi.project.Project
import com.intellij.platform.rpc.topics.broadcast
/**
* Bridges IntelliJ's `RecentPlacesListener` into [CHANGE_LIST_REMOTE_TOPIC].
*
* `PlaceInfo.caretPosition` carries the *post-command* caret (one past `iX<Esc>`,
* end of `rA`, etc.) but Vim's `g;` targets where the edit *began*, so we capture
* `event.offset` from a `DocumentListener` and prefer it when available.
*
* Line/col are computed here on the backend (where the document lives) and sent
* pre-resolved over the topic; the frontend then has no VirtualFile lookup to
* race with editor loading in split mode.
*
* `recentPlaceRemoved` is intentionally NOT mirrored: IntelliJ's `putLastOrMerge`
* fires "remove A, add B" across different lines (`canBeMergedWith(NAVIGATION)`),
* which is far more aggressive than Vim's same-line/textwidth merge rule. The
* frontend service does its own merging and capping, so platform eviction is moot.
*/
internal class ChangeListPlacesListener(private val project: Project) : RecentPlacesListener {
private data class Pending(val document: Document, val offset: Int)
private val pendingByPath = mutableMapOf<String, Pending>()
init {
EditorFactory.getInstance().eventMulticaster.addDocumentListener(
object : DocumentListener {
override fun documentChanged(event: DocumentEvent) {
val file = FileDocumentManager.getInstance().getFile(event.document) ?: return
pendingByPath[file.path] = Pending(event.document, event.offset)
}
},
project,
)
}
@Suppress("OVERRIDE_DEPRECATION")
override fun recentPlaceAdded(changePlace: PlaceInfo, isChanged: Boolean) {
if (!isChanged) return
val file = changePlace.file
val path = file.path
val pending = pendingByPath.remove(path)
val (document, offset) = pending?.let { it.document to it.offset }
?: run {
val doc = FileDocumentManager.getInstance().getDocument(file) ?: return
val off = changePlace.caretPosition?.startOffset ?: return
doc to off
}
val safeOffset = offset.coerceIn(0, document.textLength)
val line = document.getLineNumber(safeOffset)
val col = safeOffset - document.getLineStartOffset(line)
CHANGE_LIST_REMOTE_TOPIC.broadcast(
project,
ChangeListInfo(
line = line,
col = col,
filepath = path,
protocol = file.fileSystem.protocol,
timestamp = System.currentTimeMillis(),
),
)
}
@Suppress("OVERRIDE_DEPRECATION")
override fun recentPlaceRemoved(changePlace: PlaceInfo, isChanged: Boolean) {
// Intentionally empty -- see class kdoc.
}
}

View File

@@ -8,6 +8,7 @@
package com.maddyhome.idea.vim.group.comment
import com.intellij.application.options.CodeStyle
import com.intellij.codeInsight.actions.MultiCaretCodeInsightActionHandler
import com.intellij.codeInsight.generation.CommentByBlockCommentHandler
import com.intellij.codeInsight.generation.CommentByLineCommentHandler
@@ -55,22 +56,41 @@ internal class CommentaryRemoteApiImpl : CommentaryRemoteApi {
val project = editor.project ?: return
val psiFile = PsiDocumentManager.getInstance(project).getPsiFile(editor.document) ?: return
CommandProcessor.getInstance().executeCommand(project, {
ApplicationManager.getApplication().runWriteAction {
val caret = editor.caretModel.primaryCaret
caret.setSelection(startOffset, endOffset)
try {
val handler = pickHandler(psiFile, lineWise)
handler.invoke(project, editor, caret, psiFile)
handler.postInvoke()
} finally {
caret.removeSelection()
if (caretOffset >= 0) {
caret.moveToOffset(caretOffset)
val invokeHandler = {
CommandProcessor.getInstance().executeCommand(project, {
ApplicationManager.getApplication().runWriteAction {
val caret = editor.caretModel.primaryCaret
caret.setSelection(startOffset, endOffset)
try {
val handler = pickHandler(psiFile, lineWise)
handler.invoke(project, editor, caret, psiFile)
handler.postInvoke()
} finally {
caret.removeSelection()
if (caretOffset >= 0) {
caret.moveToOffset(caretOffset)
}
}
}
}, "Commentary", null)
}
// normally comment action goes through rider backend comment action running on .net nto jvm so we cannot call it directly.
// But we still want to apply space after comment as it's default bahavior there so we overrite this flag for intelij comment handler
if (isCFamily(psiFile)) {
val baseSettings = CodeStyle.getSettings(psiFile)
CodeStyle.runWithLocalSettings(project, baseSettings) { localSettings ->
localSettings.getCommonSettings(psiFile.language).LINE_COMMENT_ADD_SPACE = true
invokeHandler()
}
}, "Commentary", null)
} else {
invokeHandler()
}
}
private fun isCFamily(psiFile: PsiFile): Boolean {
val fileTypeName = psiFile.fileType.name
return fileTypeName == "C++" || fileTypeName == "C#" || fileTypeName == "ObjectiveC"
}
private fun pickHandler(psiFile: PsiFile, lineWise: Boolean): MultiCaretCodeInsightActionHandler {

View File

@@ -19,6 +19,8 @@
<projectListeners>
<listener class="com.maddyhome.idea.vim.group.jump.JumpsListener"
topic="com.intellij.openapi.fileEditor.impl.IdeDocumentHistoryImpl$RecentPlacesListener"/>
<listener class="com.maddyhome.idea.vim.group.changelist.ChangeListPlacesListener"
topic="com.intellij.openapi.fileEditor.impl.IdeDocumentHistoryImpl$RecentPlacesListener"/>
</projectListeners>
<extensions defaultExtensionNs="com.intellij">

View File

@@ -10,12 +10,6 @@
<dependencies>
<plugin id="org.jetbrains.plugins.clion.radler"/>
</dependencies>
<extensions defaultExtensionNs="com.intellij">
<editorActionHandler action="EditorEscape"
implementationClass="com.maddyhome.idea.vim.handler.VimEscForRiderHandler"
id="ideavim-clion-nova-esc"
order="first, before idea.only.escape"/>
</extensions>
<extensions defaultExtensionNs="IdeaVIM">
<clionNovaProvider implementation="com.maddyhome.idea.vim.ide.ClionNovaProviderImpl"/>
</extensions>

View File

@@ -0,0 +1,29 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package com.maddyhome.idea.vim.group.changelist
import com.intellij.platform.rpc.topics.ProjectRemoteTopic
import kotlinx.serialization.Serializable
/**
* Backend-to-frontend change-list event. Line/col are pre-computed on the
* backend so the frontend doesn't need a VirtualFile lookup -- which can race
* with editor loading in split mode (mirrors the `JumpInfo` pattern).
*/
@Serializable
data class ChangeListInfo(
val line: Int,
val col: Int,
val filepath: String,
val protocol: String,
val timestamp: Long,
)
val CHANGE_LIST_REMOTE_TOPIC: ProjectRemoteTopic<ChangeListInfo> =
ProjectRemoteTopic("ideavim.changelist", ChangeListInfo.serializer())

View File

@@ -36,10 +36,6 @@
topic="com.intellij.ide.ui.LafManagerListener"/>
<listener class="com.maddyhome.idea.vim.extension.highlightedyank.HighlightColorResetter"
topic="com.intellij.ide.ui.LafManagerListener"/>
<listener class="com.maddyhome.idea.vim.handler.IdeaVimKeymapChangedListener"
topic="com.intellij.openapi.keymap.KeymapManagerListener"/>
<listener class="com.maddyhome.idea.vim.handler.IdeaVimCorrectorKeymapChangedListener"
topic="com.intellij.openapi.keymap.KeymapManagerListener"/>
<listener class="com.maddyhome.idea.vim.listener.VimAppActivationListener"
topic="com.intellij.openapi.application.ApplicationActivationListener"/>
</applicationListeners>
@@ -177,6 +173,12 @@
<platform.rpc.projectRemoteTopicListener
implementation="com.maddyhome.idea.vim.group.JumpRemoteTopicListener"/>
<!-- Frontend change-list service (g; / g,) + topic listener that mirrors
backend RecentPlacesListener events into it. -->
<applicationService serviceImplementation="com.maddyhome.idea.vim.group.changelist.ChangeListService"/>
<platform.rpc.projectRemoteTopicListener
implementation="com.maddyhome.idea.vim.group.changelist.ChangeListRemoteTopicListener"/>
<applicationService serviceImplementation="com.maddyhome.idea.vim.group.IjFileGroup"
serviceInterface="com.maddyhome.idea.vim.api.VimFile"/>
@@ -225,11 +227,6 @@
implementation="com.maddyhome.idea.vim.ui.widgets.macro.MacroWidgetFactory"
order="first, after IdeaVimShowCmd"/>
<!-- Editor-specific startup activities -->
<postStartupActivity implementation="com.maddyhome.idea.vim.handler.EditorHandlersChainLogger"/>
<postStartupActivity implementation="com.maddyhome.idea.vim.handler.KeymapChecker"/>
<postStartupActivity implementation="com.maddyhome.idea.vim.handler.CopilotKeymapCorrector"/>
<editorFloatingToolbarProvider implementation="com.maddyhome.idea.vim.ui.ReloadFloatingToolbar"/>
<actionPromoter implementation="com.maddyhome.idea.vim.key.VimActionsPromoter" order="last"/>
@@ -249,35 +246,6 @@
<statistics.applicationUsagesCollector implementation="com.maddyhome.idea.vim.statistic.WidgetState"/>
<statistics.counterUsagesCollector implementationClass="com.maddyhome.idea.vim.statistic.ActionTracker"/>
<!-- Editor action handlers -->
<!-- Do not care about red handlers in order. They are necessary for proper ordering, and they'll be resolved when needed -->
<editorActionHandler action="EditorEnter" implementationClass="com.maddyhome.idea.vim.handler.VimEnterHandler"
id="ideavim-enter"
order="before editorEnter, before inline.completion.enter, before rd.client.editor.enter, after smart-step-into-enter, after AceHandlerEnter, after jupyterCommandModeEnterKeyHandler, after swift.placeholder.enter"/>
<editorActionHandler action="EditorEnter"
implementationClass="com.maddyhome.idea.vim.handler.CaretShapeEnterEditorHandler"
id="ideavim-enter-shape"
order="before jupyterCommandModeEnterKeyHandler"/>
<!-- "first" is not defined for this handler as it leads to "unsatisfied ordering exception". Not sure exectly why, but it appears in tests-->
<editorActionHandler action="EditorEscape" implementationClass="com.maddyhome.idea.vim.handler.VimEscHandler"
id="ideavim-esc"
order="after smart-step-into-escape, after AceHandlerEscape, before jupyterCommandModeEscKeyHandler, before templateEscape, before backend.escape"/>
<editorActionHandler action="EditorEscape" implementationClass="com.maddyhome.idea.vim.handler.VimEscLoggerHandler"
id="ideavim-esc-logger"
order="first"/>
<editorActionHandler action="EditorEnter" implementationClass="com.maddyhome.idea.vim.handler.VimEnterLoggerHandler"
id="ideavim-enter-logger"
order="first"/>
<editorActionHandler action="EditorStartNewLine"
implementationClass="com.maddyhome.idea.vim.handler.StartNewLineDetector"
id="ideavim-start-new-line-detector"
order="first"/>
<editorActionHandler action="EditorStartNewLineBefore"
implementationClass="com.maddyhome.idea.vim.handler.StartNewLineBeforeCurrentDetector"
id="ideavim-start-new-line-before-current-detector"
order="first"/>
<editorFactoryDocumentListener
implementation="com.maddyhome.idea.vim.listener.VimListenerManager$VimDocumentListener"/>
@@ -464,7 +432,8 @@
<actions>
<action class="com.maddyhome.idea.vim.extension.hints.ToggleHintsAction" text="Toggle Hints">
<keyboard-shortcut keymap="$default" first-keystroke="ctrl BACK_SLASH"/>
<keyboard-shortcut keymap="$default" first-keystroke="ctrl shift BACK_SLASH"/>
<keyboard-shortcut keymap="Mac OS X 10.5+" first-keystroke="ctrl meta BACK_SLASH"/>
</action>
</actions>
</idea-plugin>

View File

@@ -14,12 +14,6 @@
<listener class="com.maddyhome.idea.vim.listener.RiderActionListener"
topic="com.intellij.openapi.actionSystem.ex.AnActionListener"/>
</projectListeners>
<extensions defaultExtensionNs="com.intellij">
<editorActionHandler action="EditorEscape"
implementationClass="com.maddyhome.idea.vim.handler.VimEscForRiderHandler"
id="ideavim-rider-esc"
order="first, before idea.only.escape"/>
</extensions>
<extensions defaultExtensionNs="IdeaVIM">
<riderProvider implementation="com.maddyhome.idea.vim.ide.RiderProviderImpl"/>
</extensions>

View File

@@ -45,6 +45,7 @@ const knownPlugins = new Set([
"com.github.pooryam92.vimcoach", // https://plugins.jetbrains.com/plugin/30148-vim-coach
"lazyideavim.whichkeylazy", // https://plugins.jetbrains.com/plugin/30446-which-key-lazy
"com.github.vimkeysuggest", // https://plugins.jetbrains.com/plugin/30486-vimkeysuggest
"dev.ckob.lazygit", // https://plugins.jetbrains.com/plugin/30919-lazygit
]);
async function getPluginLinkByXmlId(xmlId: string): Promise<string | null> {

View File

@@ -1,31 +0,0 @@
/**
* Simple test script to verify TeamCity can run TypeScript scripts.
* Run with: npx tsx src/teamcityTest.ts
*/
console.log("=== TeamCity TypeScript Test Script ===");
console.log(`Node version: ${process.version}`);
console.log(`Platform: ${process.platform}`);
console.log(`Current directory: ${process.cwd()}`);
console.log(`Script arguments: ${process.argv.slice(2).join(", ") || "(none)"}`);
// Test that we can import modules
import * as fs from "fs";
import * as path from "path";
const packageJsonPath = path.join(process.cwd(), "package.json");
if (fs.existsSync(packageJsonPath)) {
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
console.log(`Package name: ${pkg.name}`);
console.log(`Package version: ${pkg.version}`);
}
// Demonstrate TeamCity service messages (for build status reporting)
// See: https://www.jetbrains.com/help/teamcity/service-messages.html
console.log("");
console.log("##teamcity[message text='TypeScript script executed successfully' status='NORMAL']");
// Exit with success
console.log("");
console.log("✓ Test completed successfully!");
process.exit(0);

View File

@@ -21,7 +21,7 @@ repositories {
}
dependencies {
compileOnly("org.jetbrains.kotlin:kotlin-stdlib:2.3.10")
compileOnly("org.jetbrains.kotlin:kotlin-stdlib:2.3.21")
testImplementation("org.junit.jupiter:junit-jupiter:6.0.3")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")

View File

@@ -183,7 +183,8 @@ public class VimPlugin implements PersistentStateComponent<Element>, Disposable
}
public static boolean isEnabled() {
return getInstance().enabled;
final VimPlugin instance = ApplicationManager.getApplication().getService(VimPlugin.class);
return instance != null && instance.enabled;
}
public static void setEnabled(final boolean enabled) {

View File

@@ -32,8 +32,6 @@ import com.maddyhome.idea.vim.api.globalOptions
import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.group.IjOptionConstants
import com.maddyhome.idea.vim.group.IjOptions
import com.maddyhome.idea.vim.handler.enableOctopus
import com.maddyhome.idea.vim.handler.isOctopusEnabled
import com.maddyhome.idea.vim.helper.EditorHelper
import com.maddyhome.idea.vim.helper.HandlerInjector
import com.maddyhome.idea.vim.helper.inNormalMode
@@ -92,8 +90,8 @@ class VimShortcutKeyAction : AnAction(), DumbAware/*, LightEditCompatible*/ {
// Control-flow exceptions (like ProcessCanceledException) should never be logged and should be rethrown
// See {@link com.intellij.openapi.diagnostic.Logger.checkException}
throw e
} catch (throwable: Throwable) {
LOG.error(throwable)
} catch (e: Exception) {
LOG.error(e)
}
}
}
@@ -119,15 +117,6 @@ class VimShortcutKeyAction : AnAction(), DumbAware/*, LightEditCompatible*/ {
if (VimPlugin.isNotEnabled()) return ActionEnableStatus.no("IdeaVim is disabled", LogLevel.DEBUG)
val editor = getEditor(e) ?: return ActionEnableStatus.no("Can't get Editor", LogLevel.DEBUG)
if (enableOctopus) {
if (isOctopusEnabled(keyStroke, editor)) {
return ActionEnableStatus.no(
"Processing VimShortcutKeyAction for the key that is used in the octopus handler",
LogLevel.ERROR
)
}
}
if (e.dataContext.isNotSupportedContextComponent && Registry.`is`("ideavim.only.in.editor.component")) {
// Note: Currently, IdeaVim works ONLY in the editor & ExTextField component. However, the presence of the
// PlatformDataKeys.EDITOR in the data context does not mean that the current focused component is editor.

View File

@@ -12,6 +12,7 @@ import com.intellij.openapi.options.advanced.AdvancedSettings
import com.intellij.util.ui.tree.TreeUtil
import com.maddyhome.idea.vim.VimPlugin
import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.newapi.vim
import com.maddyhome.idea.vim.vimscript.model.datatypes.VimString
import javax.swing.KeyStroke
import javax.swing.tree.TreeNode
@@ -135,6 +136,9 @@ val navigationMappings: Map<List<KeyStroke>, NerdTreeAction> = mutableMapOf<List
register("NERDTreeMapJumpNextSibling", "<C-J>", NerdTreeAction.swing("selectNextSibling"))
register("NERDTreeMapJumpPrevSibling", "<C-K>", NerdTreeAction.swing("selectPreviousSibling"))
register("/", NerdTreeAction.ij("SpeedSearch"))
register("/", NerdTreeAction { event, tree ->
armSelectionRestoreOnEscape(tree)
NerdTreeAction.callAction(null, "SpeedSearch", event.dataContext.vim)
})
register("<ESC>", NerdTreeAction { _, _ -> })
}

View File

@@ -0,0 +1,63 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package com.maddyhome.idea.vim.extension.nerdtree
import com.intellij.ui.treeStructure.Tree
import java.awt.event.FocusAdapter
import java.awt.event.FocusEvent
import java.awt.event.KeyAdapter
import java.awt.event.KeyEvent
import javax.swing.SwingUtilities
/**
* Captures the current tree selection and arranges to restore it if the
* upcoming SpeedSearch session is dismissed via ESC, so the cursor returns
* to the file the user was on before pressing `/`.
*
* No-op if no row is currently selected. Any non-ESC key (e.g. ENTER) cancels
* the restoration so committing the search keeps the matched item selected.
*/
internal fun armSelectionRestoreOnEscape(tree: Tree) {
val originalPath = tree.selectionPath ?: return
lateinit var keyListener: KeyAdapter
lateinit var focusListener: FocusAdapter
val disarm = {
tree.removeKeyListener(keyListener)
tree.removeFocusListener(focusListener)
}
keyListener = object : KeyAdapter() {
override fun keyPressed(e: KeyEvent) {
when (e.keyCode) {
KeyEvent.VK_ESCAPE -> {
disarm()
// Defer until SpeedSearch finishes processing the ESC and clearing
// its own state, so our restored selection is the one that sticks.
SwingUtilities.invokeLater {
tree.selectionPath = originalPath
tree.scrollPathToVisible(originalPath)
}
}
KeyEvent.VK_ENTER -> disarm()
}
}
}
// If focus leaves the tree before ESC/ENTER (mouse click elsewhere, popup
// dismissed by tool window switch), drop both listeners so we don't leak
// or surprise the user with a delayed jump on a later ESC.
focusListener = object : FocusAdapter() {
override fun focusLost(e: FocusEvent) = disarm()
}
tree.addKeyListener(keyListener)
tree.addFocusListener(focusListener)
}

View File

@@ -14,11 +14,9 @@ import com.intellij.openapi.command.CommandProcessor
import com.intellij.openapi.command.UndoConfirmationPolicy
import com.intellij.openapi.diagnostic.logger
import com.intellij.openapi.editor.actionSystem.TypedActionHandler
import com.intellij.openapi.editor.actions.EnterAction
import com.intellij.openapi.editor.event.EditorMouseEvent
import com.intellij.openapi.editor.event.EditorMouseListener
import com.intellij.openapi.editor.impl.editorId
import com.intellij.openapi.util.UserDataHolder
import com.intellij.psi.codeStyle.CodeStyleManager
import com.intellij.psi.util.PsiUtilBase
import com.maddyhome.idea.vim.EventFacade
@@ -31,7 +29,6 @@ import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.common.TextRange
import com.maddyhome.idea.vim.group.change.ChangeRemoteApi
import com.maddyhome.idea.vim.group.format.FormatRemoteApi
import com.maddyhome.idea.vim.handler.commandContinuation
import com.maddyhome.idea.vim.helper.CodeWrapper
import com.maddyhome.idea.vim.helper.CommentLeaderParser
import com.maddyhome.idea.vim.helper.inInsertMode
@@ -99,35 +96,6 @@ class ChangeGroup : VimChangeGroupBase() {
injector.scroll.scrollCaretIntoView(vimEditor)
}
/**
* If this is REPLACE mode we need to turn off OVERWRITE before and then turn OVERWRITE back on after sending the
* "ENTER" key.
*/
override fun processEnter(
editor: VimEditor,
caret: VimCaret,
context: ExecutionContext,
) {
if (editor.mode is Mode.REPLACE) {
editor.insertMode = true
}
try {
val continuation = (context.context as UserDataHolder).getUserData(commandContinuation)
val ijEditor = editor.ij
val ij = context.ij
val ijCaret = caret.ij
if (continuation != null) {
continuation.execute(ijEditor, ijCaret, ij)
} else {
EnterAction().handler.execute(ijEditor, ijCaret, ij)
}
} finally {
if (editor.mode is Mode.REPLACE) {
editor.insertMode = false
}
}
}
override fun processBackspace(editor: VimEditor, context: ExecutionContext) {
injector.actionExecutor.executeAction(editor, name = IdeActions.ACTION_EDITOR_BACKSPACE, context = context)
injector.scroll.scrollCaretIntoView(editor)

View File

@@ -326,11 +326,7 @@ public class KeyGroup extends VimKeyGroupBase implements PersistentStateComponen
private void registerRequiredShortcut(@NotNull List<KeyStroke> keys, MappingOwner owner) {
for (KeyStroke key : keys) {
if (key.getKeyChar() == KeyEvent.CHAR_UNDEFINED) {
if (!injector.getApplication().isOctopusEnabled() ||
!(key.getKeyCode() == KeyEvent.VK_ESCAPE && key.getModifiers() == 0) &&
!(key.getKeyCode() == KeyEvent.VK_ENTER && key.getModifiers() == 0)) {
getRequiredShortcutKeys().add(new RequiredShortcut(key, owner));
}
getRequiredShortcutKeys().add(new RequiredShortcut(key, owner));
}
}
}

View File

@@ -8,6 +8,7 @@
package com.maddyhome.idea.vim.group
import com.intellij.openapi.actionSystem.DataContext
import com.intellij.openapi.components.service
import com.intellij.openapi.editor.Caret
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.VisualPosition
@@ -15,7 +16,9 @@ import com.intellij.openapi.fileEditor.FileEditorManagerEvent
import com.intellij.openapi.fileEditor.TextEditor
import com.intellij.openapi.fileEditor.ex.FileEditorManagerEx
import com.intellij.openapi.fileEditor.impl.EditorWindow
import com.intellij.platform.project.projectId
import com.maddyhome.idea.vim.KeyHandler
import com.maddyhome.idea.vim.api.BufferPosition
import com.maddyhome.idea.vim.api.ExecutionContext
import com.maddyhome.idea.vim.api.ImmutableVimCaret
import com.maddyhome.idea.vim.api.VimChangeGroupBase
@@ -26,12 +29,14 @@ import com.maddyhome.idea.vim.api.getLeadingCharacterOffset
import com.maddyhome.idea.vim.api.getVisualLineCount
import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.api.lineLength
import com.maddyhome.idea.vim.api.normalizeOffset
import com.maddyhome.idea.vim.api.normalizeVisualLine
import com.maddyhome.idea.vim.api.visualLineToBufferLine
import com.maddyhome.idea.vim.command.Argument
import com.maddyhome.idea.vim.command.MotionType
import com.maddyhome.idea.vim.command.OperatorArguments
import com.maddyhome.idea.vim.common.TextRange
import com.maddyhome.idea.vim.group.changelist.ChangeListService
import com.maddyhome.idea.vim.handler.ExternalActionHandler
import com.maddyhome.idea.vim.handler.Motion
import com.maddyhome.idea.vim.handler.Motion.AbsoluteOffset
@@ -57,6 +62,39 @@ import kotlin.math.min
*/
class MotionGroup : VimMotionGroupBase() {
override fun moveCaretToChange(
editor: VimEditor,
caret: ImmutableVimCaret,
count: Int,
): Motion {
val project = editor.ij.project ?: return Motion.Error
val result = service<ChangeListService>().goToChange(project.projectId().serializeToString(), count)
return when (result) {
ChangeListService.MoveResult.Empty -> reportChangeListError(editor, "E664")
ChangeListService.MoveResult.AtStart -> reportChangeListError(editor, "E662")
ChangeListService.MoveResult.AtEnd -> reportChangeListError(editor, "E663")
is ChangeListService.MoveResult.At -> motionToChange(editor, result.change)
}
}
private fun reportChangeListError(editor: VimEditor, code: String): Motion {
injector.messages.showErrorMessage(editor, injector.messages.message(code))
return Motion.Error
}
private fun motionToChange(editor: VimEditor, change: ChangeListService.Change): Motion {
val target = BufferPosition(change.line, change.col, false)
if (editor.getPath() == change.filepath) {
return AbsoluteOffset(editor.bufferPositionToOffset(target))
}
injector.file.selectEditor(editor.projectId, change.filepath, change.protocol)?.let { newEditor ->
val offset = newEditor.bufferPositionToOffset(target)
newEditor.currentCaret().moveToOffset(newEditor.normalizeOffset(offset, false))
}
return Motion.Error
}
override fun moveCaretToFirstDisplayLine(
editor: VimEditor,
caret: ImmutableVimCaret,

View File

@@ -33,7 +33,6 @@ import com.intellij.openapi.util.SystemInfo
import com.maddyhome.idea.vim.VimPlugin
import com.maddyhome.idea.vim.api.VimEditor
import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.handler.KeyMapIssue
import com.maddyhome.idea.vim.helper.MessageHelper
import com.maddyhome.idea.vim.icons.VimIcons
import com.maddyhome.idea.vim.key.ShortcutOwner
@@ -161,78 +160,6 @@ internal class NotificationService(private val project: Project?) : VimNotificat
ActionIdNotifier.notifyActionId(id, project, candidates, intentionName)
}
override fun notifyKeymapIssues(issues: ArrayList<KeyMapIssue>) {
val keymapManager = KeymapManagerEx.getInstanceEx()
val keymap = keymapManager.activeKeymap
val message = buildString {
appendLine("Current IDE keymap (${keymap.name}) has issues:<br/>")
issues.forEach {
when (it) {
is KeyMapIssue.AddShortcut -> {
appendLine("- ${it.key} key is not assigned to the ${it.action} action.<br/>")
}
is KeyMapIssue.RemoveShortcut -> {
appendLine("- ${it.shortcut} key is incorrectly assigned to the ${it.action} action.<br/>")
}
}
}
}
val notification = IDEAVIM_STICKY_GROUP.createNotification(
IDEAVIM_NOTIFICATION_TITLE,
message,
NotificationType.ERROR,
)
notification.subtitle = "IDE keymap misconfigured"
notification.addAction(object : DumbAwareAction("Fix Keymap") {
override fun actionPerformed(e: AnActionEvent) {
issues.forEach {
when (it) {
is KeyMapIssue.AddShortcut -> {
keymap.addShortcut(it.actionId, KeyboardShortcut(it.keyStroke, null))
}
is KeyMapIssue.RemoveShortcut -> {
keymap.removeShortcut(it.actionId, it.shortcut)
}
}
}
LOG.info("Shortcuts updated $issues")
notification.expire()
requiredShortcutsAssigned()
}
})
notification.addAction(object : DumbAwareAction("Open Keymap Settings") {
override fun actionPerformed(e: AnActionEvent) {
ShowSettingsUtil.getInstance().showSettingsDialog(e.project, KeymapPanel::class.java)
notification.hideBalloon()
}
})
notification.addAction(object : DumbAwareAction("Ignore") {
override fun actionPerformed(e: AnActionEvent) {
LOG.info("Ignored to update shortcuts $issues")
notification.hideBalloon()
}
})
notification.notify(project)
}
private fun requiredShortcutsAssigned() {
val notification = Notification(
IDEAVIM_NOTIFICATION_ID,
IDEAVIM_NOTIFICATION_TITLE,
"Keymap fixed",
NotificationType.INFORMATION,
)
notification.addAction(object : DumbAwareAction("Open Keymap Settings") {
override fun actionPerformed(e: AnActionEvent) {
ShowSettingsUtil.getInstance().showSettingsDialog(e.project, KeymapPanel::class.java)
notification.hideBalloon()
}
})
notification.notify(project)
}
object ActionIdNotifier {
private var notification: Notification? = null

View File

@@ -10,7 +10,6 @@ package com.maddyhome.idea.vim.group
import com.intellij.openapi.project.Project
import com.maddyhome.idea.vim.api.VimEditor
import com.maddyhome.idea.vim.handler.KeyMapIssue
import javax.swing.KeyStroke
/**
@@ -32,5 +31,4 @@ interface VimNotifications {
fun notifyEapFinished()
fun showReenableNotification(project: Project)
fun notifyActionId(id: String?, candidates: List<String>? = null, intentionName: String?)
fun notifyKeymapIssues(issues: ArrayList<KeyMapIssue>)
}

View File

@@ -0,0 +1,26 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package com.maddyhome.idea.vim.group.changelist
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import com.intellij.platform.project.projectId
import com.intellij.platform.rpc.topics.ProjectRemoteTopic
import com.intellij.platform.rpc.topics.ProjectRemoteTopicListener
internal class ChangeListRemoteTopicListener : ProjectRemoteTopicListener<ChangeListInfo> {
override val topic: ProjectRemoteTopic<ChangeListInfo> = CHANGE_LIST_REMOTE_TOPIC
override fun handleEvent(project: Project, event: ChangeListInfo) {
service<ChangeListService>().addChange(
project.projectId().serializeToString(),
ChangeListService.Change(event.line, event.col, event.filepath, event.protocol),
)
}
}

View File

@@ -0,0 +1,85 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package com.maddyhome.idea.vim.group.changelist
import com.intellij.openapi.components.Service
import org.jetbrains.annotations.TestOnly
/**
* Per-project change list backing `g;` and `g,` (`:help changelist`).
*
* Index/merge semantics follow Neovim's `get_changelist` (`src/nvim/mark.c`)
* and `changed_common` (`src/nvim/change.c`): after each recorded change the
* index sits past the end, so the first `g;` lands on the newest entry.
*/
@Service(Service.Level.APP)
internal class ChangeListService {
private val projectToChanges = mutableMapOf<String, MutableList<Change>>()
private val projectToIndex = mutableMapOf<String, Int>()
data class Change(
val line: Int,
val col: Int,
val filepath: String,
val protocol: String,
)
sealed interface MoveResult {
object Empty : MoveResult
object AtStart : MoveResult
object AtEnd : MoveResult
data class At(val change: Change) : MoveResult
}
@Synchronized
fun addChange(projectId: String, change: Change) {
val list = projectToChanges.getOrPut(projectId) { mutableListOf() }
if (list.lastOrNull()?.shouldMergeWith(change) == true) {
list[list.lastIndex] = change
} else {
list.add(change)
if (list.size > CHANGE_LIST_LIMIT) list.removeAt(0)
}
projectToIndex[projectId] = list.size
}
@Synchronized
fun goToChange(projectId: String, count: Int): MoveResult {
val list = projectToChanges[projectId]
if (list.isNullOrEmpty()) return MoveResult.Empty
val current = projectToIndex.getOrPut(projectId) { list.size }
val target = current + count
if (target < 0 && current == 0) return MoveResult.AtStart
if (target >= list.size && current == list.size - 1) return MoveResult.AtEnd
val newIndex = target.coerceIn(0, list.size - 1)
projectToIndex[projectId] = newIndex
return MoveResult.At(list[newIndex])
}
private fun Change.shouldMergeWith(next: Change): Boolean =
filepath == next.filepath &&
line == next.line &&
kotlin.math.abs(col - next.col) < TEXTWIDTH_FALLBACK
@TestOnly
@Synchronized
fun reset() {
projectToChanges.clear()
projectToIndex.clear()
}
companion object {
private const val CHANGE_LIST_LIMIT = 100
private const val TEXTWIDTH_FALLBACK = 79
}
}

View File

@@ -1,116 +0,0 @@
/*
* Copyright 2003-2023 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package com.maddyhome.idea.vim.handler
import com.intellij.openapi.actionSystem.KeyboardShortcut
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.diagnostic.logger
import com.intellij.openapi.keymap.Keymap
import com.intellij.openapi.keymap.KeymapManagerListener
import com.intellij.openapi.keymap.ex.KeymapManagerEx
import com.intellij.openapi.project.Project
import com.intellij.openapi.startup.ProjectActivity
import com.maddyhome.idea.vim.VimPlugin
import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.api.key
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch
import org.jetbrains.annotations.NonNls
import java.util.concurrent.ConcurrentHashMap
// We use alarm with delay to avoid many actions in case many events are fired at the same time
internal val correctorRequester = MutableSharedFlow<Unit>(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
private val LOG = logger<CopilotKeymapCorrector>()
internal class CopilotKeymapCorrector : ProjectActivity {
override suspend fun execute(project: Project) {
project.service<CopilotKeymapCorrectorService>().start()
correctorRequester.emit(Unit)
}
}
/**
* At the moment of release 2023.3 there is a problem that starting a coroutine like this
* right in the project activity will block this project activity in tests.
* To avoid that, there is an intermediate service that will allow to avoid this issue.
*
* However, in general we should start this coroutine right in the [CopilotKeymapCorrector]
*/
@OptIn(FlowPreview::class)
@Service(Service.Level.PROJECT)
internal class CopilotKeymapCorrectorService(private val cs: CoroutineScope) {
fun start() {
cs.launch {
correctorRequester
.debounce(5_000)
.collectLatest { correctCopilotKeymap() }
}
}
}
internal class IdeaVimCorrectorKeymapChangedListener : KeymapManagerListener {
override fun activeKeymapChanged(keymap: Keymap?) {
check(correctorRequester.tryEmit(Unit))
}
override fun shortcutsChanged(keymap: Keymap, actionIds: @NonNls Collection<String>, fromSettings: Boolean) {
check(correctorRequester.tryEmit(Unit))
}
}
private val copilotHideActionMap = ConcurrentHashMap<String, Unit>()
/**
* See VIM-3206
* The user expected to both copilot suggestion and the insert mode to be exited on a single esc.
* However, for the moment, the first esc hides copilot suggestion and the second one exits insert mode.
* To fix this, we remove the esc shortcut from the copilot action if the IdeaVim is active.
*
* This workaround is not the best solution, however, I don't see the better way with the current architecture of
* actions and EditorHandlers. Firstly, I wanted to suggest to copilot to migrate to EditorActionHandler as well,
* but this doesn't seem correct for me because in this case the user will lose an ability to change the shorcut for
* it. It seems like copilot has a similar problem as we do - we don't want to make a handler for "Editor enter action",
* but a handler for the esc key press. And, moreover, be able to communicate with other plugins about the ordering.
* Before this feature is implemented, hiding the copilot suggestion on esc looks like a good workaround.
*/
private fun correctCopilotKeymap() {
// This is needed to initialize the injector in case this verification is called to fast
VimPlugin.getInstance()
if (!enableOctopus) return
if (injector.enabler.isEnabled()) {
val keymap = KeymapManagerEx.getInstanceEx().activeKeymap
val res = keymap.getShortcuts("copilot.disposeInlays")
if (res.isEmpty()) return
val escapeShortcut = res.find { it.toString() == "[pressed ESCAPE]" } ?: return
keymap.removeShortcut("copilot.disposeInlays", escapeShortcut)
copilotHideActionMap[keymap.name] = Unit
LOG.info("Remove copilot escape shortcut from keymap ${keymap.name}")
} else {
copilotHideActionMap.forEach { (name, _) ->
val keymap = KeymapManagerEx.getInstanceEx().getKeymap(name) ?: return@forEach
val currentShortcuts = keymap.getShortcuts("copilot.disposeInlays")
if ("[pressed ESCAPE]" !in currentShortcuts.map { it.toString() }) {
keymap.addShortcut("copilot.disposeInlays", KeyboardShortcut(key("<esc>"), null))
}
LOG.info("Restore copilot escape shortcut in keymap ${keymap.name}")
}
}
}

View File

@@ -1,72 +0,0 @@
/*
* Copyright 2003-2023 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package com.maddyhome.idea.vim.handler
import com.intellij.openapi.actionSystem.IdeActions
import com.intellij.openapi.diagnostic.logger
import com.intellij.openapi.editor.actionSystem.EditorActionHandlerBean
import com.intellij.openapi.extensions.ExtensionPointName
import com.intellij.openapi.keymap.ex.KeymapManagerEx
import com.intellij.openapi.project.Project
import com.intellij.openapi.startup.ProjectActivity
import com.maddyhome.idea.vim.api.key
import com.maddyhome.idea.vim.newapi.initInjector
/**
* Logs the chain of handlers for esc and enter
*
* As we made a migration to the new way of handling esc keys (VIM-2974), we may face several issues around that
* One of the possible issues is that some plugin may also register a shortcut for this key and do not pass
* the control to the next handler. In this way, the esc won't work, but there will be no exceptions.
*
* This is a logger that logs the chain of handlers.
*
* Strictly speaking, such access to the extension point is not allowed by the platform. But we can't do this thing
* otherwise, so let's use it as long as we can.
*/
internal class EditorHandlersChainLogger : ProjectActivity {
@Suppress("UnresolvedPluginConfigReference")
private val editorHandlers = ExtensionPointName<EditorActionHandlerBean>("com.intellij.editorActionHandler")
override suspend fun execute(project: Project) {
initInjector()
if (!enableOctopus) return
val escHandlers = editorHandlers.extensionList
.filter { it.action == "EditorEscape" }
.joinToString("\n") { it.implementationClass }
val enterHandlers = editorHandlers.extensionList
.filter { it.action == "EditorEnter" }
.joinToString("\n") { it.implementationClass }
LOG.info("Esc handlers chain:\n$escHandlers")
LOG.info("Enter handlers chain:\n$enterHandlers")
val keymapManager = KeymapManagerEx.getInstanceEx()
val keymap = keymapManager.activeKeymap
val keymapShortcutsForEsc = keymap.getShortcuts(IdeActions.ACTION_EDITOR_ESCAPE).joinToString()
val keymapShortcutsForEnter = keymap.getShortcuts(IdeActions.ACTION_EDITOR_ENTER).joinToString()
LOG.info("Active keymap (${keymap.name}) shortcuts for esc: $keymapShortcutsForEsc, Shortcuts for enter: $keymapShortcutsForEnter")
val actionsForEsc = keymap.getActionIds(key("<esc>")).joinToString("\n")
val actionsForEnter = keymap.getActionIds(key("<enter>")).joinToString("\n")
LOG.info(
"Also keymap (${keymap.name}) has " +
"the following actions assigned to esc:\n$actionsForEsc " +
"\nand following actions assigned to enter:\n$actionsForEnter"
)
}
companion object {
val LOG = logger<EditorHandlersChainLogger>()
}
}

View File

@@ -1,153 +0,0 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package com.maddyhome.idea.vim.handler
import com.intellij.openapi.actionSystem.IdeActions
import com.intellij.openapi.actionSystem.KeyboardShortcut
import com.intellij.openapi.actionSystem.Shortcut
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.keymap.Keymap
import com.intellij.openapi.keymap.KeymapManagerListener
import com.intellij.openapi.keymap.ex.KeymapManagerEx
import com.intellij.openapi.project.Project
import com.intellij.openapi.startup.ProjectActivity
import com.maddyhome.idea.vim.VimPlugin
import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.api.key
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch
import org.jetbrains.annotations.NonNls
import javax.swing.KeyStroke
// We use alarm with delay to avoid many notifications in case many events are fired at the same time
internal val keyCheckRequests = MutableSharedFlow<Unit>(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
/**
* This checker verifies that the keymap has a correct configuration that is required for IdeaVim plugin
*/
internal class KeymapChecker : ProjectActivity {
override suspend fun execute(project: Project) {
project.service<KeymapCheckerService>().start()
keyCheckRequests.emit(Unit)
}
}
/**
* At the moment of release 2023.3 there is a problem that starting a coroutine like this
* right in the project activity will block this project activity in tests.
* To avoid that, there is an intermediate service that will allow to avoid this issue.
*
* However, in general we should start this coroutine right in the [KeymapChecker]
*/
@OptIn(FlowPreview::class)
@Service(Service.Level.PROJECT)
internal class KeymapCheckerService(private val cs: CoroutineScope) {
fun start() {
cs.launch {
keyCheckRequests
.debounce(5_000)
.collectLatest { verifyKeymap() }
}
}
}
internal class IdeaVimKeymapChangedListener : KeymapManagerListener {
override fun activeKeymapChanged(keymap: Keymap?) {
check(keyCheckRequests.tryEmit(Unit))
}
override fun shortcutsChanged(keymap: Keymap, actionIds: @NonNls Collection<String>, fromSettings: Boolean) {
check(keyCheckRequests.tryEmit(Unit))
}
}
/**
* After migration to the editor action handlers, we have to make sure that the keymap has a correct configuration.
* For example, that esc key is assigned to esc editor action
*
* Usually this is not a problem because this is a standard mapping, but the problem may appear in a misconfiguration
* like it was in VIM-3204
*/
private fun verifyKeymap() {
// This is needed to initialize the injector in case this verification is called to fast
VimPlugin.getInstance()
if (!enableOctopus) return
if (!injector.enabler.isEnabled()) return
val keymap = KeymapManagerEx.getInstanceEx().activeKeymap
val keymapShortcutsForEsc = keymap.getShortcuts(IdeActions.ACTION_EDITOR_ESCAPE)
val keymapShortcutsForEnter = keymap.getShortcuts(IdeActions.ACTION_EDITOR_ENTER)
val issues = ArrayList<KeyMapIssue>()
val correctShortcutMissing = keymapShortcutsForEsc
.filterIsInstance<KeyboardShortcut>()
.none { it.firstKeyStroke.toString() == "pressed ESCAPE" && it.secondKeyStroke == null }
// We also check if there are any shortcuts starting from esc and with a second key. This should also be removed.
// For example, VIM-3162 has a case when two escapes were assigned to editor escape action
val shortcutsStartingFromEsc = keymapShortcutsForEsc
.filterIsInstance<KeyboardShortcut>()
.filter { it.firstKeyStroke.toString() == "pressed ESCAPE" && it.secondKeyStroke != null }
if (correctShortcutMissing) {
issues += KeyMapIssue.AddShortcut(
"esc",
"editor escape",
IdeActions.ACTION_EDITOR_ESCAPE,
key("<esc>")
)
}
shortcutsStartingFromEsc.forEach {
issues += KeyMapIssue.RemoveShortcut("editor escape", IdeActions.ACTION_EDITOR_ESCAPE, it)
}
val correctEnterShortcutMissing = keymapShortcutsForEnter
.filterIsInstance<KeyboardShortcut>()
.none { it.firstKeyStroke.toString() == "pressed ENTER" && it.secondKeyStroke == null }
val shortcutsStartingFromEnter = keymapShortcutsForEnter
.filterIsInstance<KeyboardShortcut>()
.filter { it.firstKeyStroke.toString() == "pressed ENTER" && it.secondKeyStroke != null }
if (correctEnterShortcutMissing) {
issues += KeyMapIssue.AddShortcut(
"enter",
"editor enter",
IdeActions.ACTION_EDITOR_ENTER,
key("<enter>")
)
}
shortcutsStartingFromEnter.forEach {
issues += KeyMapIssue.RemoveShortcut("editor enter", IdeActions.ACTION_EDITOR_ENTER, it)
}
if (issues.isNotEmpty()) {
VimPlugin.getNotifications(null).notifyKeymapIssues(issues)
}
}
sealed interface KeyMapIssue {
data class AddShortcut(
val key: String,
val action: String,
val actionId: String,
val keyStroke: KeyStroke,
) : KeyMapIssue
data class RemoveShortcut(
val action: String,
val actionId: String,
val shortcut: Shortcut,
) : KeyMapIssue
}

View File

@@ -1,374 +0,0 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package com.maddyhome.idea.vim.handler
import com.intellij.codeInsight.editorActions.AutoHardWrapHandler
import com.intellij.codeInsight.lookup.LookupManager
import com.intellij.formatting.LineWrappingUtil
import com.intellij.ide.DataManager
import com.intellij.openapi.actionSystem.DataContext
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.application.invokeLater
import com.intellij.openapi.diagnostic.logger
import com.intellij.openapi.editor.Caret
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.actionSystem.EditorActionHandler
import com.intellij.openapi.editor.actions.SplitLineAction
import com.intellij.openapi.editor.impl.CaretModelImpl
import com.intellij.openapi.util.Key
import com.intellij.openapi.util.UserDataHolder
import com.intellij.openapi.util.removeUserData
import com.maddyhome.idea.vim.KeyHandler
import com.maddyhome.idea.vim.VimPlugin
import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.api.key
import com.maddyhome.idea.vim.group.IjOptionConstants
import com.maddyhome.idea.vim.helper.EditorHelper
import com.maddyhome.idea.vim.helper.IjActionExecutor
import com.maddyhome.idea.vim.helper.inNormalMode
import com.maddyhome.idea.vim.helper.isIdeaVimDisabledHere
import com.maddyhome.idea.vim.helper.isPrimaryEditor
import com.maddyhome.idea.vim.helper.updateCaretsVisualAttributes
import com.maddyhome.idea.vim.newapi.actionStartedFromVim
import com.maddyhome.idea.vim.newapi.globalIjOptions
import com.maddyhome.idea.vim.newapi.vim
import com.maddyhome.idea.vim.state.mode.Mode
import java.awt.event.KeyEvent
import javax.swing.KeyStroke
internal val commandContinuation = Key.create<EditorActionHandler>("commandContinuation")
/**
* Handler that corrects the shape of the caret in python notebooks.
*
* By default, py notebooks show a thin caret after entering the cell.
* However, we're in normal mode, so this handler fixes it.
*/
internal class CaretShapeEnterEditorHandler(private val nextHandler: EditorActionHandler) : EditorActionHandler() {
override fun doExecute(editor: Editor, caret: Caret?, dataContext: DataContext?) {
if (VimPlugin.isEnabled() && !editor.isIdeaVimDisabledHere && enableOctopus) {
invokeLater {
editor.updateCaretsVisualAttributes()
}
}
nextHandler.execute(editor, caret, dataContext)
}
override fun isEnabledForCaret(editor: Editor, caret: Caret, dataContext: DataContext?): Boolean {
return nextHandler.isEnabled(editor, caret, dataContext)
}
}
/**
* This handler doesn't work in tests for ex commands
*
* About this handler: VIM-2974
*/
internal abstract class OctopusHandler(private val nextHandler: EditorActionHandler?) : EditorActionHandler() {
abstract fun executeHandler(editor: Editor, caret: Caret?, dataContext: DataContext?)
open fun isHandlerEnabled(editor: Editor, dataContext: DataContext?): Boolean {
return true
}
final override fun doExecute(editor: Editor, caret: Caret?, dataContext: DataContext?) {
if (isThisHandlerEnabled(editor, caret, dataContext)) {
val executeInInvokeLater = executeInInvokeLater(editor)
val executionHandler = {
try {
(dataContext as? UserDataHolder)?.putUserData(commandContinuation, nextHandler)
executeHandler(editor, caret, dataContext)
} finally {
(dataContext as? UserDataHolder)?.removeUserData(commandContinuation)
}
}
if (executeInInvokeLater) {
// This `invokeLater` is used to escape the potential `runForEachCaret` function.
//
// The `runForEachCaret` function is disallowed to be called recursively. However, with this new handler, we lose
// control if we execute the code inside this function or not. See IDEA-300030 for details.
// This means the code in IdeaVim MUST NOT call `runForEachCaret` function. While this is possible for most cases,
// the user may make a mapping to some intellij action where the `runForEachCaret` is called. This breaks
// the condition (see VIM-3103 for example).
// Since we can't make sure we don't execute `runForEachCaret`, we have to "escape" out of this function. This is
// done by scheduling the execution of our code later via the invokeLater function.
//
// We run this job only once for a primary caret. In the handler itself, we'll multiply the execution by the
// number of carets. If we run this job for each caret, we may end up in the issue like VIM-3186.
// However, I think that we may do some refactoring to run this job for each caret (if needed).
//
// For the moment, the known case when the caret is null - work in injected editor - VIM-3195
if (caret == null || caret == editor.caretModel.primaryCaret) {
ApplicationManager.getApplication().invokeLater(executionHandler)
}
} else {
executionHandler()
}
} else {
nextHandler?.execute(editor, caret, dataContext)
}
}
private fun executeInInvokeLater(editor: Editor): Boolean {
// Currently we have a workaround for the PY console VIM-3157
if (EditorHelper.isPythonConsole(editor)) return false
return (editor.caretModel as? CaretModelImpl)?.isIteratingOverCarets ?: true
}
private fun isThisHandlerEnabled(editor: Editor, caret: Caret?, dataContext: DataContext?): Boolean {
if (VimPlugin.isNotEnabled()) return false
if (editor.isIdeaVimDisabledHere) return false
if (!isHandlerEnabled(editor, dataContext)) return false
if (isNotActualKeyPress(dataContext)) return false
if (!enableOctopus) return false
return true
}
/**
* In some cases IJ runs handlers to imitate "enter" or other key. In such cases we should not process it on the
* IdeaVim side because the user may have mappings on enter the we'll get an unexpected behaviour.
* This method should return true if we detect that this handler is called in such case and this is not an
* actual keypress from the user.
*/
private fun isNotActualKeyPress(dataContext: DataContext?): Boolean {
if (dataContext != null) {
// This flag is set when the enter handlers are executed as a part of moving the comment on the new line
val dataManager = DataManager.getInstance()
if (dataManager.loadFromDataContext(dataContext, AutoHardWrapHandler.AUTO_WRAP_LINE_IN_PROGRESS_KEY) == true) {
return true
}
// From VIM-3177
val wrapLongLineDuringFormattingInProgress = dataManager
.loadFromDataContext(dataContext, LineWrappingUtil.WRAP_LONG_LINE_DURING_FORMATTING_IN_PROGRESS_KEY)
if (wrapLongLineDuringFormattingInProgress == true) {
return true
}
// From VIM-3203
val splitLineInProgress = dataManager.loadFromDataContext(dataContext, SplitLineAction.SPLIT_LINE_KEY)
if (splitLineInProgress == true) {
return true
}
if (dataManager.loadFromDataContext(dataContext, StartNewLineDetectorBase.Util.key) == true) {
return true
}
}
if (dataContext?.actionStartedFromVim == true) return true
if ((injector.actionExecutor as? IjActionExecutor)?.isRunningActionFromVim == true) return true
return false
}
final override fun isEnabledForCaret(editor: Editor, caret: Caret, dataContext: DataContext?): Boolean {
return isThisHandlerEnabled(editor, caret, dataContext)
|| nextHandler?.isEnabled(editor, caret, dataContext) == true
}
}
/**
* Known conflicts & solutions:
* - Smart step into - set handler after
* - Python notebooks - set handler after
* - Ace jump - set handler after
* - Lookup - doesn't intersect with enter anymore
* - App code - set handler after
* - Template - doesn't intersect with enter anymore
* - rd.client.editor.enter - set handler before. Otherwise, rider will add new line on enter even in normal mode
* - inline.completion.enter - set handler before. Otherwise, AI completion is not invoked on enter.
*
* This rule is disabled due to VIM-3124
* - before terminalEnter - not necessary, but terminalEnter causes "file is read-only" tooltip for readonly files VIM-3122
* - `first` is set to satisfy sorting condition "before terminalEnter".
*
*
* DO NOT add handlers that force to add "first" ordering. This doesn't work with jupyterCommandModeEnterKeyHandler (see VIM-3124)
*/
internal class VimEnterHandler(nextHandler: EditorActionHandler?) : VimKeyHandler(nextHandler) {
override val key: String = "<CR>"
override fun isHandlerEnabled(editor: Editor, dataContext: DataContext?): Boolean {
if (!super.isHandlerEnabled(editor, dataContext)) return false
// This is important for one-line editors, to turn off enter.
// Some one-line editors rely on the fact that there are no enter actions registered. For example, hash search in git
// See VIM-2974 for example where it was broken
return !editor.isOneLineMode
}
}
/**
* Known conflicts & solutions:
*
* - Smart step into - set handler after
* - Python notebooks - set handler before - yes, we have `<CR>` as "after" and `<esc>` as before. I'm not completely sure
* why this combination is correct, but other versions don't work.
* - Ace jump - set handler after
* - Lookup - It disappears after putting our esc before templateEscape. But I'm not sure why it works like that
* - App code - Need to review
* - Template - Need to review
* - before backend.escape - to handle our handlers before Rider processing. Also, without this rule, we get problems like VIM-3146
*/
internal class VimEscHandler(nextHandler: EditorActionHandler) : VimKeyHandler(nextHandler) {
override val key: String = "<Esc>"
private val ideaVimSupportDialog
get() = injector.globalIjOptions().ideavimsupport.contains(IjOptionConstants.ideavimsupport_dialog)
override fun isHandlerEnabled(editor: Editor, dataContext: DataContext?): Boolean {
return editor.isPrimaryEditor() ||
EditorHelper.isFileEditor(editor) && vimStateNeedsToHandleEscape(editor) ||
ideaVimSupportDialog && vimStateNeedsToHandleEscape(editor)
}
private fun vimStateNeedsToHandleEscape(editor: Editor): Boolean {
return !editor.vim.mode.inNormalMode || KeyHandler.getInstance().keyHandlerState.mappingState.hasKeys
}
}
/**
* Rider (and CLion Nova) uses a separate handler for esc to close the completion. IdeaOnlyEscapeHandlerAction is especially
* designed to get all the esc presses, and if there is a completion close it and do not pass the execution further.
* This doesn't work the same as in IJ.
* In IdeaVim, we'd like to exit insert mode on closing completion. This is a requirement as the change of this
* behaviour causes a lot of complaining from users. Since the rider handler gets execution control, we don't
* receive an event and don't exit the insert mode.
* To fix it, this special handler exists only for rider and stands before the rider's handler. We don't execute the
* handler from rider because the autocompletion is closed automatically anyway.
*
* NOTE: This handler only works when octopus is enabled (non-Rider IDEs). For Rider, where octopus is disabled
* (VIM-3815) and Escape is consumed by the popup manager before the EditorEscape chain fires, the fix is in
* [com.maddyhome.idea.vim.listener.IdeaSpecifics.LookupTopicListener] via a LookupListener.
*/
internal class VimEscForRiderHandler(nextHandler: EditorActionHandler) : VimKeyHandler(nextHandler) {
override val key: String = "<Esc>"
override fun isHandlerEnabled(editor: Editor, dataContext: DataContext?): Boolean {
if (!enableOctopus) return false
return LookupManager.getActiveLookup(editor) != null
}
}
/**
* Empty logger for esc presses
*
* As we made a migration to the new way of handling esc keys (VIM-2974), we may face several issues around that
* One of the possible issues is that some plugin may also register a shortcut for this key and do not pass
* the control to the next handler. In this way, the esc won't work, but there will be no exceptions.
* This handler, that should stand in front of handlers change, just logs the event of pressing the key
* and passes the execution.
*/
internal class VimEscLoggerHandler(private val nextHandler: EditorActionHandler) : EditorActionHandler() {
override fun doExecute(editor: Editor, caret: Caret?, dataContext: DataContext?) {
if (enableOctopus) {
LOG.info("Esc pressed")
}
nextHandler.execute(editor, caret, dataContext)
}
override fun isEnabledForCaret(editor: Editor, caret: Caret, dataContext: DataContext?): Boolean {
return nextHandler.isEnabled(editor, caret, dataContext)
}
companion object {
val LOG = logger<VimEscLoggerHandler>()
}
}
/**
* Workaround to support "Start New Line" action in normal mode.
* IJ executes enter handler on "Start New Line". This causes an issue that IdeaVim thinks that this is just an enter key.
* This thing should be refactored, but for now we'll use this workaround VIM-3159
*
* The Same thing happens with "Start New Line Before Current" action.
*/
internal class StartNewLineDetector(nextHandler: EditorActionHandler) : StartNewLineDetectorBase(nextHandler)
internal class StartNewLineBeforeCurrentDetector(nextHandler: EditorActionHandler) :
StartNewLineDetectorBase(nextHandler)
internal open class StartNewLineDetectorBase(private val nextHandler: EditorActionHandler) : EditorActionHandler() {
override fun doExecute(editor: Editor, caret: Caret?, dataContext: DataContext?) {
if (enableOctopus) {
DataManager.getInstance().saveInDataContext(dataContext, Util.key, true)
}
nextHandler.execute(editor, caret, dataContext)
}
override fun isEnabledForCaret(editor: Editor, caret: Caret, dataContext: DataContext?): Boolean {
return nextHandler.isEnabled(editor, caret, dataContext)
}
object Util {
val key = Key.create<Boolean>("vim.is.start.new.line")
}
companion object {
val LOG = logger<VimEscLoggerHandler>()
}
}
/**
* Empty logger for enter presses
*
* As we made a migration to the new way of handling enter keys (VIM-2974), we may face several issues around that
* One of the possible issues is that some plugin may also register a shortcut for this key and do not pass
* the control to the next handler. In this way, the esc won't work, but there will be no exceptions.
* This handler, that should stand in front of handlers change, just logs the event of pressing the key
* and passes the execution.
*/
internal class VimEnterLoggerHandler(private val nextHandler: EditorActionHandler) : EditorActionHandler() {
override fun doExecute(editor: Editor, caret: Caret?, dataContext: DataContext?) {
if (enableOctopus) {
LOG.info("Enter pressed")
}
nextHandler.execute(editor, caret, dataContext)
}
override fun isEnabledForCaret(editor: Editor, caret: Caret, dataContext: DataContext?): Boolean {
return nextHandler.isEnabled(editor, caret, dataContext)
}
companion object {
val LOG = logger<VimEnterLoggerHandler>()
}
}
internal abstract class VimKeyHandler(nextHandler: EditorActionHandler?) : OctopusHandler(nextHandler) {
abstract val key: String
override fun executeHandler(editor: Editor, caret: Caret?, dataContext: DataContext?) {
val enterKey = key(key)
val context = dataContext?.vim ?: injector.executionContextManager.getEditorExecutionContext(editor.vim)
val keyHandler = KeyHandler.getInstance()
keyHandler.handleKey(editor.vim, enterKey, context, keyHandler.keyHandlerState)
}
override fun isHandlerEnabled(editor: Editor, dataContext: DataContext?): Boolean {
val enterKey = key(key)
return isOctopusEnabled(enterKey, editor)
}
}
fun isOctopusEnabled(s: KeyStroke, editor: Editor): Boolean {
if (!enableOctopus) return false
// CMD line has a different processing mechanizm: the processing actions are registered
// for the input field component. These keys are not dispatched via the octopus handler.
if (editor.vim.mode is Mode.CMD_LINE) return false
when {
s.keyCode == KeyEvent.VK_ENTER && s.modifiers == 0 -> return true
s.keyCode == KeyEvent.VK_ESCAPE && s.modifiers == 0 -> return true
}
return false
}
internal val enableOctopus: Boolean
get() = injector.application.isOctopusEnabled()

View File

@@ -172,6 +172,7 @@ class CaretVisualAttributesListener : IsReplaceCharListener, ModeChangeListener,
@RequiresEdt
private fun updateCaretsVisual(editor: VimEditor) {
val ijEditor = (editor as IjVimEditor).editor
if (ijEditor.isDisposed) return
ijEditor.updateCaretsVisualAttributes()
ijEditor.updateCaretsVisualPosition()
}

View File

@@ -687,19 +687,20 @@ public class EditorHelper {
* Checks if the editor is the Python console, so we can disable Vim features
*/
public static boolean isPythonConsole(@NotNull Editor editor) {
if (editor.getVirtualFile() == null) return false;
var file = EditorHelper.getVirtualFile(editor);
if (file == null) return false;
// In split mode, the projected VirtualFile may have a different getName() result,
// so we also check getPath() to reliably detect the Python console.
return editor.getVirtualFile().getName().contains(PYTHON_CONSOLE_FILE_NAME)
|| editor.getVirtualFile().getPath().contains(PYTHON_CONSOLE_FILE_NAME);
return file.getName().contains(PYTHON_CONSOLE_FILE_NAME) || file.getPath().contains(PYTHON_CONSOLE_FILE_NAME);
}
/**
* Checks if the editor is hosted in the Commit tool window, so we can enable Vim features
*/
public static boolean isCommitWindowEditor(@NotNull Editor editor) {
// The best heuristic we have is the file name, which is Dummy.txt
var file = editor.getVirtualFile();
@SuppressWarnings("deprecation") Key<?> dataKey = Key.findKeyByName("Vcs.CommitMessage.Panel");
if (dataKey != null && editor.getDocument().getUserData(dataKey) != null) return true;
var file = EditorHelper.getVirtualFile(editor);
return file != null && file.getName().contains("Dummy.txt");
}
@@ -721,8 +722,8 @@ public class EditorHelper {
*/
public static boolean isKotlinClassDecompiledToJavaFile(@NotNull Editor editor) {
@SuppressWarnings("deprecation") @Nullable Key<?> key = Key.findKeyByName("IS_KOTLIN_DECOMPILED_FILE");
var file = editor.getVirtualFile();
return file != null && key != null && editor.getVirtualFile().getUserData(key) == Boolean.TRUE;
var file = EditorHelper.getVirtualFile(editor);
return file != null && key != null && file.getUserData(key) == Boolean.TRUE;
}
/**

View File

@@ -64,6 +64,7 @@ import com.maddyhome.idea.vim.vimscript.model.options.helpers.IdeaRefactorModeHe
import com.maddyhome.idea.vim.vimscript.model.options.helpers.isIdeaRefactorModeKeep
import com.maddyhome.idea.vim.vimscript.model.options.helpers.isIdeaRefactorModeSelect
import org.jetbrains.annotations.NonNls
import java.awt.AWTEvent
import java.awt.event.KeyEvent
import javax.swing.KeyStroke
@@ -374,12 +375,11 @@ internal object IdeaSpecifics {
(VimPlugin.getKey() as VimKeyGroupBase).registerShortcutsForLookup(newLookup)
// In Rider/CLion Nova, octopus is disabled (VIM-3815) and Escape is consumed by the popup manager
// (due to LookupSummaryInfo popup) before the action system runs, so IdeaVim never sees it.
// In Rider/CLion Nova, the popup manager (due to LookupSummaryInfo parameter info popup)
// consumes Escape before the action system runs, so IdeaVim never sees it.
// Listen for explicit lookup cancellation (Escape) to exit insert mode.
// Note: we check isRider/isClionNova specifically, not !isOctopusEnabled(), because
// JetBrains Client (split mode) also has octopus disabled but doesn't need this workaround,
// and isCanceledExplicitly can be true for non-Escape keys (e.g. space) in that environment.
// Note: this listener must NOT be attached in JetBrains Client (split mode), because
// isCanceledExplicitly can be true for non-Escape keys (e.g. space) there.
if (isRider() || isClionNova()) {
newLookup.addLookupListener(RiderEscLookupListener(newLookup.editor))
}
@@ -396,13 +396,37 @@ internal object IdeaSpecifics {
}
/**
* In Rider/CLion Nova, octopus is disabled (VIM-3815) and Escape is consumed by the popup manager
* (due to LookupSummaryInfo parameter info popup) before the action system runs, so IdeaVim never sees it.
* Tracks whether the last KEY_PRESSED was Escape. Needed because [LookupEvent.isCanceledExplicitly]
* is also true for non-Esc keys in Rider/CLion Nova (e.g. space), so it can't be used on its own
* to decide whether to exit insert mode. Wired up as an IdeEventQueue preprocessor in
* [VimListenerManager.GlobalListeners.enable].
*/
internal object RiderEscAwtKeyTracker {
private val LOG = com.intellij.openapi.diagnostic.Logger.getInstance(RiderEscAwtKeyTracker::class.java)
@Volatile
var lastKeyPressedWasEscape: Boolean = false
private set
fun onAwtEvent(event: AWTEvent) {
if (event is KeyEvent && event.id == KeyEvent.KEY_PRESSED) {
val isEsc = event.keyCode == KeyEvent.VK_ESCAPE
lastKeyPressedWasEscape = isEsc
if (LOG.isTraceEnabled) {
LOG.trace("RiderEscAwtKeyTracker KEY_PRESSED keyCode=${event.keyCode} isEsc=$isEsc")
}
}
}
}
/**
* In Rider/CLion Nova, the popup manager (due to LookupSummaryInfo parameter info popup)
* consumes Escape before the action system runs, so IdeaVim never sees it.
* This listener exits insert mode when the lookup is explicitly cancelled (Escape).
*/
private class RiderEscLookupListener(private val editor: Editor) : com.intellij.codeInsight.lookup.LookupListener {
override fun lookupCanceled(event: com.intellij.codeInsight.lookup.LookupEvent) {
if (event.isCanceledExplicitly && editor.vim.mode is Mode.INSERT) {
if (RiderEscAwtKeyTracker.lastKeyPressedWasEscape && editor.vim.mode is Mode.INSERT) {
editor.vim.exitInsertMode(injector.executionContextManager.getEditorExecutionContext(editor.vim))
KeyHandler.getInstance().reset(editor.vim)
}

View File

@@ -92,8 +92,6 @@ import com.maddyhome.idea.vim.group.ScrollOptionsChangeListener
import com.maddyhome.idea.vim.group.visual.IdeaSelectionControl
import com.maddyhome.idea.vim.group.visual.VimVisualTimer
import com.maddyhome.idea.vim.group.visual.moveCaretOneCharLeftFromSelectionEnd
import com.maddyhome.idea.vim.handler.correctorRequester
import com.maddyhome.idea.vim.handler.keyCheckRequests
import com.maddyhome.idea.vim.helper.CaretVisualAttributesListener
import com.maddyhome.idea.vim.helper.GuicursorChangeListener
import com.maddyhome.idea.vim.helper.StrictMode
@@ -170,8 +168,6 @@ object VimListenerManager {
SlowOperations.knownIssue("VIM-3648, VIM-3649").use {
EditorListeners.addAll()
}
check(correctorRequester.tryEmit(Unit))
check(keyCheckRequests.tryEmit(Unit))
val caretVisualAttributesListener = CaretVisualAttributesListener()
injector.listenersNotifier.myEditorListeners.add(caretVisualAttributesListener)
@@ -204,8 +200,6 @@ object VimListenerManager {
GlobalListeners.disable()
EditorListeners.removeAll()
injector.listenersNotifier.reset()
check(correctorRequester.tryEmit(Unit))
}
object GlobalListeners {
@@ -242,6 +236,18 @@ object VimListenerManager {
busConnection.subscribe(FileOpenedSyncListener.TOPIC, VimEditorFactoryListener)
busConnection.subscribe(VirtualFileManager.VFS_CHANGES, BufNewFileTracker)
busConnection.subscribe(FileDocumentManagerListener.TOPIC, BufWriteListener)
// VIM-4205: feed Esc presses to RiderEscAwtKeyTracker. Must be a preprocessor (not a dispatcher)
// so it fires before Rider's popup manager consumes the event.
if (com.maddyhome.idea.vim.ide.isRider() || com.maddyhome.idea.vim.ide.isClionNova()) {
com.intellij.ide.IdeEventQueue.getInstance().addPreprocessor(
{ event ->
IdeaSpecifics.RiderEscAwtKeyTracker.onAwtEvent(event)
false
},
VimPlugin.getInstance().onOffDisposable,
)
}
}
fun disable() {
@@ -381,11 +387,13 @@ object VimListenerManager {
*/
private object VimFocusListener : FocusChangeListener {
override fun focusGained(editor: Editor) {
if (editor.isDisposed) return
if (vimDisabled(editor)) return
injector.listenersNotifier.notifyEditorFocusGained(editor.vim)
}
override fun focusLost(editor: Editor) {
if (editor.isDisposed) return
if (vimDisabled(editor)) return
injector.listenersNotifier.notifyEditorFocusLost(editor.vim)
}

View File

@@ -12,12 +12,9 @@ import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.application.ModalityState
import com.intellij.openapi.util.Computable
import com.intellij.util.ExceptionUtil
import com.intellij.util.PlatformUtils
import com.maddyhome.idea.vim.api.VimApplicationBase
import com.maddyhome.idea.vim.api.VimEditor
import com.maddyhome.idea.vim.diagnostic.vimLogger
import com.maddyhome.idea.vim.ide.isClionNova
import com.maddyhome.idea.vim.ide.isRider
import java.awt.Component
import java.awt.Toolkit
import java.awt.Window
@@ -79,14 +76,6 @@ internal class IjVimApplication : VimApplicationBase() {
com.maddyhome.idea.vim.helper.runAfterGotFocus(runnable)
}
override fun isOctopusEnabled(): Boolean {
// Turn off octopus for some IDEs. They have issues with ENTER and ESC on the octopus like VIM-3815
if (isRider() || PlatformUtils.isJetBrainsClient() || isClionNova()) return false
val property = System.getProperty("octopus.handler") ?: "true"
if (property.isBlank()) return true
return property.toBoolean()
}
private fun createKeyEvent(stroke: KeyStroke, component: Component): KeyEvent {
return KeyEvent(
component,

View File

@@ -8,68 +8,14 @@
package org.jetbrains.plugins.ideavim.action.change.insert
import com.intellij.ide.plugins.PluginManagerCore
import com.intellij.openapi.actionSystem.DataContext
import com.intellij.openapi.editor.Caret
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.actionSystem.EditorActionHandler
import com.intellij.openapi.editor.actionSystem.EditorActionHandlerBean
import com.intellij.openapi.extensions.ExtensionPointName
import com.intellij.testFramework.ExtensionTestUtil
import com.maddyhome.idea.vim.VimPlugin
import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.state.mode.Mode
import org.jetbrains.plugins.ideavim.SkipNeovimReason
import org.jetbrains.plugins.ideavim.TestWithoutNeovim
import org.jetbrains.plugins.ideavim.VimTestCase
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.RepeatedTest
import org.junit.jupiter.api.RepetitionInfo
import org.junit.jupiter.api.Test
class InsertEnterActionTest : VimTestCase() {
@BeforeEach
fun setUp(repetitionInfo: RepetitionInfo) {
// Set up a different combination of handlers for enter action
// There is a specific that due to IDEA-300030 the existing for "forEach" handler may affect our handlers execution.
val mainBean = EditorActionHandlerBean()
mainBean.implementationClass = "com.maddyhome.idea.vim.handler.VimEnterHandler"
mainBean.action = "EditorEnter"
mainBean.setPluginDescriptor(PluginManagerCore.getPlugin(VimPlugin.getPluginId())!!)
val singleBean = EditorActionHandlerBean()
singleBean.implementationClass = DestroyerHandlerSingle::class.java.name
singleBean.action = "EditorEnter"
singleBean.setPluginDescriptor(PluginManagerCore.getPlugin(VimPlugin.getPluginId())!!)
val forEachBean = EditorActionHandlerBean()
forEachBean.implementationClass = DestroyerHandlerForEach::class.java.name
forEachBean.action = "EditorEnter"
forEachBean.setPluginDescriptor(PluginManagerCore.getPlugin(VimPlugin.getPluginId())!!)
if (injector.application.isOctopusEnabled()) {
if (repetitionInfo.currentRepetition == 1) {
ExtensionTestUtil.maskExtensions(
ExtensionPointName("com.intellij.editorActionHandler"),
listOf(mainBean),
fixture.testRootDisposable
)
} else if (repetitionInfo.currentRepetition == 2) {
ExtensionTestUtil.maskExtensions(
ExtensionPointName("com.intellij.editorActionHandler"),
listOf(singleBean, mainBean),
fixture.testRootDisposable
)
} else if (repetitionInfo.currentRepetition == 3) {
ExtensionTestUtil.maskExtensions(
ExtensionPointName("com.intellij.editorActionHandler"),
listOf(forEachBean, mainBean),
fixture.testRootDisposable
)
}
}
}
@RepeatedTest(3)
@Test
fun `test insert enter`() {
val before = """Lorem ipsum dolor sit amet,
|${c}consectetur adipiscing elit
@@ -85,7 +31,7 @@ class InsertEnterActionTest : VimTestCase() {
doTest(listOf("i", "<Enter>"), before, after, Mode.INSERT)
}
@RepeatedTest(3)
@Test
fun `test insert enter multicaret`() {
val before = """Lorem ipsum dolor sit amet,
|${c}consectetur adipiscing elit
@@ -103,7 +49,7 @@ class InsertEnterActionTest : VimTestCase() {
}
@TestWithoutNeovim(SkipNeovimReason.CTRL_CODES)
@RepeatedTest(3)
@Test
fun `test insert enter with C-M`() {
val before = """Lorem ipsum dolor sit amet,
|${c}consectetur adipiscing elit
@@ -120,7 +66,7 @@ class InsertEnterActionTest : VimTestCase() {
}
@TestWithoutNeovim(SkipNeovimReason.CTRL_CODES)
@RepeatedTest(3)
@Test
fun `test insert enter with C-J`() {
val before = """Lorem ipsum dolor sit amet,
|${c}consectetur adipiscing elit
@@ -137,7 +83,7 @@ class InsertEnterActionTest : VimTestCase() {
}
@TestWithoutNeovim(SkipNeovimReason.OPTION)
@RepeatedTest(3)
@Test
fun `test insert enter scrolls view up at scrolloff`() {
configureByLines(50, "Lorem ipsum dolor sit amet,")
enterCommand("set scrolloff=10")
@@ -147,29 +93,3 @@ class InsertEnterActionTest : VimTestCase() {
assertVisibleArea(6, 40)
}
}
/**
* An empty handler that works as run "for each caret"
*/
internal class DestroyerHandlerForEach(private val nextHandler: EditorActionHandler) : EditorActionHandler(true) {
override fun doExecute(editor: Editor, caret: Caret?, dataContext: DataContext?) {
nextHandler.execute(editor, caret, dataContext)
}
override fun isEnabledForCaret(editor: Editor, caret: Caret, dataContext: DataContext?): Boolean {
return nextHandler.isEnabled(editor, caret, dataContext)
}
}
/**
* An empty handler that works as run "single time"
*/
internal class DestroyerHandlerSingle(private val nextHandler: EditorActionHandler) : EditorActionHandler(false) {
override fun doExecute(editor: Editor, caret: Caret?, dataContext: DataContext?) {
nextHandler.execute(editor, caret, dataContext)
}
override fun isEnabledForCaret(editor: Editor, caret: Caret, dataContext: DataContext?): Boolean {
return nextHandler.isEnabled(editor, caret, dataContext)
}
}

View File

@@ -0,0 +1,325 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package org.jetbrains.plugins.ideavim.action.motion.changelist
import com.maddyhome.idea.vim.state.mode.Mode
import org.jetbrains.plugins.ideavim.VimTestCase
import org.junit.jupiter.api.Test
/**
* Tests for VIM-519: cycling between recent edits with `g;` and `g,`.
*
* Reference: Neovim 0.11 — `nv_pcmark` / `get_changelist` (`src/nvim/normal.c`,
* `src/nvim/mark.c`) and `changed_common` (`src/nvim/change.c`).
*
* Semantics in one breath:
* - Each undoable change appends an entry; the per-window index sits AT the
* position past the newest entry (so the first `g;` lands on the newest one).
* - `g;` walks backwards (`count` older), `g,` walks forwards (`count` newer).
* - Same-line edits within `'textwidth'` columns of the prior entry merge into
* a single entry at the latest position (default 79 when `'textwidth'` is 0).
* - Errors:
* E662 "At start of changelist" — `g;` at the oldest entry
* E663 "At end of changelist" — `g,` at the newest entry
* E664 "Changelist is empty" — either command with no changes recorded
* - `g,` from the fresh "past end" position silently clamps to the newest
* entry; the error fires on the *next* `g,`.
*/
class MotionGotoChangeActionTest : VimTestCase() {
@Test
fun `test g_semicolon returns to last change after moving away`() {
val before = """
aaa
${c}bbb
ccc
ddd
eee
""".trimIndent()
// Edit on line 2, jump to bottom, `g;` should bring us back.
val keys = listOf("rA", "G\$", "g;")
val after = """
aaa
${c}Abb
ccc
ddd
eee
""".trimIndent()
doTest(keys, before, after, Mode.NORMAL())
}
@Test
fun `test g_semicolon walks backwards through multiple changes`() {
val before = """
${c}aaa
bbb
ccc
ddd
""".trimIndent()
// Three changes on lines 1-3; G$ moves "past end"; first `g;` lands on the
// newest (line 3, already where the cursor is); second `g;` lands on B.
val keys = listOf("rA", "jrB", "jrC", "G\$", "g;", "g;")
val after = """
Aaa
${c}Bbb
Ccc
ddd
""".trimIndent()
doTest(keys, before, after, Mode.NORMAL())
}
@Test
fun `test g_semicolon then g_comma round trips`() {
val before = """
${c}aaa
bbb
ccc
ddd
""".trimIndent()
// Need at least 3 entries for a real round trip: the index sits past the
// newest, so `g;` first hops to the newest, `g;` again to the middle, then
// `g,` advances back to the newest entry.
val keys = listOf("rA", "jrB", "jrC", "G\$", "g;", "g;", "g,")
val after = """
Aaa
Bbb
${c}Ccc
ddd
""".trimIndent()
doTest(keys, before, after, Mode.NORMAL())
}
@Test
fun `test g_semicolon with count walks back N entries from past-end`() {
val before = """
${c}aaa
bbb
ccc
ddd
""".trimIndent()
// 4 changes; index = 4 (past end). `3g;` → index 1 → entry B.
// (Not the oldest -- "3 older" from past-end is the third-newest.)
val keys = listOf("rA", "jrB", "jrC", "jrD", "3g;")
val after = """
Aaa
${c}Bbb
Ccc
Ddd
""".trimIndent()
doTest(keys, before, after, Mode.NORMAL())
}
@Test
fun `test g_semicolon with large count clamps to oldest change`() {
val before = """
${c}aaa
bbb
ccc
ddd
""".trimIndent()
val keys = listOf("rA", "jrB", "jrC", "jrD", "999g;")
val after = """
${c}Aaa
Bbb
Ccc
Ddd
""".trimIndent()
doTest(keys, before, after, Mode.NORMAL())
}
@Test
fun `test g_comma with count walks forward N entries`() {
val before = """
${c}aaa
bbb
ccc
ddd
""".trimIndent()
// Walk all the way to oldest first, then 2 newer → entry C.
val keys = listOf("rA", "jrB", "jrC", "jrD", "999g;", "2g,")
val after = """
Aaa
Bbb
${c}Ccc
Ddd
""".trimIndent()
doTest(keys, before, after, Mode.NORMAL())
}
@Test
fun `test g_semicolon on buffer with no changes reports empty changelist`() {
val before = """
${c}aaa
bbb
""".trimIndent()
configureByText(before)
typeText("g;")
assertPluginError(true)
assertStatusLineMessageContains("E664")
}
@Test
fun `test g_semicolon past oldest entry reports start of changelist`() {
val before = """
${c}aaa
bbb
ccc
""".trimIndent()
configureByText(before)
// One change, then walk to it (oldest == newest), then try to go older.
typeText("rA")
typeText("G")
typeText("g;") // lands on the only entry; idx = 0
typeText("g;") // already at oldest → E662
assertPluginError(true)
assertStatusLineMessageContains("E662")
}
@Test
fun `test g_comma past newest entry reports end of changelist`() {
val before = """
${c}aaa
bbb
""".trimIndent()
configureByText(before)
// First `g,` after a single change silently clamps idx to the newest;
// the second `g,` is the one that actually errors.
typeText("rA")
typeText("g,")
typeText("g,")
assertPluginError(true)
assertStatusLineMessageContains("E663")
}
@Test
fun `test nearby same line edits collapse into one change list entry`() {
val before = "${c}abcdef"
// Two single-character edits on the same line, well within 'textwidth'
// (default 79). The two changes coalesce into a single entry sitting at
// the *latest* position (column 1), so a second `g;` errors instead of
// taking us back to column 0.
configureByText(before)
typeText("rA")
typeText("lrB")
typeText("G\$")
typeText("g;") // lands on the merged entry (col 1, on the 'B')
assertState("A${c}Bcdef")
typeText("g;") // only one entry → E662
assertPluginError(true)
assertStatusLineMessageContains("E662")
}
@Test
fun `test insert mode change is recorded at insert position`() {
val before = """
aaa
${c}bbb
ccc
""".trimIndent()
val keys = listOf("iX", "<Esc>", "gg", "g;")
val after = """
aaa
${c}Xbbb
ccc
""".trimIndent()
doTest(keys, before, after, Mode.NORMAL())
}
@Test
fun `test append at end of line is recorded`() {
val before = """
aaa
${c}bbb
ccc
""".trimIndent()
val keys = listOf("AZ", "<Esc>", "gg", "g;")
// Cursor lands on the inserted Z (the recorded position).
val after = """
aaa
bbb${c}Z
ccc
""".trimIndent()
doTest(keys, before, after, Mode.NORMAL())
}
@Test
fun `test line delete is recorded in change list`() {
val before = """
aaa
${c}bbb
ccc
""".trimIndent()
val keys = listOf("dd", "gg", "g;")
// After `dd` deletes line 2, the change is remembered at that line; `g;`
// returns the cursor to the deletion site (which now holds "ccc").
val after = """
aaa
${c}ccc
""".trimIndent()
doTest(keys, before, after, Mode.NORMAL())
}
@Test
fun `test new change after walking back does not truncate forward history`() {
val before = """
${c}aaa
bbb
ccc
""".trimIndent()
// Make 3 changes, walk back to B, edit there. Unlike the jump list,
// Vim's change list does NOT prune newer entries when a change is made
// mid-list -- it just appends. So C must still be reachable via `g;`.
//
// list before rX: [A@(1,1), B@(2,1), C@(3,1)] idx=3 (past end)
// after G$ g;g; : idx=1, cursor on B
// after rX : [A, B, C, X@(2,1)] idx=4 (past end) -- C survives
// after 2g; : idx=2, cursor on C (at line 3)
val keys = listOf("rA", "jrB", "jrC", "G\$", "g;", "g;", "rX", "2g;")
val after = """
Aaa
Xbb
${c}Ccc
""".trimIndent()
doTest(keys, before, after, Mode.NORMAL())
}
}

View File

@@ -321,4 +321,29 @@ class AutoCmdTest : VimTestCase() {
typeText(injector.parser.parseKeys("i"))
assertExOutput("all\npython")
}
@Test
fun `mode() in InsertEnter autocmd returns n (fires before transition)`() {
enterCommand("autocmd InsertEnter * echo mode()")
typeText(injector.parser.parseKeys("i"))
assertState(Mode.INSERT)
assertExOutput("n")
}
@Test
fun `mode() in InsertEnter autocmd for Replace returns n (fires before transition)`() {
enterCommand("autocmd InsertEnter * echo mode()")
typeText(injector.parser.parseKeys("R"))
assertState(Mode.REPLACE)
assertExOutput("n")
}
@Test
fun `mode() in InsertLeave autocmd returns n (fires after transition)`() {
enterCommand("autocmd InsertLeave * echo mode()")
typeText(injector.parser.parseKeys("i"))
typeText(injector.parser.parseKeys("<esc>"))
assertState(Mode.NORMAL())
assertExOutput("n")
}
}

View File

@@ -134,6 +134,25 @@ class CommandLineCompletionTest : VimExTestCase() {
assertExText("set foo")
}
@Test
fun `test tab does not file-complete echo argument even when prefix matches a real file`() {
// The argument prefix would match `alpha.txt` if file completion ran -- it must not.
typeText(":echo $tempPath/a<Tab>")
assertExText("echo $tempPath/a")
}
@Test
fun `test tab does not file-complete let argument even when prefix matches a real file`() {
typeText(":let $tempPath/b<Tab>")
assertExText("let $tempPath/b")
}
@Test
fun `test tab does not file-complete map argument even when prefix matches a real file`() {
typeText(":map $tempPath/s<Tab>")
assertExText("map $tempPath/s")
}
@Test
fun `test typing after completion invalidates session`() {
typeText(":edit $tempPath/b<Tab>")
@@ -428,4 +447,95 @@ class CommandLineCompletionTest : VimExTestCase() {
typeText("<Left>")
assertExText("edit $tempPath/subdir/notes.md")
}
@Test
fun `test tab completes command name from abbreviation`() {
typeText(":vs<Tab>")
assertExText("vsplit")
}
@Test
fun `test tab completes command name with single match`() {
typeText(":tabc<Tab>")
assertExText("tabclose")
}
@Test
fun `test tab on full command name with no longer match keeps it unchanged`() {
typeText(":edit<Tab>")
assertExText("edit")
}
@Test
fun `test tab cycles through command names sharing a prefix`() {
typeText(":set<Tab>")
assertExText("set")
typeText("<Tab>")
assertExText("setglobal")
typeText("<Tab>")
assertExText("sethandler")
typeText("<Tab>")
assertExText("setlocal")
}
@Test
fun `test tab wraps after last command name match`() {
typeText(":set<Tab>")
typeText("<Tab>")
typeText("<Tab>")
typeText("<Tab>")
assertExText("setlocal")
typeText("<Tab>")
assertExText("set")
}
@Test
fun `test shift tab cycles command names backwards`() {
typeText(":set<S-Tab>")
assertExText("setlocal")
typeText("<S-Tab>")
assertExText("sethandler")
}
@Test
fun `test right arrow cycles command names forward after tab`() {
typeText(":set<Tab>")
assertExText("set")
typeText("<Right>")
assertExText("setglobal")
}
@Test
fun `test left arrow cycles command names backward after tab`() {
typeText(":set<Tab>")
assertExText("set")
typeText("<Left>")
assertExText("setlocal")
}
@Test
fun `test tab on unknown command prefix does not change text`() {
typeText(":xyzzy<Tab>")
assertExText("xyzzy")
}
@Test
fun `test typing after command name completion invalidates session`() {
typeText(":set<Tab>")
assertExText("set")
typeText(" foo")
assertExText("set foo")
// `set` has no argument completion type registered, so Tab in argument position is a no-op.
typeText("<Tab>")
assertExText("set foo")
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2003-2023 The IdeaVim authors
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
@@ -1479,4 +1479,64 @@ class SubstituteCommandTest : VimTestCase() {
enterCommand("set nooldundo")
}
}
@Test
@TestWithoutNeovim(reason = SkipNeovimReason.OPTION)
fun `test substitute with e flag suppresses pattern not found error`() {
configureByText("${c}Hello world\n")
enterCommand("s/missing//e")
assertPluginError(false)
assertStatusLineCleared()
}
@Test
@TestWithoutNeovim(reason = SkipNeovimReason.OPTION)
fun `test substitute without e flag reports pattern not found error`() {
configureByText("${c}Hello world\n")
enterCommand("s/missing//")
assertPluginError(true)
assertPluginErrorMessage("E486: Pattern not found: missing")
}
@Test
@TestWithoutNeovim(reason = SkipNeovimReason.OPTION)
fun `test substitute with e flag and trailing whitespace pattern`() {
// The classic autocmd use case: %s/\s\+$//e should not produce errors when there is no trailing whitespace
configureByText("${c}Hello world\n")
enterCommand("%s/\\s\\+$//e")
assertPluginError(false)
assertStatusLineCleared()
}
@Test
@TestWithoutNeovim(reason = SkipNeovimReason.OPTION)
fun `test substitute with e flag combined with g flag`() {
configureByText("${c}Hello world\n")
enterCommand("s/missing//ge")
assertPluginError(false)
assertStatusLineCleared()
}
@Test
@TestWithoutNeovim(reason = SkipNeovimReason.OPTION)
fun `test substitute with e flag still performs substitution when pattern matches`() {
doTest(
exCommand("s/world/universe/e"),
"${c}Hello world\n",
"${c}Hello universe\n",
)
assertPluginError(false)
}
@Test
@TestWithoutNeovim(reason = SkipNeovimReason.OPTION)
fun `test substitute e flag does not persist to next substitute`() {
// :h :&& - flags are not kept between substitute commands
configureByText("${c}Hello world\n")
enterCommand("s/missing//e")
assertPluginError(false)
enterCommand("s/missing//")
assertPluginError(true)
assertPluginErrorMessage("E486: Pattern not found: missing")
}
}

View File

@@ -0,0 +1,82 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package org.jetbrains.plugins.ideavim.ex.implementation.functions.variousFunctions
import com.maddyhome.idea.vim.api.injector
import org.jetbrains.plugins.ideavim.VimTestCase
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInfo
class ModeFunctionTest : VimTestCase() {
@BeforeEach
override fun setUp(testInfo: TestInfo) {
super.setUp(testInfo)
configureByText("\n")
}
@Test
fun `test mode in normal mode returns n`() {
assertCommandOutput("echo mode()", "n")
}
@Test
fun `test mode with zero argument in normal mode returns n`() {
assertCommandOutput("echo mode(0)", "n")
}
@Test
fun `test mode with truthy argument in normal mode returns n`() {
assertCommandOutput("echo mode(1)", "n")
}
@Test
fun `test mode with string argument in normal mode returns n`() {
assertCommandOutput("echo mode('x')", "n")
}
@Test
fun `test mode reports too many arguments`() {
enterCommand("echo mode(0, 1)")
assertPluginError(true)
assertPluginErrorMessage("E118: Too many arguments for function: mode")
}
@Test
fun `test mode in insert returns i`() {
configureByText("\n")
enterCommand("inoremap <expr> q mode()")
typeText(injector.parser.parseKeys("iq<esc>"))
assertState("i\n")
}
@Test
fun `test mode in replace returns R`() {
configureByText("a\n")
enterCommand("inoremap <expr> q mode()")
typeText(injector.parser.parseKeys("Rq<esc>"))
assertState("R\n")
}
@Test
fun `test mode in visual char-wise returns v`() {
configureByText("abc\n")
enterCommand("vmap <expr> q '<Esc>A - mode='.mode().'<Esc>'")
typeText(injector.parser.parseKeys("vq"))
assertState("abc - mode=v\n")
}
@Test
fun `test mode in visual line-wise returns V`() {
configureByText("abc\n")
enterCommand("vmap <expr> q '<Esc>A - mode='.mode().'<Esc>'")
typeText(injector.parser.parseKeys("Vq"))
assertState("abc - mode=V\n")
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2003-2025 The IdeaVim authors
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
@@ -8,10 +8,19 @@
package org.jetbrains.plugins.ideavim.extension.nerdtree
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.options.advanced.AdvancedSettings
import com.intellij.testFramework.PlatformTestUtil
import com.intellij.ui.treeStructure.Tree
import com.maddyhome.idea.vim.extension.nerdtree.armSelectionRestoreOnEscape
import org.jetbrains.plugins.ideavim.VimTestCase
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertDoesNotThrow
import java.awt.event.KeyEvent
import javax.swing.tree.DefaultMutableTreeNode
import javax.swing.tree.DefaultTreeModel
import javax.swing.tree.TreePath
class NerdTreeTest : VimTestCase() {
@Test
@@ -20,4 +29,79 @@ class NerdTreeTest : VimTestCase() {
AdvancedSettings.getBoolean("ide.tree.collapse.recursively") // will throw if the id is invalid
}
}
// VIM-4196: pressing `/` to speed search and then ESC should restore the
// original tree selection rather than leave the cursor on the search match.
@Test
fun `test esc after speed search restores original tree selection`() {
onEdt {
val tree = createSampleTree()
val pathA = tree.pathFor("fileA.txt")
val pathB = tree.pathFor("fileB.txt")
tree.selectionPath = pathA
armSelectionRestoreOnEscape(tree)
tree.selectionPath = pathB
tree.fireKeyPressed(KeyEvent.VK_ESCAPE)
PlatformTestUtil.dispatchAllEventsInIdeEventQueue()
assertEquals(pathA, tree.selectionPath, "ESC after `/` speed search should restore original selection")
}
}
// VIM-4196: pressing ENTER (commit) must not roll the selection back to the
// pre-search path; the user explicitly chose the matched item.
@Test
fun `test enter after speed search keeps the search match selected`() {
onEdt {
val tree = createSampleTree()
val pathA = tree.pathFor("fileA.txt")
val pathB = tree.pathFor("fileB.txt")
tree.selectionPath = pathA
armSelectionRestoreOnEscape(tree)
tree.selectionPath = pathB
tree.fireKeyPressed(KeyEvent.VK_ENTER)
PlatformTestUtil.dispatchAllEventsInIdeEventQueue()
assertEquals(pathB, tree.selectionPath, "ENTER after `/` speed search should keep the matched selection")
}
}
private fun onEdt(block: () -> Unit) {
ApplicationManager.getApplication().invokeAndWait(block)
}
private fun createSampleTree(): Tree {
val root = DefaultMutableTreeNode("root")
root.add(DefaultMutableTreeNode("fileA.txt"))
root.add(DefaultMutableTreeNode("fileB.txt"))
root.add(DefaultMutableTreeNode("fileC.txt"))
return Tree(DefaultTreeModel(root))
}
private fun Tree.pathFor(name: String): TreePath {
val root = model.root as DefaultMutableTreeNode
val child = (0 until root.childCount)
.map { root.getChildAt(it) as DefaultMutableTreeNode }
.first { it.userObject == name }
return TreePath(arrayOf<Any>(root, child))
}
/**
* Fire a synthetic KEY_PRESSED to all listeners registered on the tree. We
* call the listeners directly because `Component.dispatchEvent` does not fire
* KeyListeners on a non-displayed component in headless tests.
*/
private fun Tree.fireKeyPressed(keyCode: Int) {
val keyChar = if (keyCode == KeyEvent.VK_ENTER) '\n' else KeyEvent.CHAR_UNDEFINED
val event = KeyEvent(this, KeyEvent.KEY_PRESSED, System.currentTimeMillis(), 0, keyCode, keyChar)
keyListeners.forEach { it.keyPressed(event) }
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2003-2024 The IdeaVim authors
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
@@ -8,12 +8,14 @@
package org.jetbrains.plugins.ideavim.group.search
import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.state.mode.Mode
import com.maddyhome.idea.vim.state.mode.SelectionType
import org.jetbrains.plugins.ideavim.SkipNeovimReason
import org.jetbrains.plugins.ideavim.TestWithoutNeovim
import org.jetbrains.plugins.ideavim.VimTestCase
import org.junit.jupiter.api.Test
import kotlin.test.assertFalse
@Suppress("SpellCheckingInspection")
class IncsearchTests : VimTestCase() {
@@ -1017,4 +1019,36 @@ class IncsearchTests : VimTestCase() {
""".trimMargin()
)
}
@TestWithoutNeovim(SkipNeovimReason.OPTION)
@Test
fun `test incsearch with incomplete regex does not indicate error`() {
configureByText(
"""I found it in a legendary land
|${c}all rocks and lavender and tufted grass,
|where it was settled on some sodden sand
|hard by the torrent of a mountain pass.
""".trimMargin(),
)
enterCommand("set incsearch")
injector.messages.clearError()
typeText("/", "\\(")
assertFalse(injector.messages.isError(), "Incomplete regex during incsearch should not trigger an error/beep")
}
@TestWithoutNeovim(SkipNeovimReason.OPTION)
@Test
fun `test incsearch in substitute command with incomplete regex does not indicate error`() {
configureByText(
"""I found it in a legendary land
|${c}all rocks and lavender and tufted grass,
|where it was settled on some sodden sand
|hard by the torrent of a mountain pass.
""".trimMargin(),
)
enterCommand("set incsearch")
injector.messages.clearError()
typeText(":", "%s/\\(")
assertFalse(injector.messages.isError(), "Incomplete regex during incsearch :s should not trigger an error/beep")
}
}

View File

@@ -68,15 +68,14 @@ import com.maddyhome.idea.vim.group.EffectiveIjOptions
import com.maddyhome.idea.vim.group.GlobalIjOptions
import com.maddyhome.idea.vim.group.IjOptions
import com.maddyhome.idea.vim.group.visual.VimVisualTimer.swingTimer
import com.maddyhome.idea.vim.handler.isOctopusEnabled
import com.maddyhome.idea.vim.helper.EditorHelper
import com.maddyhome.idea.vim.helper.TestInputModel
import com.maddyhome.idea.vim.helper.getGuiCursorMode
import com.maddyhome.idea.vim.mark.Mark
import com.maddyhome.idea.vim.key.MappingOwner
import com.maddyhome.idea.vim.key.ToKeysMappingInfo
import com.maddyhome.idea.vim.listener.SelectionVimListenerSuppressor
import com.maddyhome.idea.vim.listener.VimListenerManager
import com.maddyhome.idea.vim.mark.Mark
import com.maddyhome.idea.vim.newapi.globalIjOptions
import com.maddyhome.idea.vim.newapi.ijOptions
import com.maddyhome.idea.vim.newapi.vim
@@ -229,6 +228,9 @@ abstract class VimTestCase(private val defaultEditorText: String? = null) {
(VimPlugin.getSearch() as VimSearchGroupBase).resetState()
injector.markService.resetAllMarks()
injector.jumpService.resetJumps()
ApplicationManager.getApplication()
.getService(com.maddyhome.idea.vim.group.changelist.ChangeListService::class.java)
?.reset()
injector.historyGroup.resetHistory()
VimPlugin.getChange().resetRepeat()
VimPlugin.getKey().savedShortcutConflicts.clear()
@@ -1015,7 +1017,14 @@ abstract class VimTestCase(private val defaultEditorText: String? = null) {
ActionManager.getInstance(),
0,
)
if (ActionUtil.lastUpdateAndCheckDumb(VimShortcutKeyAction.instance, e, true)) {
if (!VimPlugin.isEnabled()) {
// VimShortcutKeyAction is a no-op when the plugin is disabled, so deliver
// Enter/Escape to the IDE directly.
when {
key.keyCode == KeyEvent.VK_ENTER && key.modifiers == 0 -> fixture.type('\n')
key.keyCode == KeyEvent.VK_ESCAPE -> fixture.performEditorAction("EditorEscape")
}
} else if (ActionUtil.lastUpdateAndCheckDumb(VimShortcutKeyAction.instance, e, true)) {
ActionUtil.performActionDumbAwareWithCallbacks(VimShortcutKeyAction.instance, e)
}
}
@@ -1027,14 +1036,6 @@ abstract class VimTestCase(private val defaultEditorText: String? = null) {
private fun KeyStroke.getChar(editor: Editor): CharType {
if (keyChar != KeyEvent.CHAR_UNDEFINED) return CharType.CharDetected(keyChar)
if (isOctopusEnabled(this, editor)) {
if (keyCode in setOf(KeyEvent.VK_ENTER)) {
if (modifiers == 0) {
return CharType.CharDetected(keyCode.toChar())
}
}
if (keyCode == KeyEvent.VK_ESCAPE) return CharType.EditorAction("EditorEscape")
}
return CharType.UNDEFINED
}

View File

@@ -1,3 +1,11 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
import org.jetbrains.intellij.platform.gradle.TestFrameworkType
/*
@@ -46,7 +54,7 @@ dependencies {
create(ideaType, ideaVersion) { this.useInstaller = useInstaller }
testFramework(TestFrameworkType.Platform)
testFramework(TestFrameworkType.JUnit5)
bundledPlugins("com.intellij.java", "org.jetbrains.plugins.yaml")
bundledPlugins("com.intellij.java", "org.jetbrains.plugins.yaml", "org.jetbrains.plugins.textmate")
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2003-2024 The IdeaVim authors
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
@@ -14,7 +14,6 @@ import com.intellij.openapi.application.ApplicationManager
import com.maddyhome.idea.vim.api.injector
import org.jetbrains.plugins.ideavim.SkipNeovimReason
import org.jetbrains.plugins.ideavim.TestWithoutNeovim
import org.jetbrains.plugins.ideavim.VimBehaviorDiffers
import org.jetbrains.plugins.ideavim.VimJavaTestCase
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
@@ -65,25 +64,12 @@ class ChangeActionJavaTest : VimJavaTestCase() {
// VIM-511 |.|
@TestWithoutNeovim(SkipNeovimReason.DIFFERENT)
@Test
@VimBehaviorDiffers(
originalVimAfter = """
class C {
C(int i) {
i = 3;
}
C(int i) {
i = 3;
}
}
""", description = """The bracket should be on the new line.
|This behaviour was explicitely broken as we migrate to the new handlers and I can't support it"""
)
fun testAutoCompleteCurlyBraceWithEnterWithinFunctionBody() {
configureByJavaText(
"""
class C $c{
}
""".trimIndent(),
)
typeText(injector.parser.parseKeys("o" + "C(" + "<BS>" + "(int i) {" + "<Enter>" + "i = 3;" + "<Esc>" + "<Down>" + "."))
@@ -93,7 +79,8 @@ class ChangeActionJavaTest : VimJavaTestCase() {
i = 3;
}
C(int i) {
i = 3;}
i = 3;
}
}
""",
)

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2003-2024 The IdeaVim authors
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
@@ -94,6 +94,28 @@ class CommentaryExtensionTest : VimJavaTestCase() {
)
}
@Test
fun testLineCommentCppSingleLine() {
doTest(
keys = "gcc",
before = "<caret>#include <cstdint>\n",
after = "<caret>// #include <cstdint>\n",
modeAfter = Mode.NORMAL(),
fileName = "test.cpp",
)
}
@Test
fun testLineCommentCppMultipleLines() {
doTest(
keys = "2gcc",
before = "<caret>#include <cstdint>\n#include <cstdio>\n",
after = "<caret>// #include <cstdint>\n// #include <cstdio>\n",
modeAfter = Mode.NORMAL(),
fileName = "test.cpp",
)
}
@Test
fun testLineCommentDownPreservesAbsoluteCaretLocation() {
doTest(

View File

@@ -0,0 +1,82 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package com.maddyhome.idea.vim.split
import org.junit.jupiter.api.Test
/**
* Split-mode coverage for VIM-519 (`g;` / `g,`).
*
* In split mode the change list is fed by the backend's `RecentPlacesListener`
* over `CHANGE_LIST_REMOTE_TOPIC`. These tests verify the full pipeline:
* edit on backend → topic broadcast → frontend `ChangeListService` → `g;` reads it.
*
* Index logic and error paths are covered by `MotionGotoChangeActionTest` in the
* monolith suite; here we only check that the RPC topic delivers events at all.
*/
class ChangeListSplitTest : IdeaVimStarterTestBase() {
private fun longFile(name: String): String {
val lines = (1..20).joinToString("\n") { "Line $it of content" }
return createFile("src/$name.txt", lines + "\n")
}
@Test
fun `g_semicolon returns to last edit after moving away`() {
openFile(longFile("ChangeList1"))
typeVim("5G")
pause()
typeVim("rX")
pause(1000)
typeVim("G")
pause()
assertCaretAfter(15, "G should go past line 15")
typeVim("g;")
pause(1000)
assertCaretAtLine(5, "g; should bring cursor back to the edited line via the RPC topic")
}
@Test
fun `g_semicolon walks back through multiple edits`() {
openFile(longFile("ChangeList2"))
typeVim("3G")
pause()
typeVim("rA")
pause(800)
typeVim("8G")
pause()
typeVim("rB")
pause(800)
typeVim("13G")
pause()
typeVim("rC")
pause(800)
typeVim("G")
pause()
typeVim("g;")
pause(800)
assertCaretAtLine(13, "first g; lands on the newest edit")
typeVim("g;")
pause(800)
assertCaretAtLine(8, "second g; walks back one entry")
typeVim("g;")
pause(800)
assertCaretAtLine(3, "third g; walks back to the oldest entry")
}
}

View File

@@ -30,18 +30,7 @@ class InsertEnterAction : VimActionHandler.SingleExecution() {
cmd: Command,
operatorArguments: OperatorArguments,
): Boolean {
if (injector.application.isOctopusEnabled()) {
if (editor.isInForEachCaretScope()) {
editor.removeSecondaryCarets()
injector.changeGroup.processEnter(editor, editor.primaryCaret(), context)
} else {
editor.forEachNativeCaret({ caret ->
injector.changeGroup.processEnter(editor, caret, context)
})
}
} else {
injector.changeGroup.processEnter(editor, context)
}
injector.changeGroup.processEnter(editor, context)
injector.scroll.scrollCaretIntoView(editor)
return true
}

View File

@@ -19,6 +19,7 @@ import com.maddyhome.idea.vim.command.OperatorArguments
import com.maddyhome.idea.vim.group.visual.VimSelection
import com.maddyhome.idea.vim.handler.VisualOperatorActionHandler
import com.maddyhome.idea.vim.put.PutData
import com.maddyhome.idea.vim.register.Register
/**
* @author vlan
@@ -73,6 +74,26 @@ sealed class PutVisualTextBaseAction(
val visualSelection = selection?.let { PutData.VisualSelection(mapOf(caret to it), it.type) }
return PutData(textData, visualSelection, count, insertTextBeforeCaret, indent, caretAfterInsertedText)
}
private fun resolveRegisterForVisualPaste(
lastRegisterChar: Char,
caret: VimCaret,
editor: VimEditor,
context: ExecutionContext,
): Register? {
// IntelliJ updates X11 PRIMARY on visual selection; use the last explicitly-yanked value.
return if (isDefaultSystemClipboard(lastRegisterChar)) {
injector.registerGroup.getLastExplicitlyWrittenRegister(lastRegisterChar)
?: caret.registerStorage.getRegister(editor, context, lastRegisterChar)
} else {
caret.registerStorage.getRegister(editor, context, lastRegisterChar)
}
}
private fun isDefaultSystemClipboard(registerChar: Char) =
!injector.registerGroup.isRegisterSpecifiedExplicitly &&
injector.registerGroup.isSystemClipboard(registerChar) &&
injector.registerGroup.isPrimaryRegisterSupported()
}
@CommandOrMotion(keys = ["P"], modes = [Mode.VISUAL])

View File

@@ -112,12 +112,23 @@ private fun startNewCompletion(
return true
}
private fun findMatches(parsed: ParsedCommandLine, context: ExecutionContext): List<String>? {
val fullCommandName = injector.vimscriptParser.exCommands.getFullCommandName(parsed.commandName) ?: return null
val completionType = CommandCompletionTypes.getCompletionType(fullCommandName)
if (completionType == CommandLineCompletionType.NONE) return null
private fun findMatches(parsed: CommandLineCompletionContext, context: ExecutionContext): List<String>? {
return when (parsed) {
is CommandNameCompletionContext -> findCommandNameMatches(parsed)
is ArgumentCompletionContext -> findArgumentMatches(parsed, context)
}
}
return injector.file.listFilesForCompletion(parsed.argumentPrefix, context)
private fun findCommandNameMatches(parsed: CommandNameCompletionContext): List<String> {
return injector.vimscriptParser.exCommands.findFullCommandsByPrefix(parsed.prefix)
}
private fun findArgumentMatches(parsed: ArgumentCompletionContext, context: ExecutionContext): List<String>? {
val fullCommandName = injector.vimscriptParser.exCommands.getFullCommandName(parsed.commandName) ?: return null
return when (CommandCompletionTypes.getCompletionType(fullCommandName)) {
CommandLineCompletionType.FILE -> injector.file.listFilesForCompletion(parsed.argumentPrefix, context)
CommandLineCompletionType.NONE -> null
}
}
internal fun selectMatch(completion: CommandLineCompletion, forward: Boolean): String? {

View File

@@ -9,31 +9,70 @@
package com.maddyhome.idea.vim.action.ex
/**
* Lightweight parser for extracting the command name and argument prefix
* from a partially-typed ex command line. Used for Tab completion context detection.
* Parsed completion context for a partially-typed ex command line. Used for Tab completion
* context detection.
*
* - [CommandNameCompletionContext]: the user is still typing the command name
* (e.g. `:vs` -> complete to `:vsplit`).
* - [ArgumentCompletionContext]: a command name plus a separator has been typed,
* so completion targets the argument (e.g. `:edit foo` -> complete file paths).
*/
internal data class ParsedCommandLine(
internal sealed interface CommandLineCompletionContext {
val completionStart: Int
}
internal data class CommandNameCompletionContext(
val prefix: String,
override val completionStart: Int,
) : CommandLineCompletionContext
internal data class ArgumentCompletionContext(
val commandName: String,
val argumentPrefix: String,
val completionStart: Int,
)
override val completionStart: Int,
) : CommandLineCompletionContext
internal fun parseCommandLineForCompletion(text: String): ParsedCommandLine? {
internal fun parseCommandLineForCompletion(text: String): CommandLineCompletionContext? {
val trimmed = text.trimStart()
if (trimmed.isEmpty()) return null
val commandName = extractCommandName(trimmed) ?: return null
val commandEnd = commandName.length
val leadingSpacesLength = text.length - trimmed.length
if (!hasArgumentSeparator(trimmed, commandEnd)) return null
val argStart = skipSpaces(trimmed, commandEnd)
val argPrefix = trimmed.substring(argStart)
val leadingSpaces = text.length - trimmed.length
return ParsedCommandLine(commandName, argPrefix, leadingSpaces + argStart)
return if (isCommandNameOnly(trimmed, commandName)) {
parseCommandNameContext(commandName, leadingSpacesLength)
} else {
parseArgumentContext(trimmed, commandName, leadingSpacesLength)
}
}
private fun isCommandNameOnly(trimmed: String, commandName: String): Boolean =
commandName.length == trimmed.length
private fun parseCommandNameContext(commandName: String, leadingSpacesLength: Int): CommandNameCompletionContext? {
if (isExplicitBangForm(commandName)) return null
return CommandNameCompletionContext(commandName, leadingSpacesLength)
}
private fun parseArgumentContext(
trimmed: String,
commandName: String,
leadingSpacesLength: Int,
): ArgumentCompletionContext? {
val commandLength = commandName.length
if (!hasArgumentSeparator(trimmed, commandLength)) return null
val argStart = skipSpaces(trimmed, commandLength)
val argPrefix = trimmed.substring(argStart)
return ArgumentCompletionContext(commandName, argPrefix, leadingSpacesLength + argStart)
}
/**
* A trailing bang means the user has committed to a specific command form;
* completing the name (e.g. `vs!` -> `vsplit`) would silently change their intent.
*/
private fun isExplicitBangForm(commandName: String): Boolean = commandName.endsWith('!')
private fun extractCommandName(text: String): String? {
var end = 0
while (end < text.length && (text[end].isLetter() || text[end] == '!')) {

View File

@@ -33,34 +33,26 @@ class PlaybackRegisterAction : VimActionHandler.SingleExecution() {
): Boolean {
val argument = cmd.argument as? Argument.Character ?: return false
val reg = argument.character
val res = arrayOf(false)
when {
return when {
reg == LAST_COMMAND_REGISTER || (reg == '@' && injector.macro.lastRegister == LAST_COMMAND_REGISTER) -> {
try {
var i = 0
while (i < cmd.count) {
res[0] = injector.vimscriptExecutor.executeLastCommand(editor, context)
if (!res[0]) {
break
}
i += 1
var success = false
for (i in 0 until cmd.count) {
success = injector.vimscriptExecutor.executeLastCommand(editor, context)
if (!success) break
}
if (reg != '@') { // @ is not a register itself, it just tells vim to use the last register
injector.macro.lastRegister = reg
}
success
} catch (_: ExException) {
res[0] = false
false
}
}
reg == '@' -> {
res[0] = injector.macro.playbackLastRegister(editor, context, cmd.count)
}
reg == '@' -> injector.macro.playbackLastRegister(editor, context, cmd.count)
else -> {
res[0] = injector.macro.playbackRegister(editor, context, reg, cmd.count)
}
else -> injector.macro.playbackRegister(editor, context, reg, cmd.count)
}
return res[0]
}
}

View File

@@ -0,0 +1,48 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package com.maddyhome.idea.vim.action.motion.changelist
import com.intellij.vim.annotations.CommandOrMotion
import com.intellij.vim.annotations.Mode
import com.maddyhome.idea.vim.api.ExecutionContext
import com.maddyhome.idea.vim.api.ImmutableVimCaret
import com.maddyhome.idea.vim.api.VimEditor
import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.command.Argument
import com.maddyhome.idea.vim.command.MotionType
import com.maddyhome.idea.vim.command.OperatorArguments
import com.maddyhome.idea.vim.handler.Motion
import com.maddyhome.idea.vim.handler.MotionActionHandler
/** `g;` -- go to [count] older position in the change list. */
@CommandOrMotion(keys = ["g;"], modes = [Mode.NORMAL])
class MotionGotoChangeOlderAction : MotionActionHandler.ForEachCaret() {
override fun getOffset(
editor: VimEditor,
caret: ImmutableVimCaret,
context: ExecutionContext,
argument: Argument?,
operatorArguments: OperatorArguments,
): Motion = injector.motion.moveCaretToChange(editor, caret, -operatorArguments.count1)
override val motionType: MotionType = MotionType.EXCLUSIVE
}
/** `g,` -- go to [count] newer position in the change list. */
@CommandOrMotion(keys = ["g,"], modes = [Mode.NORMAL])
class MotionGotoChangeNewerAction : MotionActionHandler.ForEachCaret() {
override fun getOffset(
editor: VimEditor,
caret: ImmutableVimCaret,
context: ExecutionContext,
argument: Argument?,
operatorArguments: OperatorArguments,
): Motion = injector.motion.moveCaretToChange(editor, caret, operatorArguments.count1)
override val motionType: MotionType = MotionType.EXCLUSIVE
}

View File

@@ -31,18 +31,7 @@ class SelectEnterAction : VimActionHandler.SingleExecution() {
cmd: Command,
operatorArguments: OperatorArguments,
): Boolean {
if (injector.application.isOctopusEnabled()) {
if (editor.isInForEachCaretScope()) {
editor.removeSecondaryCarets()
injector.changeGroup.processEnter(editor, editor.primaryCaret(), context)
} else {
editor.forEachNativeCaret({ caret ->
injector.changeGroup.processEnter(editor, caret, context)
})
}
} else {
injector.changeGroup.processEnter(editor, context)
}
injector.changeGroup.processEnter(editor, context)
return true
}
}

View File

@@ -24,5 +24,4 @@ interface VimApplication {
fun currentStackTrace(): String
fun runAfterGotFocus(runnable: Runnable)
fun isOctopusEnabled(): Boolean
}

View File

@@ -59,7 +59,6 @@ interface VimChangeGroup {
fun processEscape(editor: VimEditor, context: ExecutionContext?)
fun processEnter(editor: VimEditor, caret: VimCaret, context: ExecutionContext)
fun processEnter(editor: VimEditor, context: ExecutionContext)
fun processBackspace(editor: VimEditor, context: ExecutionContext)

View File

@@ -169,13 +169,7 @@ abstract class VimKeyGroupBase : VimKeyGroup {
val oldSize = requiredShortcutKeys.size
for (key in fromKeys) {
if (key.keyChar == KeyEvent.CHAR_UNDEFINED) {
if (
!injector.application.isOctopusEnabled() ||
!(key.keyCode == KeyEvent.VK_ESCAPE && key.modifiers == 0) &&
!(key.keyCode == KeyEvent.VK_ENTER && key.modifiers == 0)
) {
requiredShortcutKeys.add(RequiredShortcut(key, owner))
}
requiredShortcutKeys.add(RequiredShortcut(key, owner))
}
}
if (requiredShortcutKeys.size != oldSize) {

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2003-2023 The IdeaVim authors
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
@@ -85,6 +85,16 @@ interface VimMotionGroup {
fun moveCaretToMark(caret: ImmutableVimCaret, ch: Char, toLineStart: Boolean): Motion
fun moveCaretToMarkRelative(caret: ImmutableVimCaret, count: Int): Motion
fun moveCaretToJump(editor: VimEditor, caret: ImmutableVimCaret, count: Int): Motion
/**
* Move the caret along the change list (`g;` / `g,`). [count] is signed:
* negative for `g;` (older), positive for `g,` (newer).
*
* Emits the appropriate E662 / E663 / E664 message and returns [Motion.Error]
* when the index is already at the relevant boundary.
*/
fun moveCaretToChange(editor: VimEditor, caret: ImmutableVimCaret, count: Int): Motion
fun moveCaretToMatchingPair(editor: VimEditor, caret: ImmutableVimCaret): Motion
/**

View File

@@ -312,6 +312,12 @@ abstract class VimMotionGroupBase : VimMotionGroup {
return offset.toMotionOrError()
}
// Engine has no change list (it lives on the frontend). Frontend MotionGroup overrides.
override fun moveCaretToChange(editor: VimEditor, caret: ImmutableVimCaret, count: Int): Motion {
injector.messages.showErrorMessage(editor, injector.messages.message("E664"))
return Motion.Error
}
override fun moveCaretToJump(editor: VimEditor, caret: ImmutableVimCaret, count: Int): Motion {
val jumpService = injector.jumpService
val spot = jumpService.getJumpSpot(editor)

View File

@@ -644,8 +644,10 @@ abstract class VimSearchGroupBase : VimSearchGroup {
options
)
if (lineToNextSubstitute == null) {
injector.messages.indicateError()
injector.messages.showStatusBarMessage(null, "E486: Pattern not found: $pattern")
if (doError) {
injector.messages.indicateError()
injector.messages.showStatusBarMessage(null, "E486: Pattern not found: $pattern")
}
return true
}
val (line, nextSubstitute) = lineToNextSubstitute
@@ -855,7 +857,7 @@ abstract class VimSearchGroupBase : VimSearchGroup {
if (!gotQuit) {
if (lastMatchLine != -1) {
caret.moveToOffset(injector.motion.moveCaretToLineStartSkipLeading(editor, lastMatchLine))
} else {
} else if (doError) {
injector.messages.showErrorMessage(editor, "E486: Pattern not found: $pattern")
}
}

View File

@@ -294,7 +294,9 @@ abstract class VimSearchHelperBase : VimSearchHelper {
val regex = try {
VimRegex(pattern)
} catch (e: VimRegexException) {
injector.messages.showErrorMessage(editor, e.message)
if (showMessages) {
injector.messages.showErrorMessage(editor, e.message)
}
return null
}
@@ -401,7 +403,6 @@ abstract class VimSearchHelperBase : VimSearchHelper {
val regex = try {
VimRegex(pattern)
} catch (e: VimRegexException) {
injector.messages.showErrorMessage(editor, e.message)
return emptyList()
}
return regex.findAll(

View File

@@ -256,7 +256,7 @@ private fun classify(codePoint: Int): CodePointType {
in 0x1100..0x115F, in 0xA960..0xA97C -> CodePointType.L
in 0x1160..0x11A7, in 0xD7B0..0xD7C6 -> CodePointType.V
in 0x11A8..0x11FF, in 0xD7CB..0xD7FB -> CodePointType.T
// LV is encountered every 28 characters, everything in-between is LVT.
// LV is encountered every 28 characters, everything in-between is LVT.
in 0xAC00..0xD7A3 -> if ((codePoint - 0xAC00) % 28 == 0) CodePointType.LV else CodePointType.LVT
else -> CodePointType.OTHER
}

View File

@@ -98,4 +98,6 @@ interface VimRegisterGroup {
fun getCurrentRegisterForMulticaret(): Char // `set clipbaard+=unnamedplus` should not make system register the default one when working with multiple carets VIM-2804
fun isSystemClipboard(register: Char): Boolean
fun isPrimaryRegisterSupported(): Boolean
fun getLastExplicitlyWrittenRegister(r: Char): Register?
}

View File

@@ -409,6 +409,11 @@ abstract class VimRegisterGroupBase : VimRegisterGroup {
return System.getenv("DISPLAY") != null && injector.systemInfoService.isXWindow
}
override fun getLastExplicitlyWrittenRegister(r: Char): Register? {
require(CLIPBOARD_REGISTERS.contains(r.lowercaseChar())) { "Only clipboard registers are supported: got '$r'" }
return myRegisters[r.lowercaseChar()]
}
private fun setSystemPrimaryRegisterText(text: String, rawText: String, transferableData: List<Any>) {
logger.trace("Setting text: $text to primary selection...")
if (isPrimaryRegisterSupported()) {
@@ -436,7 +441,13 @@ abstract class VimRegisterGroupBase : VimRegisterGroup {
return refreshClipboardRegister()
}
try {
val clipboardData = injector.clipboardManager.getPrimaryContent() ?: return null
val clipboardData = injector.clipboardManager.getPrimaryContent()
if (clipboardData == null || clipboardData.text.isEmpty()) {
// Wayland/XWayland clears PRIMARY on focus change; prefer the last IdeaVim-written value over
// an empty result. Differs from Vim only when the user deliberately clears PRIMARY externally.
logger.trace("PRIMARY selection is unavailable or empty; falling back to in-memory register value")
return myRegisters[PRIMARY_REGISTER]
}
val currentRegister = myRegisters[PRIMARY_REGISTER]
val text = clipboardData.text
val transferableData = clipboardData.transferableData.toMutableList()

View File

@@ -36,6 +36,14 @@ class ExCommandTree {
return abbrevToCommand[abbreviation]
}
/**
* Results are sorted alphabetically; command-name Tab completion relies on this
* order to cycle through matches deterministically (e.g. `:set` -> `set`, `setglobal`, `sethandler`, ...).
*/
fun findFullCommandsByPrefix(prefix: String): List<String> {
return commandToInstance.keys.filter { it.startsWith(prefix) }.sorted()
}
private fun parseCommandPattern(commandsPattern: String): List<Pair<String, String>> {
val result = mutableListOf<Pair<String, String>>()
val commands = commandsPattern.split(",")

View File

@@ -0,0 +1,55 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package com.maddyhome.idea.vim.vimscript.model.functions.handlers.variousFunctions
import com.intellij.vim.annotations.VimscriptFunction
import com.maddyhome.idea.vim.api.ExecutionContext
import com.maddyhome.idea.vim.api.VimEditor
import com.maddyhome.idea.vim.state.mode.Mode
import com.maddyhome.idea.vim.state.mode.SelectionType
import com.maddyhome.idea.vim.vimscript.model.VimLContext
import com.maddyhome.idea.vim.vimscript.model.datatypes.VimString
import com.maddyhome.idea.vim.vimscript.model.functions.BuiltinFunctionHandler
@VimscriptFunction(name = "mode")
internal class ModeFunctionHandler : BuiltinFunctionHandler<VimString>(minArity = 0, maxArity = 1) {
override fun doFunction(
arguments: Arguments,
editor: VimEditor,
context: ExecutionContext,
vimContext: VimLContext,
): VimString {
return VimString(modeString(editor.mode))
}
private fun modeString(mode: Mode): String = when (mode) {
is Mode.NORMAL -> "n"
is Mode.OP_PENDING -> "no"
is Mode.INSERT -> "i"
is Mode.REPLACE -> "R"
is Mode.VISUAL -> when (mode.selectionType) {
SelectionType.CHARACTER_WISE -> "v"
SelectionType.LINE_WISE -> "V"
SelectionType.BLOCK_WISE -> CTRL_V
}
is Mode.SELECT -> when (mode.selectionType) {
SelectionType.CHARACTER_WISE -> "s"
SelectionType.LINE_WISE -> "S"
SelectionType.BLOCK_WISE -> CTRL_S
}
is Mode.CMD_LINE -> "c"
}
companion object {
private const val CTRL_V = ""
private const val CTRL_S = ""
}
}

View File

@@ -1644,6 +1644,11 @@
"class": "com.maddyhome.idea.vim.action.motion.search.SearchWordForwardAction",
"modes": "NXO"
},
{
"keys": "g,",
"class": "com.maddyhome.idea.vim.action.motion.changelist.MotionGotoChangeNewerAction",
"modes": "N"
},
{
"keys": "g0",
"class": "com.maddyhome.idea.vim.action.motion.leftright.MotionFirstScreenColumnAction",
@@ -1654,6 +1659,11 @@
"class": "com.maddyhome.idea.vim.action.file.FileGetHexAction",
"modes": "N"
},
{
"keys": "g;",
"class": "com.maddyhome.idea.vim.action.motion.changelist.MotionGotoChangeOlderAction",
"modes": "N"
},
{
"keys": "g<C-A>",
"class": "com.maddyhome.idea.vim.action.change.change.number.ChangeVisualNumberAvalancheIncAction",

View File

@@ -52,6 +52,7 @@
"mapnew": "com.maddyhome.idea.vim.vimscript.model.functions.handlers.collectionFunctions.MapNewFunctionHandler",
"max": "com.maddyhome.idea.vim.vimscript.model.functions.handlers.collectionFunctions.MaxFunctionHandler",
"min": "com.maddyhome.idea.vim.vimscript.model.functions.handlers.collectionFunctions.MinFunctionHandler",
"mode": "com.maddyhome.idea.vim.vimscript.model.functions.handlers.variousFunctions.ModeFunctionHandler",
"nr2char": "com.maddyhome.idea.vim.vimscript.model.functions.handlers.stringFunctions.Nr2charFunctionHandler",
"or": "com.maddyhome.idea.vim.vimscript.model.functions.handlers.bitwiseFunctions.OrFunctionHandler",
"pow": "com.maddyhome.idea.vim.vimscript.model.functions.handlers.floatFunctions.PowFunctionHandler",

View File

@@ -45,6 +45,9 @@ E69=E69: Missing ] after {0}%[
E70=E70: Empty {0}%[]
E71=E71: Invalid character after {0}%
E76=E76: Too many [
E662=E662: At start of changelist
E663=E663: At end of changelist
E664=E664: changelist is empty
E81=E81: Using <SID> not in a script context
E86=E86: Buffer {0} does not exist
E93=E93: More than one match for {0}

View File

@@ -10,15 +10,14 @@ package com.maddyhome.idea.vim.action.ex
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertIs
import kotlin.test.assertNull
class CommandLineParserTest {
@Test
fun `test parse simple command with argument`() {
val result = parseCommandLineForCompletion("edit foo.txt")
assertNotNull(result)
val result = assertIs<ArgumentCompletionContext>(parseCommandLineForCompletion("edit foo.txt"))
assertEquals("edit", result.commandName)
assertEquals("foo.txt", result.argumentPrefix)
assertEquals(5, result.completionStart)
@@ -26,8 +25,7 @@ class CommandLineParserTest {
@Test
fun `test parse abbreviated command`() {
val result = parseCommandLineForCompletion("e foo.txt")
assertNotNull(result)
val result = assertIs<ArgumentCompletionContext>(parseCommandLineForCompletion("e foo.txt"))
assertEquals("e", result.commandName)
assertEquals("foo.txt", result.argumentPrefix)
assertEquals(2, result.completionStart)
@@ -35,8 +33,7 @@ class CommandLineParserTest {
@Test
fun `test parse command with path argument`() {
val result = parseCommandLineForCompletion("edit src/main/")
assertNotNull(result)
val result = assertIs<ArgumentCompletionContext>(parseCommandLineForCompletion("edit src/main/"))
assertEquals("edit", result.commandName)
assertEquals("src/main/", result.argumentPrefix)
assertEquals(5, result.completionStart)
@@ -44,8 +41,7 @@ class CommandLineParserTest {
@Test
fun `test parse command with home path`() {
val result = parseCommandLineForCompletion("e ~/.vim")
assertNotNull(result)
val result = assertIs<ArgumentCompletionContext>(parseCommandLineForCompletion("e ~/.vim"))
assertEquals("e", result.commandName)
assertEquals("~/.vim", result.argumentPrefix)
assertEquals(2, result.completionStart)
@@ -53,8 +49,7 @@ class CommandLineParserTest {
@Test
fun `test parse command with empty argument`() {
val result = parseCommandLineForCompletion("edit ")
assertNotNull(result)
val result = assertIs<ArgumentCompletionContext>(parseCommandLineForCompletion("edit "))
assertEquals("edit", result.commandName)
assertEquals("", result.argumentPrefix)
assertEquals(5, result.completionStart)
@@ -62,13 +57,79 @@ class CommandLineParserTest {
@Test
fun `test parse command with multiple spaces before argument`() {
val result = parseCommandLineForCompletion("edit foo")
assertNotNull(result)
val result = assertIs<ArgumentCompletionContext>(parseCommandLineForCompletion("edit foo"))
assertEquals("edit", result.commandName)
assertEquals("foo", result.argumentPrefix)
assertEquals(7, result.completionStart)
}
@Test
fun `test parse with leading whitespace`() {
val result = assertIs<ArgumentCompletionContext>(parseCommandLineForCompletion(" edit foo"))
assertEquals("edit", result.commandName)
assertEquals("foo", result.argumentPrefix)
assertEquals(7, result.completionStart)
}
@Test
fun `test parse bang command`() {
val result = assertIs<ArgumentCompletionContext>(parseCommandLineForCompletion("edit! foo.txt"))
assertEquals("edit!", result.commandName)
assertEquals("foo.txt", result.argumentPrefix)
assertEquals(6, result.completionStart)
}
@Test
fun `test parse write command`() {
val result = assertIs<ArgumentCompletionContext>(parseCommandLineForCompletion("w output.txt"))
assertEquals("w", result.commandName)
assertEquals("output.txt", result.argumentPrefix)
assertEquals(2, result.completionStart)
}
@Test
fun `test parse source command with path`() {
val result = assertIs<ArgumentCompletionContext>(parseCommandLineForCompletion("source ~/.ideavimrc"))
assertEquals("source", result.commandName)
assertEquals("~/.ideavimrc", result.argumentPrefix)
assertEquals(7, result.completionStart)
}
@Test
fun `test parse command name only returns command-name context`() {
val result = assertIs<CommandNameCompletionContext>(parseCommandLineForCompletion("edit"))
assertEquals("edit", result.prefix)
assertEquals(0, result.completionStart)
}
@Test
fun `test parse single letter abbreviation returns command-name context`() {
val result = assertIs<CommandNameCompletionContext>(parseCommandLineForCompletion("e"))
assertEquals("e", result.prefix)
assertEquals(0, result.completionStart)
}
@Test
fun `test parse partial abbreviation returns command-name context`() {
val result = assertIs<CommandNameCompletionContext>(parseCommandLineForCompletion("vs"))
assertEquals("vs", result.prefix)
assertEquals(0, result.completionStart)
}
@Test
fun `test parse command name with leading whitespace`() {
val result = assertIs<CommandNameCompletionContext>(parseCommandLineForCompletion(" vs"))
assertEquals("vs", result.prefix)
assertEquals(2, result.completionStart)
}
@Test
fun `test parse mixed case command name preserved`() {
val result = assertIs<CommandNameCompletionContext>(parseCommandLineForCompletion("tabN"))
assertEquals("tabN", result.prefix)
assertEquals(0, result.completionStart)
}
@Test
fun `test parse returns null for empty text`() {
assertNull(parseCommandLineForCompletion(""))
@@ -79,54 +140,19 @@ class CommandLineParserTest {
assertNull(parseCommandLineForCompletion(" "))
}
@Test
fun `test parse returns null for command without space`() {
assertNull(parseCommandLineForCompletion("edit"))
}
@Test
fun `test parse returns null for command abbreviation without space`() {
assertNull(parseCommandLineForCompletion("e"))
}
@Test
fun `test parse returns null for non-letter start`() {
assertNull(parseCommandLineForCompletion("123"))
}
@Test
fun `test parse with leading whitespace`() {
val result = parseCommandLineForCompletion(" edit foo")
assertNotNull(result)
assertEquals("edit", result.commandName)
assertEquals("foo", result.argumentPrefix)
assertEquals(7, result.completionStart)
fun `test parse returns null for bang form without space`() {
assertNull(parseCommandLineForCompletion("vs!"))
assertNull(parseCommandLineForCompletion("edit!"))
}
@Test
fun `test parse bang command`() {
val result = parseCommandLineForCompletion("edit! foo.txt")
assertNotNull(result)
assertEquals("edit!", result.commandName)
assertEquals("foo.txt", result.argumentPrefix)
assertEquals(6, result.completionStart)
}
@Test
fun `test parse write command`() {
val result = parseCommandLineForCompletion("w output.txt")
assertNotNull(result)
assertEquals("w", result.commandName)
assertEquals("output.txt", result.argumentPrefix)
assertEquals(2, result.completionStart)
}
@Test
fun `test parse source command with path`() {
val result = parseCommandLineForCompletion("source ~/.ideavimrc")
assertNotNull(result)
assertEquals("source", result.commandName)
assertEquals("~/.ideavimrc", result.argumentPrefix)
assertEquals(7, result.completionStart)
fun `test parse returns null for command followed by non-space chars`() {
assertNull(parseCommandLineForCompletion("foo123"))
}
}