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

Compare commits

..

34 Commits

Author SHA1 Message Date
818af5d0d5 Set plugin version to 58 2026-05-09 12:25:28 +02:00
0ea8927ce5 Disable IdeaVim in popup editors 2026-05-09 12:25:28 +02:00
210e9dfdad Fix AltGr not triggering Ctrl-Alt bindings on Windows 2026-05-09 12:25:28 +02:00
a2310422a2 Add 'isactionenabled' function 2026-05-09 12:25:28 +02:00
e9f3954f97 Fix Ex commands not working 2026-05-09 12:25:28 +02:00
f9e02f0329 Preserve visual mode after executing IDE action 2026-05-09 12:25:28 +02:00
bdc81daa91 Make g0/g^/g$ work with soft wraps 2026-05-09 12:25:28 +02:00
08184178e5 Make gj/gk jump over soft wraps 2026-05-09 12:25:28 +02:00
b0e5366a7e Make camelCase motions adjust based on direction of visual selection 2026-05-09 12:25:28 +02:00
c2af281233 Make search highlights temporary & use different color for nearby results 2026-05-09 12:25:28 +02:00
f065e20777 Do not switch to normal mode after inserting a live template 2026-05-09 12:25:27 +02:00
bb6a197350 Exit insert mode after refactoring 2026-05-09 12:25:27 +02:00
3da6e380a7 Add action to run last macro in all opened files 2026-05-09 12:25:27 +02:00
468e3d8db8 Stop macro execution after a failed search 2026-05-09 12:25:27 +02:00
3eba0556b2 Revert per-caret registers 2026-05-09 12:25:27 +02:00
911a0ba2e1 Apply scrolloff after executing native IDEA actions 2026-05-09 12:22:07 +02:00
30c015ea57 Automatically add unambiguous imports after running a macro 2026-05-09 12:22:07 +02:00
5c2c4d62b6 Fix(VIM-3986): Exception when pasting register contents containing new line 2026-05-09 12:22:07 +02:00
ff97da64fc Fix(VIM-3179): Respect virtual space below editor (imperfectly) 2026-05-09 12:22:07 +02:00
7f62e89b57 Fix(VIM-3178): Workaround to support "Jump to Source" action mapping 2026-05-09 12:22:07 +02:00
dbd2b08521 Update search register when using f/t 2026-05-09 12:22:07 +02:00
279b548d3e Add support for count for visual and line motion surround 2026-05-09 12:22:07 +02:00
4af261694a Fix vim-surround not working with multiple cursors
Fixes multiple cursors with vim-surround commands `cs, ds, S` (but not `ys`).
2026-05-09 12:22:07 +02:00
e569d9b5d4 Fix(VIM-696): Restore visual mode after undo/redo, and disable incompatible actions 2026-05-09 12:22:06 +02:00
412aa70c23 Respect count with <Action> mappings 2026-05-09 12:22:06 +02:00
fe9ea87b19 Change matchit plugin to use HTML patterns in unrecognized files 2026-05-09 12:22:06 +02:00
681b770ac3 Fix ex command panel causing Undock tool window to hide 2026-05-09 12:22:06 +02:00
42b12b532e Reset insert mode when switching active editor 2026-05-09 12:22:06 +02:00
ffec8dc3e0 Remove notifications about configuration options 2026-05-09 12:22:06 +02:00
e284a13e9e Remove AI 2026-05-09 12:22:03 +02:00
568de8fec3 Set custom plugin version 2026-05-09 12:21:58 +02:00
0a4bd278ec Revert "Fix(VIM-4108): Use default ANTLR output directory for Gradle 9+ compatibility"
This reverts commit a476583ea3.
2026-05-09 12:21:45 +02:00
8a38191fc9 Revert "Upgrade Gradle wrapper to 9.2.1"
This reverts commit 517bda93
2026-05-09 12:21:42 +02:00
1a51520428 Revert "Fix(VIM-4109): Configure test source sets for Gradle 9+ compatibility"
This reverts commit 5c0d9569d9.
2026-05-09 12:21:37 +02:00
77 changed files with 1545 additions and 1774 deletions

182
.github/workflows/runUiOctopusTests.yml vendored Normal file
View File

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

View File

@@ -33,47 +33,40 @@ object Compatibility : IdeaVimBuildType({
name = "Load Verifier" name = "Load Verifier"
scriptContent = """ scriptContent = """
mkdir verifier1 mkdir verifier1
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" 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"
""".trimIndent() """.trimIndent()
} }
script { script {
name = "Check" name = "Check"
scriptContent = """ scriptContent = """
# We use a custom build of plugin-verifier that resolves IdeaVim from the dev channel. # We use a custom build of verifier that downloads IdeaVim from dev channel
# The fork lives at https://github.com/AlexPl292/intellij-plugin-verifier — the patch is in # To create a custom build: Download plugin verifier repo, add an if that switches to dev channel for IdeaVim repo
# com.jetbrains.pluginverifier.repository.repositories.marketplace.MarketplaceRepository#getLastCompatibleVersionOfPlugin # At the moment it's com.jetbrains.pluginverifier.repository.repositories.marketplace.MarketplaceRepository#getLastCompatibleVersionOfPlugin
# (switches the marketplace channel to "dev" when pluginId is org.jetbrains.IdeaVim). # Build using gradlew :intellij-plugin-verifier:verifier-cli:shadowJar
# # Upload verifier-cli-dev-all.jar artifact to the repo in IdeaVim space repo
# 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 --version
java -jar verifier1/verifier-cli-ideavim.jar check-plugin '${'$'}org.jetbrains.IdeaVim-EasyMotion' [latest-IU] -team-city java -jar verifier1/verifier-cli-dev-all-2.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-dev-all-2.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 java -jar verifier1/verifier-cli-dev-all-2.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 # Outdated java -jar verifier/verifier-cli-dev-all.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-dev-all-2.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-dev-all-2.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-dev-all-2.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-dev-all-2.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-dev-all-2.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-dev-all-2.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-dev-all-2.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-dev-all-2.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-dev-all-2.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-dev-all-2.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-dev-all-2.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-dev-all-2.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-dev-all-2.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-dev-all-2.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-dev-all-2.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-dev-all-2.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-dev-all-2.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() """.trimIndent()
} }
} }

View File

@@ -13,7 +13,6 @@ import _Self.IdeaVimBuildType
import jetbrains.buildServer.configs.kotlin.v2019_2.CheckoutMode import jetbrains.buildServer.configs.kotlin.v2019_2.CheckoutMode
import jetbrains.buildServer.configs.kotlin.v2019_2.DslContext import jetbrains.buildServer.configs.kotlin.v2019_2.DslContext
import jetbrains.buildServer.configs.kotlin.v2019_2.buildSteps.script import jetbrains.buildServer.configs.kotlin.v2019_2.buildSteps.script
import jetbrains.buildServer.configs.kotlin.v2019_2.triggers.vcs
object SplitModeTests : IdeaVimBuildType({ object SplitModeTests : IdeaVimBuildType({
name = "Split mode tests" name = "Split mode tests"
@@ -28,6 +27,7 @@ object SplitModeTests : IdeaVimBuildType({
params { params {
param("env.ORG_GRADLE_PROJECT_downloadIdeaSources", "false") param("env.ORG_GRADLE_PROJECT_downloadIdeaSources", "false")
param("env.ORG_GRADLE_PROJECT_instrumentPluginCode", "false") param("env.ORG_GRADLE_PROJECT_instrumentPluginCode", "false")
param("env.DISPLAY", ":99")
} }
vcs { vcs {
@@ -39,16 +39,43 @@ object SplitModeTests : IdeaVimBuildType({
steps { steps {
script { script {
name = "Run split mode tests" name = "Start Xvfb and 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" 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()
} }
} }
triggers { // VCS trigger disabled until Xvfb is installed on the TeamCity agent
vcs { // triggers {
branchFilter = "+:<default>" // vcs {
} // branchFilter = "+:<default>"
} // }
// }
requirements { requirements {
// Use a larger agent for split-mode tests — they launch two full IDE instances // Use a larger agent for split-mode tests — they launch two full IDE instances

View File

@@ -0,0 +1,45 @@
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,9 +37,6 @@ 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 `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-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-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: ### 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 * [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
@@ -82,29 +79,8 @@ 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 `<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-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-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: ### 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> * [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 * [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 * [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,6 +209,7 @@ tasks {
// Note that this will run the plugin installed in the IDE specified in dependencies. To run in a different IDE, use // 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) // a custom task (see below)
runIde { runIde {
systemProperty("octopus.handler", System.getProperty("octopus.handler") ?: true)
systemProperty("idea.trust.all.projects", "true") systemProperty("idea.trust.all.projects", "true")
} }
@@ -228,16 +229,25 @@ tasks {
val runPycharm by intellijPlatformTesting.runIde.registering { val runPycharm by intellijPlatformTesting.runIde.registering {
type = IntelliJPlatformType.PyCharmProfessional type = IntelliJPlatformType.PyCharmProfessional
version = "2026.1" version = "2026.1"
task {
systemProperty("octopus.handler", System.getProperty("octopus.handler") ?: true)
}
} }
val runWebstorm by intellijPlatformTesting.runIde.registering { val runWebstorm by intellijPlatformTesting.runIde.registering {
type = IntelliJPlatformType.WebStorm type = IntelliJPlatformType.WebStorm
version = "2025.3.2" version = "2025.3.2"
task {
systemProperty("octopus.handler", System.getProperty("octopus.handler") ?: true)
}
} }
val runClion by intellijPlatformTesting.runIde.registering { val runClion by intellijPlatformTesting.runIde.registering {
type = IntelliJPlatformType.CLion type = IntelliJPlatformType.CLion
version = "2026.1" version = "2026.1"
task {
systemProperty("octopus.handler", System.getProperty("octopus.handler") ?: true)
}
} }
val runIdeForUiTests by intellijPlatformTesting.runIde.registering { val runIdeForUiTests by intellijPlatformTesting.runIde.registering {
@@ -250,6 +260,7 @@ tasks {
"-Djb.privacy.policy.text=<!--999.999-->", "-Djb.privacy.policy.text=<!--999.999-->",
"-Djb.consents.confirmation.enabled=false", "-Djb.consents.confirmation.enabled=false",
"-Dide.show.tips.on.startup.default.value=false", "-Dide.show.tips.on.startup.default.value=false",
"-Doctopus.handler=" + (System.getProperty("octopus.handler") ?: true),
) )
} }
} }
@@ -292,7 +303,7 @@ tasks {
} }
val runCLionSplitMode by intellijPlatformTesting.runIde.registering { val runCLionSplitMode by intellijPlatformTesting.runIde.registering {
type = IntelliJPlatformType.CLion type = IntelliJPlatformType.CLion
version = "2026.1" version = "2025.3.2"
splitMode = true splitMode = true
splitModeTarget = SplitModeAware.SplitModeTarget.BOTH splitModeTarget = SplitModeAware.SplitModeTarget.BOTH
@@ -474,9 +485,6 @@ 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>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-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-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> <br>
<b>Fixes:</b><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> * <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>
@@ -519,29 +527,8 @@ 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>&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-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-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> <br>
<b>Merged PRs:</b><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/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/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> * <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,34 +618,6 @@ https://github.com/kana/vim-textobj-entire/blob/master/doc/textobj-entire.txt
</details> </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> <details>
<summary><h2>Which-Key: Displays available keybindings in popup</h2></summary> <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. # Darwin, MinGW, and NonStop.
# #
# (3) This script is generated from the Groovy template # (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project. # within the Gradle project.
# #
# You can find Gradle at https://github.com/gradle/gradle/. # You can find Gradle at https://github.com/gradle/gradle/.

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

@@ -23,8 +23,8 @@
@rem @rem
@rem ########################################################################## @rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled @rem Set local scope for the variables with windows NT shell
setlocal EnableExtensions if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0 set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=. 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 Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2 echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1 goto fail
:findJavaFromJavaHome :findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=% 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 Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2 echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1 goto fail
:execute :execute
@rem Setup the command line @rem Setup the command line
@@ -73,10 +73,21 @@ echo location of your Java installation. 1>&2
@rem Execute Gradle @rem Execute Gradle
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
@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
:exitWithErrorLevel :end
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts @rem End local scope for the variables with windows NT shell
"%COMSPEC%" /c exit %ERRORLEVEL% 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

View File

@@ -1,89 +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.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,7 +8,6 @@
package com.maddyhome.idea.vim.group.comment package com.maddyhome.idea.vim.group.comment
import com.intellij.application.options.CodeStyle
import com.intellij.codeInsight.actions.MultiCaretCodeInsightActionHandler import com.intellij.codeInsight.actions.MultiCaretCodeInsightActionHandler
import com.intellij.codeInsight.generation.CommentByBlockCommentHandler import com.intellij.codeInsight.generation.CommentByBlockCommentHandler
import com.intellij.codeInsight.generation.CommentByLineCommentHandler import com.intellij.codeInsight.generation.CommentByLineCommentHandler
@@ -56,41 +55,22 @@ internal class CommentaryRemoteApiImpl : CommentaryRemoteApi {
val project = editor.project ?: return val project = editor.project ?: return
val psiFile = PsiDocumentManager.getInstance(project).getPsiFile(editor.document) ?: return val psiFile = PsiDocumentManager.getInstance(project).getPsiFile(editor.document) ?: return
val invokeHandler = { CommandProcessor.getInstance().executeCommand(project, {
CommandProcessor.getInstance().executeCommand(project, { ApplicationManager.getApplication().runWriteAction {
ApplicationManager.getApplication().runWriteAction { val caret = editor.caretModel.primaryCaret
val caret = editor.caretModel.primaryCaret caret.setSelection(startOffset, endOffset)
caret.setSelection(startOffset, endOffset) try {
try { val handler = pickHandler(psiFile, lineWise)
val handler = pickHandler(psiFile, lineWise) handler.invoke(project, editor, caret, psiFile)
handler.invoke(project, editor, caret, psiFile) handler.postInvoke()
handler.postInvoke() } finally {
} finally { caret.removeSelection()
caret.removeSelection() if (caretOffset >= 0) {
if (caretOffset >= 0) { caret.moveToOffset(caretOffset)
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()
} }
} else { }, "Commentary", null)
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 { private fun pickHandler(psiFile: PsiFile, lineWise: Boolean): MultiCaretCodeInsightActionHandler {

View File

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

View File

@@ -10,6 +10,12 @@
<dependencies> <dependencies>
<plugin id="org.jetbrains.plugins.clion.radler"/> <plugin id="org.jetbrains.plugins.clion.radler"/>
</dependencies> </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"> <extensions defaultExtensionNs="IdeaVIM">
<clionNovaProvider implementation="com.maddyhome.idea.vim.ide.ClionNovaProviderImpl"/> <clionNovaProvider implementation="com.maddyhome.idea.vim.ide.ClionNovaProviderImpl"/>
</extensions> </extensions>

View File

@@ -1,29 +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.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,6 +36,10 @@
topic="com.intellij.ide.ui.LafManagerListener"/> topic="com.intellij.ide.ui.LafManagerListener"/>
<listener class="com.maddyhome.idea.vim.extension.highlightedyank.HighlightColorResetter" <listener class="com.maddyhome.idea.vim.extension.highlightedyank.HighlightColorResetter"
topic="com.intellij.ide.ui.LafManagerListener"/> 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" <listener class="com.maddyhome.idea.vim.listener.VimAppActivationListener"
topic="com.intellij.openapi.application.ApplicationActivationListener"/> topic="com.intellij.openapi.application.ApplicationActivationListener"/>
</applicationListeners> </applicationListeners>
@@ -173,12 +177,6 @@
<platform.rpc.projectRemoteTopicListener <platform.rpc.projectRemoteTopicListener
implementation="com.maddyhome.idea.vim.group.JumpRemoteTopicListener"/> 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" <applicationService serviceImplementation="com.maddyhome.idea.vim.group.IjFileGroup"
serviceInterface="com.maddyhome.idea.vim.api.VimFile"/> serviceInterface="com.maddyhome.idea.vim.api.VimFile"/>
@@ -227,6 +225,11 @@
implementation="com.maddyhome.idea.vim.ui.widgets.macro.MacroWidgetFactory" implementation="com.maddyhome.idea.vim.ui.widgets.macro.MacroWidgetFactory"
order="first, after IdeaVimShowCmd"/> 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"/> <editorFloatingToolbarProvider implementation="com.maddyhome.idea.vim.ui.ReloadFloatingToolbar"/>
<actionPromoter implementation="com.maddyhome.idea.vim.key.VimActionsPromoter" order="last"/> <actionPromoter implementation="com.maddyhome.idea.vim.key.VimActionsPromoter" order="last"/>
@@ -246,6 +249,35 @@
<statistics.applicationUsagesCollector implementation="com.maddyhome.idea.vim.statistic.WidgetState"/> <statistics.applicationUsagesCollector implementation="com.maddyhome.idea.vim.statistic.WidgetState"/>
<statistics.counterUsagesCollector implementationClass="com.maddyhome.idea.vim.statistic.ActionTracker"/> <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 <editorFactoryDocumentListener
implementation="com.maddyhome.idea.vim.listener.VimListenerManager$VimDocumentListener"/> implementation="com.maddyhome.idea.vim.listener.VimListenerManager$VimDocumentListener"/>
@@ -432,8 +464,7 @@
<actions> <actions>
<action class="com.maddyhome.idea.vim.extension.hints.ToggleHintsAction" text="Toggle Hints"> <action class="com.maddyhome.idea.vim.extension.hints.ToggleHintsAction" text="Toggle Hints">
<keyboard-shortcut keymap="$default" first-keystroke="ctrl shift BACK_SLASH"/> <keyboard-shortcut keymap="$default" first-keystroke="ctrl BACK_SLASH"/>
<keyboard-shortcut keymap="Mac OS X 10.5+" first-keystroke="ctrl meta BACK_SLASH"/>
</action> </action>
</actions> </actions>
</idea-plugin> </idea-plugin>

View File

@@ -14,6 +14,12 @@
<listener class="com.maddyhome.idea.vim.listener.RiderActionListener" <listener class="com.maddyhome.idea.vim.listener.RiderActionListener"
topic="com.intellij.openapi.actionSystem.ex.AnActionListener"/> topic="com.intellij.openapi.actionSystem.ex.AnActionListener"/>
</projectListeners> </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"> <extensions defaultExtensionNs="IdeaVIM">
<riderProvider implementation="com.maddyhome.idea.vim.ide.RiderProviderImpl"/> <riderProvider implementation="com.maddyhome.idea.vim.ide.RiderProviderImpl"/>
</extensions> </extensions>

View File

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

View File

@@ -0,0 +1,31 @@
/**
* 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 { dependencies {
compileOnly("org.jetbrains.kotlin:kotlin-stdlib:2.3.21") compileOnly("org.jetbrains.kotlin:kotlin-stdlib:2.3.10")
testImplementation("org.junit.jupiter:junit-jupiter:6.0.3") testImplementation("org.junit.jupiter:junit-jupiter:6.0.3")
testRuntimeOnly("org.junit.platform:junit-platform-launcher") testRuntimeOnly("org.junit.platform:junit-platform-launcher")

View File

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

View File

@@ -32,6 +32,8 @@ import com.maddyhome.idea.vim.api.globalOptions
import com.maddyhome.idea.vim.api.injector import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.group.IjOptionConstants import com.maddyhome.idea.vim.group.IjOptionConstants
import com.maddyhome.idea.vim.group.IjOptions 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.EditorHelper
import com.maddyhome.idea.vim.helper.HandlerInjector import com.maddyhome.idea.vim.helper.HandlerInjector
import com.maddyhome.idea.vim.helper.inNormalMode import com.maddyhome.idea.vim.helper.inNormalMode
@@ -90,8 +92,8 @@ class VimShortcutKeyAction : AnAction(), DumbAware/*, LightEditCompatible*/ {
// Control-flow exceptions (like ProcessCanceledException) should never be logged and should be rethrown // Control-flow exceptions (like ProcessCanceledException) should never be logged and should be rethrown
// See {@link com.intellij.openapi.diagnostic.Logger.checkException} // See {@link com.intellij.openapi.diagnostic.Logger.checkException}
throw e throw e
} catch (e: Exception) { } catch (throwable: Throwable) {
LOG.error(e) LOG.error(throwable)
} }
} }
} }
@@ -117,6 +119,15 @@ class VimShortcutKeyAction : AnAction(), DumbAware/*, LightEditCompatible*/ {
if (VimPlugin.isNotEnabled()) return ActionEnableStatus.no("IdeaVim is disabled", LogLevel.DEBUG) if (VimPlugin.isNotEnabled()) return ActionEnableStatus.no("IdeaVim is disabled", LogLevel.DEBUG)
val editor = getEditor(e) ?: return ActionEnableStatus.no("Can't get Editor", 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")) { 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 // 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. // PlatformDataKeys.EDITOR in the data context does not mean that the current focused component is editor.

View File

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

View File

@@ -1,63 +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.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,9 +14,11 @@ import com.intellij.openapi.command.CommandProcessor
import com.intellij.openapi.command.UndoConfirmationPolicy import com.intellij.openapi.command.UndoConfirmationPolicy
import com.intellij.openapi.diagnostic.logger import com.intellij.openapi.diagnostic.logger
import com.intellij.openapi.editor.actionSystem.TypedActionHandler 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.EditorMouseEvent
import com.intellij.openapi.editor.event.EditorMouseListener import com.intellij.openapi.editor.event.EditorMouseListener
import com.intellij.openapi.editor.impl.editorId import com.intellij.openapi.editor.impl.editorId
import com.intellij.openapi.util.UserDataHolder
import com.intellij.psi.codeStyle.CodeStyleManager import com.intellij.psi.codeStyle.CodeStyleManager
import com.intellij.psi.util.PsiUtilBase import com.intellij.psi.util.PsiUtilBase
import com.maddyhome.idea.vim.EventFacade import com.maddyhome.idea.vim.EventFacade
@@ -29,6 +31,7 @@ import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.common.TextRange import com.maddyhome.idea.vim.common.TextRange
import com.maddyhome.idea.vim.group.change.ChangeRemoteApi import com.maddyhome.idea.vim.group.change.ChangeRemoteApi
import com.maddyhome.idea.vim.group.format.FormatRemoteApi 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.CodeWrapper
import com.maddyhome.idea.vim.helper.CommentLeaderParser import com.maddyhome.idea.vim.helper.CommentLeaderParser
import com.maddyhome.idea.vim.helper.inInsertMode import com.maddyhome.idea.vim.helper.inInsertMode
@@ -96,6 +99,35 @@ class ChangeGroup : VimChangeGroupBase() {
injector.scroll.scrollCaretIntoView(vimEditor) 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) { override fun processBackspace(editor: VimEditor, context: ExecutionContext) {
injector.actionExecutor.executeAction(editor, name = IdeActions.ACTION_EDITOR_BACKSPACE, context = context) injector.actionExecutor.executeAction(editor, name = IdeActions.ACTION_EDITOR_BACKSPACE, context = context)
injector.scroll.scrollCaretIntoView(editor) injector.scroll.scrollCaretIntoView(editor)

View File

@@ -326,7 +326,11 @@ public class KeyGroup extends VimKeyGroupBase implements PersistentStateComponen
private void registerRequiredShortcut(@NotNull List<KeyStroke> keys, MappingOwner owner) { private void registerRequiredShortcut(@NotNull List<KeyStroke> keys, MappingOwner owner) {
for (KeyStroke key : keys) { for (KeyStroke key : keys) {
if (key.getKeyChar() == KeyEvent.CHAR_UNDEFINED) { if (key.getKeyChar() == KeyEvent.CHAR_UNDEFINED) {
getRequiredShortcutKeys().add(new RequiredShortcut(key, owner)); 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));
}
} }
} }
} }

View File

@@ -8,7 +8,6 @@
package com.maddyhome.idea.vim.group package com.maddyhome.idea.vim.group
import com.intellij.openapi.actionSystem.DataContext import com.intellij.openapi.actionSystem.DataContext
import com.intellij.openapi.components.service
import com.intellij.openapi.editor.Caret import com.intellij.openapi.editor.Caret
import com.intellij.openapi.editor.Editor import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.VisualPosition import com.intellij.openapi.editor.VisualPosition
@@ -16,9 +15,7 @@ import com.intellij.openapi.fileEditor.FileEditorManagerEvent
import com.intellij.openapi.fileEditor.TextEditor import com.intellij.openapi.fileEditor.TextEditor
import com.intellij.openapi.fileEditor.ex.FileEditorManagerEx import com.intellij.openapi.fileEditor.ex.FileEditorManagerEx
import com.intellij.openapi.fileEditor.impl.EditorWindow import com.intellij.openapi.fileEditor.impl.EditorWindow
import com.intellij.platform.project.projectId
import com.maddyhome.idea.vim.KeyHandler 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.ExecutionContext
import com.maddyhome.idea.vim.api.ImmutableVimCaret import com.maddyhome.idea.vim.api.ImmutableVimCaret
import com.maddyhome.idea.vim.api.VimChangeGroupBase import com.maddyhome.idea.vim.api.VimChangeGroupBase
@@ -29,14 +26,12 @@ import com.maddyhome.idea.vim.api.getLeadingCharacterOffset
import com.maddyhome.idea.vim.api.getVisualLineCount import com.maddyhome.idea.vim.api.getVisualLineCount
import com.maddyhome.idea.vim.api.injector import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.api.lineLength 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.normalizeVisualLine
import com.maddyhome.idea.vim.api.visualLineToBufferLine import com.maddyhome.idea.vim.api.visualLineToBufferLine
import com.maddyhome.idea.vim.command.Argument import com.maddyhome.idea.vim.command.Argument
import com.maddyhome.idea.vim.command.MotionType import com.maddyhome.idea.vim.command.MotionType
import com.maddyhome.idea.vim.command.OperatorArguments import com.maddyhome.idea.vim.command.OperatorArguments
import com.maddyhome.idea.vim.common.TextRange 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.ExternalActionHandler
import com.maddyhome.idea.vim.handler.Motion import com.maddyhome.idea.vim.handler.Motion
import com.maddyhome.idea.vim.handler.Motion.AbsoluteOffset import com.maddyhome.idea.vim.handler.Motion.AbsoluteOffset
@@ -62,39 +57,6 @@ import kotlin.math.min
*/ */
class MotionGroup : VimMotionGroupBase() { 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( override fun moveCaretToFirstDisplayLine(
editor: VimEditor, editor: VimEditor,
caret: ImmutableVimCaret, caret: ImmutableVimCaret,

View File

@@ -33,6 +33,7 @@ import com.intellij.openapi.util.SystemInfo
import com.maddyhome.idea.vim.VimPlugin import com.maddyhome.idea.vim.VimPlugin
import com.maddyhome.idea.vim.api.VimEditor import com.maddyhome.idea.vim.api.VimEditor
import com.maddyhome.idea.vim.api.injector 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.helper.MessageHelper
import com.maddyhome.idea.vim.icons.VimIcons import com.maddyhome.idea.vim.icons.VimIcons
import com.maddyhome.idea.vim.key.ShortcutOwner import com.maddyhome.idea.vim.key.ShortcutOwner
@@ -160,6 +161,78 @@ internal class NotificationService(private val project: Project?) : VimNotificat
ActionIdNotifier.notifyActionId(id, project, candidates, intentionName) 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 { object ActionIdNotifier {
private var notification: Notification? = null private var notification: Notification? = null

View File

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

View File

@@ -1,26 +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.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

@@ -1,85 +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.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

@@ -0,0 +1,116 @@
/*
* 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

@@ -0,0 +1,72 @@
/*
* 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

@@ -0,0 +1,153 @@
/*
* 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

@@ -0,0 +1,374 @@
/*
* 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,7 +172,6 @@ class CaretVisualAttributesListener : IsReplaceCharListener, ModeChangeListener,
@RequiresEdt @RequiresEdt
private fun updateCaretsVisual(editor: VimEditor) { private fun updateCaretsVisual(editor: VimEditor) {
val ijEditor = (editor as IjVimEditor).editor val ijEditor = (editor as IjVimEditor).editor
if (ijEditor.isDisposed) return
ijEditor.updateCaretsVisualAttributes() ijEditor.updateCaretsVisualAttributes()
ijEditor.updateCaretsVisualPosition() ijEditor.updateCaretsVisualPosition()
} }

View File

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

View File

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

View File

@@ -92,6 +92,8 @@ import com.maddyhome.idea.vim.group.ScrollOptionsChangeListener
import com.maddyhome.idea.vim.group.visual.IdeaSelectionControl import com.maddyhome.idea.vim.group.visual.IdeaSelectionControl
import com.maddyhome.idea.vim.group.visual.VimVisualTimer import com.maddyhome.idea.vim.group.visual.VimVisualTimer
import com.maddyhome.idea.vim.group.visual.moveCaretOneCharLeftFromSelectionEnd 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.CaretVisualAttributesListener
import com.maddyhome.idea.vim.helper.GuicursorChangeListener import com.maddyhome.idea.vim.helper.GuicursorChangeListener
import com.maddyhome.idea.vim.helper.StrictMode import com.maddyhome.idea.vim.helper.StrictMode
@@ -168,6 +170,8 @@ object VimListenerManager {
SlowOperations.knownIssue("VIM-3648, VIM-3649").use { SlowOperations.knownIssue("VIM-3648, VIM-3649").use {
EditorListeners.addAll() EditorListeners.addAll()
} }
check(correctorRequester.tryEmit(Unit))
check(keyCheckRequests.tryEmit(Unit))
val caretVisualAttributesListener = CaretVisualAttributesListener() val caretVisualAttributesListener = CaretVisualAttributesListener()
injector.listenersNotifier.myEditorListeners.add(caretVisualAttributesListener) injector.listenersNotifier.myEditorListeners.add(caretVisualAttributesListener)
@@ -200,6 +204,8 @@ object VimListenerManager {
GlobalListeners.disable() GlobalListeners.disable()
EditorListeners.removeAll() EditorListeners.removeAll()
injector.listenersNotifier.reset() injector.listenersNotifier.reset()
check(correctorRequester.tryEmit(Unit))
} }
object GlobalListeners { object GlobalListeners {
@@ -236,18 +242,6 @@ object VimListenerManager {
busConnection.subscribe(FileOpenedSyncListener.TOPIC, VimEditorFactoryListener) busConnection.subscribe(FileOpenedSyncListener.TOPIC, VimEditorFactoryListener)
busConnection.subscribe(VirtualFileManager.VFS_CHANGES, BufNewFileTracker) busConnection.subscribe(VirtualFileManager.VFS_CHANGES, BufNewFileTracker)
busConnection.subscribe(FileDocumentManagerListener.TOPIC, BufWriteListener) 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() { fun disable() {
@@ -387,13 +381,11 @@ object VimListenerManager {
*/ */
private object VimFocusListener : FocusChangeListener { private object VimFocusListener : FocusChangeListener {
override fun focusGained(editor: Editor) { override fun focusGained(editor: Editor) {
if (editor.isDisposed) return
if (vimDisabled(editor)) return if (vimDisabled(editor)) return
injector.listenersNotifier.notifyEditorFocusGained(editor.vim) injector.listenersNotifier.notifyEditorFocusGained(editor.vim)
} }
override fun focusLost(editor: Editor) { override fun focusLost(editor: Editor) {
if (editor.isDisposed) return
if (vimDisabled(editor)) return if (vimDisabled(editor)) return
injector.listenersNotifier.notifyEditorFocusLost(editor.vim) injector.listenersNotifier.notifyEditorFocusLost(editor.vim)
} }

View File

@@ -12,9 +12,12 @@ import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.application.ModalityState import com.intellij.openapi.application.ModalityState
import com.intellij.openapi.util.Computable import com.intellij.openapi.util.Computable
import com.intellij.util.ExceptionUtil import com.intellij.util.ExceptionUtil
import com.intellij.util.PlatformUtils
import com.maddyhome.idea.vim.api.VimApplicationBase import com.maddyhome.idea.vim.api.VimApplicationBase
import com.maddyhome.idea.vim.api.VimEditor import com.maddyhome.idea.vim.api.VimEditor
import com.maddyhome.idea.vim.diagnostic.vimLogger 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.Component
import java.awt.Toolkit import java.awt.Toolkit
import java.awt.Window import java.awt.Window
@@ -76,6 +79,14 @@ internal class IjVimApplication : VimApplicationBase() {
com.maddyhome.idea.vim.helper.runAfterGotFocus(runnable) 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 { private fun createKeyEvent(stroke: KeyStroke, component: Component): KeyEvent {
return KeyEvent( return KeyEvent(
component, component,

View File

@@ -8,14 +8,68 @@
package org.jetbrains.plugins.ideavim.action.change.insert 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 com.maddyhome.idea.vim.state.mode.Mode
import org.jetbrains.plugins.ideavim.SkipNeovimReason import org.jetbrains.plugins.ideavim.SkipNeovimReason
import org.jetbrains.plugins.ideavim.TestWithoutNeovim import org.jetbrains.plugins.ideavim.TestWithoutNeovim
import org.jetbrains.plugins.ideavim.VimTestCase import org.jetbrains.plugins.ideavim.VimTestCase
import org.junit.jupiter.api.Test import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.RepeatedTest
import org.junit.jupiter.api.RepetitionInfo
class InsertEnterActionTest : VimTestCase() { class InsertEnterActionTest : VimTestCase() {
@Test @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)
fun `test insert enter`() { fun `test insert enter`() {
val before = """Lorem ipsum dolor sit amet, val before = """Lorem ipsum dolor sit amet,
|${c}consectetur adipiscing elit |${c}consectetur adipiscing elit
@@ -31,7 +85,7 @@ class InsertEnterActionTest : VimTestCase() {
doTest(listOf("i", "<Enter>"), before, after, Mode.INSERT) doTest(listOf("i", "<Enter>"), before, after, Mode.INSERT)
} }
@Test @RepeatedTest(3)
fun `test insert enter multicaret`() { fun `test insert enter multicaret`() {
val before = """Lorem ipsum dolor sit amet, val before = """Lorem ipsum dolor sit amet,
|${c}consectetur adipiscing elit |${c}consectetur adipiscing elit
@@ -49,7 +103,7 @@ class InsertEnterActionTest : VimTestCase() {
} }
@TestWithoutNeovim(SkipNeovimReason.CTRL_CODES) @TestWithoutNeovim(SkipNeovimReason.CTRL_CODES)
@Test @RepeatedTest(3)
fun `test insert enter with C-M`() { fun `test insert enter with C-M`() {
val before = """Lorem ipsum dolor sit amet, val before = """Lorem ipsum dolor sit amet,
|${c}consectetur adipiscing elit |${c}consectetur adipiscing elit
@@ -66,7 +120,7 @@ class InsertEnterActionTest : VimTestCase() {
} }
@TestWithoutNeovim(SkipNeovimReason.CTRL_CODES) @TestWithoutNeovim(SkipNeovimReason.CTRL_CODES)
@Test @RepeatedTest(3)
fun `test insert enter with C-J`() { fun `test insert enter with C-J`() {
val before = """Lorem ipsum dolor sit amet, val before = """Lorem ipsum dolor sit amet,
|${c}consectetur adipiscing elit |${c}consectetur adipiscing elit
@@ -83,7 +137,7 @@ class InsertEnterActionTest : VimTestCase() {
} }
@TestWithoutNeovim(SkipNeovimReason.OPTION) @TestWithoutNeovim(SkipNeovimReason.OPTION)
@Test @RepeatedTest(3)
fun `test insert enter scrolls view up at scrolloff`() { fun `test insert enter scrolls view up at scrolloff`() {
configureByLines(50, "Lorem ipsum dolor sit amet,") configureByLines(50, "Lorem ipsum dolor sit amet,")
enterCommand("set scrolloff=10") enterCommand("set scrolloff=10")
@@ -93,3 +147,29 @@ class InsertEnterActionTest : VimTestCase() {
assertVisibleArea(6, 40) 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

@@ -1,325 +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 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,29 +321,4 @@ class AutoCmdTest : VimTestCase() {
typeText(injector.parser.parseKeys("i")) typeText(injector.parser.parseKeys("i"))
assertExOutput("all\npython") 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,25 +134,6 @@ class CommandLineCompletionTest : VimExTestCase() {
assertExText("set foo") 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 @Test
fun `test typing after completion invalidates session`() { fun `test typing after completion invalidates session`() {
typeText(":edit $tempPath/b<Tab>") typeText(":edit $tempPath/b<Tab>")
@@ -447,95 +428,4 @@ class CommandLineCompletionTest : VimExTestCase() {
typeText("<Left>") typeText("<Left>")
assertExText("edit $tempPath/subdir/notes.md") 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-2026 The IdeaVim authors * Copyright 2003-2023 The IdeaVim authors
* *
* Use of this source code is governed by an MIT-style * Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at * license that can be found in the LICENSE.txt file or at
@@ -1479,64 +1479,4 @@ class SubstituteCommandTest : VimTestCase() {
enterCommand("set nooldundo") 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

@@ -1,82 +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 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-2026 The IdeaVim authors * Copyright 2003-2025 The IdeaVim authors
* *
* Use of this source code is governed by an MIT-style * Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at * license that can be found in the LICENSE.txt file or at
@@ -8,19 +8,10 @@
package org.jetbrains.plugins.ideavim.extension.nerdtree package org.jetbrains.plugins.ideavim.extension.nerdtree
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.options.advanced.AdvancedSettings 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.jetbrains.plugins.ideavim.VimTestCase
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertDoesNotThrow 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() { class NerdTreeTest : VimTestCase() {
@Test @Test
@@ -29,79 +20,4 @@ class NerdTreeTest : VimTestCase() {
AdvancedSettings.getBoolean("ide.tree.collapse.recursively") // will throw if the id is invalid 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-2026 The IdeaVim authors * Copyright 2003-2024 The IdeaVim authors
* *
* Use of this source code is governed by an MIT-style * Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at * license that can be found in the LICENSE.txt file or at
@@ -8,14 +8,12 @@
package org.jetbrains.plugins.ideavim.group.search 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.Mode
import com.maddyhome.idea.vim.state.mode.SelectionType import com.maddyhome.idea.vim.state.mode.SelectionType
import org.jetbrains.plugins.ideavim.SkipNeovimReason import org.jetbrains.plugins.ideavim.SkipNeovimReason
import org.jetbrains.plugins.ideavim.TestWithoutNeovim import org.jetbrains.plugins.ideavim.TestWithoutNeovim
import org.jetbrains.plugins.ideavim.VimTestCase import org.jetbrains.plugins.ideavim.VimTestCase
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import kotlin.test.assertFalse
@Suppress("SpellCheckingInspection") @Suppress("SpellCheckingInspection")
class IncsearchTests : VimTestCase() { class IncsearchTests : VimTestCase() {
@@ -1019,36 +1017,4 @@ class IncsearchTests : VimTestCase() {
""".trimMargin() """.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,14 +68,15 @@ import com.maddyhome.idea.vim.group.EffectiveIjOptions
import com.maddyhome.idea.vim.group.GlobalIjOptions import com.maddyhome.idea.vim.group.GlobalIjOptions
import com.maddyhome.idea.vim.group.IjOptions import com.maddyhome.idea.vim.group.IjOptions
import com.maddyhome.idea.vim.group.visual.VimVisualTimer.swingTimer 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.EditorHelper
import com.maddyhome.idea.vim.helper.TestInputModel import com.maddyhome.idea.vim.helper.TestInputModel
import com.maddyhome.idea.vim.helper.getGuiCursorMode 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.MappingOwner
import com.maddyhome.idea.vim.key.ToKeysMappingInfo import com.maddyhome.idea.vim.key.ToKeysMappingInfo
import com.maddyhome.idea.vim.listener.SelectionVimListenerSuppressor import com.maddyhome.idea.vim.listener.SelectionVimListenerSuppressor
import com.maddyhome.idea.vim.listener.VimListenerManager 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.globalIjOptions
import com.maddyhome.idea.vim.newapi.ijOptions import com.maddyhome.idea.vim.newapi.ijOptions
import com.maddyhome.idea.vim.newapi.vim import com.maddyhome.idea.vim.newapi.vim
@@ -228,9 +229,6 @@ abstract class VimTestCase(private val defaultEditorText: String? = null) {
(VimPlugin.getSearch() as VimSearchGroupBase).resetState() (VimPlugin.getSearch() as VimSearchGroupBase).resetState()
injector.markService.resetAllMarks() injector.markService.resetAllMarks()
injector.jumpService.resetJumps() injector.jumpService.resetJumps()
ApplicationManager.getApplication()
.getService(com.maddyhome.idea.vim.group.changelist.ChangeListService::class.java)
?.reset()
injector.historyGroup.resetHistory() injector.historyGroup.resetHistory()
VimPlugin.getChange().resetRepeat() VimPlugin.getChange().resetRepeat()
VimPlugin.getKey().savedShortcutConflicts.clear() VimPlugin.getKey().savedShortcutConflicts.clear()
@@ -1017,14 +1015,7 @@ abstract class VimTestCase(private val defaultEditorText: String? = null) {
ActionManager.getInstance(), ActionManager.getInstance(),
0, 0,
) )
if (!VimPlugin.isEnabled()) { if (ActionUtil.lastUpdateAndCheckDumb(VimShortcutKeyAction.instance, e, true)) {
// 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) ActionUtil.performActionDumbAwareWithCallbacks(VimShortcutKeyAction.instance, e)
} }
} }
@@ -1036,6 +1027,14 @@ abstract class VimTestCase(private val defaultEditorText: String? = null) {
private fun KeyStroke.getChar(editor: Editor): CharType { private fun KeyStroke.getChar(editor: Editor): CharType {
if (keyChar != KeyEvent.CHAR_UNDEFINED) return CharType.CharDetected(keyChar) 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 return CharType.UNDEFINED
} }

View File

@@ -1,11 +1,3 @@
/*
* 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 import org.jetbrains.intellij.platform.gradle.TestFrameworkType
/* /*
@@ -54,7 +46,7 @@ dependencies {
create(ideaType, ideaVersion) { this.useInstaller = useInstaller } create(ideaType, ideaVersion) { this.useInstaller = useInstaller }
testFramework(TestFrameworkType.Platform) testFramework(TestFrameworkType.Platform)
testFramework(TestFrameworkType.JUnit5) testFramework(TestFrameworkType.JUnit5)
bundledPlugins("com.intellij.java", "org.jetbrains.plugins.yaml", "org.jetbrains.plugins.textmate") bundledPlugins("com.intellij.java", "org.jetbrains.plugins.yaml")
} }
} }

View File

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

View File

@@ -1,5 +1,5 @@
/* /*
* Copyright 2003-2026 The IdeaVim authors * Copyright 2003-2024 The IdeaVim authors
* *
* Use of this source code is governed by an MIT-style * Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at * license that can be found in the LICENSE.txt file or at
@@ -94,28 +94,6 @@ 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 @Test
fun testLineCommentDownPreservesAbsoluteCaretLocation() { fun testLineCommentDownPreservesAbsoluteCaretLocation() {
doTest( doTest(

View File

@@ -1,82 +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.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,7 +30,18 @@ class InsertEnterAction : VimActionHandler.SingleExecution() {
cmd: Command, cmd: Command,
operatorArguments: OperatorArguments, operatorArguments: OperatorArguments,
): Boolean { ): Boolean {
injector.changeGroup.processEnter(editor, context) 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.scroll.scrollCaretIntoView(editor) injector.scroll.scrollCaretIntoView(editor)
return true return true
} }

View File

@@ -19,7 +19,6 @@ import com.maddyhome.idea.vim.command.OperatorArguments
import com.maddyhome.idea.vim.group.visual.VimSelection import com.maddyhome.idea.vim.group.visual.VimSelection
import com.maddyhome.idea.vim.handler.VisualOperatorActionHandler import com.maddyhome.idea.vim.handler.VisualOperatorActionHandler
import com.maddyhome.idea.vim.put.PutData import com.maddyhome.idea.vim.put.PutData
import com.maddyhome.idea.vim.register.Register
/** /**
* @author vlan * @author vlan
@@ -74,26 +73,6 @@ sealed class PutVisualTextBaseAction(
val visualSelection = selection?.let { PutData.VisualSelection(mapOf(caret to it), it.type) } val visualSelection = selection?.let { PutData.VisualSelection(mapOf(caret to it), it.type) }
return PutData(textData, visualSelection, count, insertTextBeforeCaret, indent, caretAfterInsertedText) 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]) @CommandOrMotion(keys = ["P"], modes = [Mode.VISUAL])

View File

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

View File

@@ -9,69 +9,30 @@
package com.maddyhome.idea.vim.action.ex package com.maddyhome.idea.vim.action.ex
/** /**
* Parsed completion context for a partially-typed ex command line. Used for Tab completion * Lightweight parser for extracting the command name and argument prefix
* context detection. * from 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 sealed interface CommandLineCompletionContext { internal data class ParsedCommandLine(
val completionStart: Int
}
internal data class CommandNameCompletionContext(
val prefix: String,
override val completionStart: Int,
) : CommandLineCompletionContext
internal data class ArgumentCompletionContext(
val commandName: String, val commandName: String,
val argumentPrefix: String, val argumentPrefix: String,
override val completionStart: Int, val completionStart: Int,
) : CommandLineCompletionContext )
internal fun parseCommandLineForCompletion(text: String): CommandLineCompletionContext? { internal fun parseCommandLineForCompletion(text: String): ParsedCommandLine? {
val trimmed = text.trimStart() val trimmed = text.trimStart()
if (trimmed.isEmpty()) return null if (trimmed.isEmpty()) return null
val commandName = extractCommandName(trimmed) ?: return null val commandName = extractCommandName(trimmed) ?: return null
val leadingSpacesLength = text.length - trimmed.length val commandEnd = commandName.length
return if (isCommandNameOnly(trimmed, commandName)) { if (!hasArgumentSeparator(trimmed, commandEnd)) return null
parseCommandNameContext(commandName, leadingSpacesLength)
} else {
parseArgumentContext(trimmed, commandName, leadingSpacesLength)
}
}
private fun isCommandNameOnly(trimmed: String, commandName: String): Boolean = val argStart = skipSpaces(trimmed, commandEnd)
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) val argPrefix = trimmed.substring(argStart)
return ArgumentCompletionContext(commandName, argPrefix, leadingSpacesLength + argStart) val leadingSpaces = text.length - trimmed.length
}
/** return ParsedCommandLine(commandName, argPrefix, leadingSpaces + 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? { private fun extractCommandName(text: String): String? {
var end = 0 var end = 0

View File

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

View File

@@ -1,48 +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.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,7 +31,18 @@ class SelectEnterAction : VimActionHandler.SingleExecution() {
cmd: Command, cmd: Command,
operatorArguments: OperatorArguments, operatorArguments: OperatorArguments,
): Boolean { ): Boolean {
injector.changeGroup.processEnter(editor, context) 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)
}
return true return true
} }
} }

View File

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

View File

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

View File

@@ -169,7 +169,13 @@ abstract class VimKeyGroupBase : VimKeyGroup {
val oldSize = requiredShortcutKeys.size val oldSize = requiredShortcutKeys.size
for (key in fromKeys) { for (key in fromKeys) {
if (key.keyChar == KeyEvent.CHAR_UNDEFINED) { if (key.keyChar == KeyEvent.CHAR_UNDEFINED) {
requiredShortcutKeys.add(RequiredShortcut(key, owner)) 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))
}
} }
} }
if (requiredShortcutKeys.size != oldSize) { if (requiredShortcutKeys.size != oldSize) {

View File

@@ -1,5 +1,5 @@
/* /*
* Copyright 2003-2026 The IdeaVim authors * Copyright 2003-2023 The IdeaVim authors
* *
* Use of this source code is governed by an MIT-style * Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at * license that can be found in the LICENSE.txt file or at
@@ -85,16 +85,6 @@ interface VimMotionGroup {
fun moveCaretToMark(caret: ImmutableVimCaret, ch: Char, toLineStart: Boolean): Motion fun moveCaretToMark(caret: ImmutableVimCaret, ch: Char, toLineStart: Boolean): Motion
fun moveCaretToMarkRelative(caret: ImmutableVimCaret, count: Int): Motion fun moveCaretToMarkRelative(caret: ImmutableVimCaret, count: Int): Motion
fun moveCaretToJump(editor: VimEditor, 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 fun moveCaretToMatchingPair(editor: VimEditor, caret: ImmutableVimCaret): Motion
/** /**

View File

@@ -312,12 +312,6 @@ abstract class VimMotionGroupBase : VimMotionGroup {
return offset.toMotionOrError() 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 { override fun moveCaretToJump(editor: VimEditor, caret: ImmutableVimCaret, count: Int): Motion {
val jumpService = injector.jumpService val jumpService = injector.jumpService
val spot = jumpService.getJumpSpot(editor) val spot = jumpService.getJumpSpot(editor)

View File

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

View File

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

View File

@@ -256,7 +256,7 @@ private fun classify(codePoint: Int): CodePointType {
in 0x1100..0x115F, in 0xA960..0xA97C -> CodePointType.L in 0x1100..0x115F, in 0xA960..0xA97C -> CodePointType.L
in 0x1160..0x11A7, in 0xD7B0..0xD7C6 -> CodePointType.V in 0x1160..0x11A7, in 0xD7B0..0xD7C6 -> CodePointType.V
in 0x11A8..0x11FF, in 0xD7CB..0xD7FB -> CodePointType.T 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 in 0xAC00..0xD7A3 -> if ((codePoint - 0xAC00) % 28 == 0) CodePointType.LV else CodePointType.LVT
else -> CodePointType.OTHER else -> CodePointType.OTHER
} }

View File

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

View File

@@ -409,11 +409,6 @@ abstract class VimRegisterGroupBase : VimRegisterGroup {
return System.getenv("DISPLAY") != null && injector.systemInfoService.isXWindow 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>) { private fun setSystemPrimaryRegisterText(text: String, rawText: String, transferableData: List<Any>) {
logger.trace("Setting text: $text to primary selection...") logger.trace("Setting text: $text to primary selection...")
if (isPrimaryRegisterSupported()) { if (isPrimaryRegisterSupported()) {
@@ -441,13 +436,7 @@ abstract class VimRegisterGroupBase : VimRegisterGroup {
return refreshClipboardRegister() return refreshClipboardRegister()
} }
try { try {
val clipboardData = injector.clipboardManager.getPrimaryContent() val clipboardData = injector.clipboardManager.getPrimaryContent() ?: return null
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 currentRegister = myRegisters[PRIMARY_REGISTER]
val text = clipboardData.text val text = clipboardData.text
val transferableData = clipboardData.transferableData.toMutableList() val transferableData = clipboardData.transferableData.toMutableList()

View File

@@ -36,14 +36,6 @@ class ExCommandTree {
return abbrevToCommand[abbreviation] 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>> { private fun parseCommandPattern(commandsPattern: String): List<Pair<String, String>> {
val result = mutableListOf<Pair<String, String>>() val result = mutableListOf<Pair<String, String>>()
val commands = commandsPattern.split(",") val commands = commandsPattern.split(",")

View File

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

View File

@@ -52,7 +52,6 @@
"mapnew": "com.maddyhome.idea.vim.vimscript.model.functions.handlers.collectionFunctions.MapNewFunctionHandler", "mapnew": "com.maddyhome.idea.vim.vimscript.model.functions.handlers.collectionFunctions.MapNewFunctionHandler",
"max": "com.maddyhome.idea.vim.vimscript.model.functions.handlers.collectionFunctions.MaxFunctionHandler", "max": "com.maddyhome.idea.vim.vimscript.model.functions.handlers.collectionFunctions.MaxFunctionHandler",
"min": "com.maddyhome.idea.vim.vimscript.model.functions.handlers.collectionFunctions.MinFunctionHandler", "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", "nr2char": "com.maddyhome.idea.vim.vimscript.model.functions.handlers.stringFunctions.Nr2charFunctionHandler",
"or": "com.maddyhome.idea.vim.vimscript.model.functions.handlers.bitwiseFunctions.OrFunctionHandler", "or": "com.maddyhome.idea.vim.vimscript.model.functions.handlers.bitwiseFunctions.OrFunctionHandler",
"pow": "com.maddyhome.idea.vim.vimscript.model.functions.handlers.floatFunctions.PowFunctionHandler", "pow": "com.maddyhome.idea.vim.vimscript.model.functions.handlers.floatFunctions.PowFunctionHandler",

View File

@@ -45,9 +45,6 @@ E69=E69: Missing ] after {0}%[
E70=E70: Empty {0}%[] E70=E70: Empty {0}%[]
E71=E71: Invalid character after {0}% E71=E71: Invalid character after {0}%
E76=E76: Too many [ 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 E81=E81: Using <SID> not in a script context
E86=E86: Buffer {0} does not exist E86=E86: Buffer {0} does not exist
E93=E93: More than one match for {0} E93=E93: More than one match for {0}

View File

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