1
0
mirror of https://github.com/chylex/IntelliJ-IdeaVim.git synced 2026-05-13 18:05:53 +02:00

Compare commits

..

29 Commits

Author SHA1 Message Date
7b813899f0 Set plugin version to chylex-53 2026-03-29 19:45:23 +02:00
727dee5b85 Preserve visual mode after executing IDE action 2026-03-29 19:45:23 +02:00
3e7ea8668c Make g0/g^/g$ work with soft wraps 2026-03-29 19:45:22 +02:00
89f7c76180 Make gj/gk jump over soft wraps 2026-03-29 19:45:22 +02:00
a7d0297e2d Make camelCase motions adjust based on direction of visual selection 2026-03-29 19:45:22 +02:00
45da61debe Make search highlights temporary 2026-03-29 19:45:22 +02:00
ebc77454ab Exit insert mode after refactoring 2026-03-29 19:45:22 +02:00
c9193cb6d4 Add action to run last macro in all opened files 2026-03-29 19:45:22 +02:00
13246c0a80 Stop macro execution after a failed search 2026-03-29 19:45:22 +02:00
b0ff57a4f5 Revert per-caret registers 2026-03-29 19:45:22 +02:00
f4e0684ca8 Apply scrolloff after executing native IDEA actions 2026-03-29 19:45:22 +02:00
3a3e7952b1 Automatically add unambiguous imports after running a macro 2026-03-29 19:45:22 +02:00
1ff6066e33 Fix(VIM-3986): Exception when pasting register contents containing new line 2026-03-29 19:45:21 +02:00
3a9abba410 Fix(VIM-3179): Respect virtual space below editor (imperfectly) 2026-03-29 19:45:21 +02:00
510f8f948e Fix(VIM-3178): Workaround to support "Jump to Source" action mapping 2026-03-29 19:45:21 +02:00
b623bf739c Update search register when using f/t 2026-03-29 19:45:21 +02:00
c99d97b3bc Add support for count for visual and line motion surround 2026-03-29 19:45:21 +02:00
6b8eb8952f Fix vim-surround not working with multiple cursors
Fixes multiple cursors with vim-surround commands `cs, ds, S` (but not `ys`).
2026-03-29 19:45:21 +02:00
25d70ee975 Fix(VIM-696): Restore visual mode after undo/redo, and disable incompatible actions 2026-03-29 19:45:21 +02:00
cbc9637d17 Respect count with <Action> mappings 2026-03-29 19:45:21 +02:00
0d893d9961 Change matchit plugin to use HTML patterns in unrecognized files 2026-03-29 19:45:21 +02:00
4ac3a1eaaa Fix ex command panel causing Undock tool window to hide 2026-03-29 19:45:21 +02:00
86a6e9643f Reset insert mode when switching active editor 2026-03-29 19:45:21 +02:00
8b06078607 Remove notifications about configuration options 2026-03-29 19:45:20 +02:00
924455907a Remove AI 2026-03-29 19:45:20 +02:00
40367859b8 Set custom plugin version 2026-03-29 19:45:20 +02:00
45f7934d71 Revert "Fix(VIM-4108): Use default ANTLR output directory for Gradle 9+ compatibility"
This reverts commit a476583ea3.
2026-03-27 21:40:07 +01:00
0880e5f935 Revert "Upgrade Gradle wrapper to 9.2.1"
This reverts commit 517bda93
2026-03-27 21:40:07 +01:00
8af3788379 Revert "Fix(VIM-4109): Configure test source sets for Gradle 9+ compatibility"
This reverts commit 5c0d9569d9.
2026-03-27 21:40:07 +01:00
180 changed files with 1305 additions and 9415 deletions

View File

@@ -1,20 +0,0 @@
name: Claude changelog auto-merge
on: pull_request
permissions:
contents: write
pull-requests: write
jobs:
claude-changelog:
runs-on: ubuntu-latest
if: >-
github.repository == 'JetBrains/ideavim' &&
github.event.pull_request.user.login == 'claude[bot]' &&
startsWith(github.event.pull_request.title, 'Update changelog:')
steps:
- name: Auto-merge Claude changelog PR
run: gh pr merge --auto --rebase "$PR_URL"
env:
PR_URL: ${{github.event.pull_request.html_url}}
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}

50
.github/workflows/runSplitModeTests.yml vendored Normal file
View File

@@ -0,0 +1,50 @@
name: Run Split Mode Tests
on:
workflow_dispatch:
push:
branches:
- master
jobs:
test-linux:
if: github.repository == 'JetBrains/ideavim'
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- name: Free up disk space
run: |
echo "Disk space before cleanup:"
df -h
sudo rm -rf /usr/share/dotnet
sudo rm -rf /usr/local/lib/android
sudo rm -rf /opt/ghc
sudo rm -rf /opt/hostedtoolcache/CodeQL
sudo docker image prune --all --force
echo "Disk space after cleanup:"
df -h
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: zulu
java-version: 21
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
with:
cache-read-only: false
- name: Start Xvfb
run: |
Xvfb :99 -screen 0 1920x1080x24 &
echo "DISPLAY=:99" >> $GITHUB_ENV
- name: Run split mode tests
run: gradle :tests:split-mode-tests:testSplitMode --console=plain
- name: Upload reports
if: always()
uses: actions/upload-artifact@v4
with:
name: split-mode-reports
path: |
tests/split-mode-tests/build/reports
out/ide-tests/tests/**/log
out/ide-tests/tests/**/frontend/log

1
.idea/gradle.xml generated
View File

@@ -33,6 +33,5 @@
</option>
</GradleProjectSettings>
</option>
<option name="parallelModelFetch" value="true" />
</component>
</project>

View File

@@ -1,16 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Split Frontend Debugger" type="Remote" folderName="Split Mode">
<module name="ideavim" />
<option name="USE_SOCKET_TRANSPORT" value="true" />
<option name="SERVER_MODE" value="false" />
<option name="SHMEM_ADDRESS" />
<option name="HOST" value="localhost" />
<option name="PORT" value="5006" />
<option name="AUTO_RESTART" value="false" />
<RunnerSettings RunnerId="Debug">
<option name="DEBUG_PORT" value="5006" />
<option name="LOCAL" value="false" />
</RunnerSettings>
<method v="2" />
</configuration>
</component>

View File

@@ -1,24 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Start CLion with IdeaVim" type="GradleRunConfiguration" factoryName="Gradle" folderName="Platforms">
<log_file alias="idea.log" path="$PROJECT_DIR$/build/idea-sandbox/system/log/idea.log" />
<ExternalSystemSettings>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value="runClion" />
</list>
</option>
<option name="vmOptions" value="" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<DebugAllEnabled>false</DebugAllEnabled>
<method v="2" />
</configuration>
</component>

View File

@@ -1,25 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Start CLion with IdeaVim (Split Mode)" type="GradleRunConfiguration" factoryName="Gradle" folderName="Platforms (Split)">
<log_file alias="idea.log" path="$PROJECT_DIR$/build/idea-sandbox/system/log/idea.log" />
<ExternalSystemSettings>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value="runCLionSplitMode" />
</list>
</option>
<option name="vmOptions" value="" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<DebugAllEnabled>false</DebugAllEnabled>
<RunAsTest>false</RunAsTest>
<method v="2" />
</configuration>
</component>

View File

@@ -1,5 +1,5 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Start IJ with IdeaVim (Split Mode)" type="GradleRunConfiguration" factoryName="Gradle" folderName="Split Mode">
<configuration default="false" name="Start IJ with IdeaVim (Split Mode)" type="GradleRunConfiguration" factoryName="Gradle">
<log_file alias="idea.log" path="$PROJECT_DIR$/build/idea-sandbox/system/log/idea.log" />
<ExternalSystemSettings>
<option name="executionName" />

View File

@@ -1,25 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Start IJ with IdeaVim (Split Mode Debug Frontend)" type="GradleRunConfiguration" factoryName="Gradle" folderName="Split Mode">
<log_file alias="idea.log" path="$PROJECT_DIR$/build/idea-sandbox/system/log/idea.log" />
<ExternalSystemSettings>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value="runIdeSplitModeDebugFrontend" />
</list>
</option>
<option name="vmOptions" value="" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<DebugAllEnabled>false</DebugAllEnabled>
<RunAsTest>false</RunAsTest>
<method v="2" />
</configuration>
</component>

View File

@@ -1,24 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Start PyCharm with IdeaVim" type="GradleRunConfiguration" factoryName="Gradle" folderName="Platforms">
<log_file alias="idea.log" path="$PROJECT_DIR$/build/idea-sandbox/system/log/idea.log" />
<ExternalSystemSettings>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value="runPycharm" />
</list>
</option>
<option name="vmOptions" value="" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<DebugAllEnabled>false</DebugAllEnabled>
<method v="2" />
</configuration>
</component>

View File

@@ -1,25 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Start PyCharm with IdeaVim (Split Mode)" type="GradleRunConfiguration" factoryName="Gradle" folderName="Platforms (Split)">
<log_file alias="idea.log" path="$PROJECT_DIR$/build/idea-sandbox/system/log/idea.log" />
<ExternalSystemSettings>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value="runPycharmSplitMode" />
</list>
</option>
<option name="vmOptions" value="" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<DebugAllEnabled>false</DebugAllEnabled>
<RunAsTest>false</RunAsTest>
<method v="2" />
</configuration>
</component>

View File

@@ -1,24 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Start Rider with IdeaVim" type="GradleRunConfiguration" factoryName="Gradle" folderName="Platforms">
<log_file alias="idea.log" path="$PROJECT_DIR$/build/idea-sandbox/system/log/idea.log" />
<ExternalSystemSettings>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value="runRider" />
</list>
</option>
<option name="vmOptions" value="" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<DebugAllEnabled>false</DebugAllEnabled>
<method v="2" />
</configuration>
</component>

View File

@@ -1,24 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Start WebStorm with IdeaVim" type="GradleRunConfiguration" factoryName="Gradle" folderName="Platforms">
<log_file alias="idea.log" path="$PROJECT_DIR$/build/idea-sandbox/system/log/idea.log" />
<ExternalSystemSettings>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value="runWebstorm" />
</list>
</option>
<option name="vmOptions" value="" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<DebugAllEnabled>false</DebugAllEnabled>
<method v="2" />
</configuration>
</component>

View File

@@ -1,25 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Start WebStorm with IdeaVim (Split Mode)" type="GradleRunConfiguration" factoryName="Gradle" folderName="Platforms (Split)">
<log_file alias="idea.log" path="$PROJECT_DIR$/build/idea-sandbox/system/log/idea.log" />
<ExternalSystemSettings>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value="runWebstormSplitMode" />
</list>
</option>
<option name="vmOptions" value="" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<DebugAllEnabled>false</DebugAllEnabled>
<RunAsTest>false</RunAsTest>
<method v="2" />
</configuration>
</component>

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.
*/
package _Self
import _Self.buildTypes.Compatibility
@@ -14,7 +6,6 @@ import _Self.buildTypes.Nvim
import _Self.buildTypes.PluginVerifier
import _Self.buildTypes.PropertyBased
import _Self.buildTypes.RandomOrderTests
import _Self.buildTypes.SplitModeTests
import _Self.buildTypes.TestingBuildType
import _Self.buildTypes.TypeScriptTest
@@ -39,7 +30,6 @@ object Project : Project({
buildType(PropertyBased)
buildType(LongRunning)
buildType(RandomOrderTests)
buildType(SplitModeTests)
buildType(Nvim)
buildType(PluginVerifier)

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.
*/
package _Self.buildTypes
import _Self.AgentSize
@@ -19,10 +11,6 @@ object Compatibility : IdeaVimBuildType({
id("IdeaVimCompatibility")
name = "IdeaVim compatibility with external plugins"
failureConditions {
executionTimeoutMin = 180
}
vcs {
root(DslContext.settingsRoot)
branchFilter = "+:<default>"

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.
*/
package _Self.buildTypes
import _Self.AgentSize
@@ -34,7 +26,7 @@ object RandomOrderTests : IdeaVimBuildType({
gradle {
clearConditions()
tasks = """
clean test
test
-x :tests:property-tests:test
-x :tests:long-running-tests:test
-Djunit.jupiter.execution.order.random.seed=default
@@ -42,7 +34,7 @@ object RandomOrderTests : IdeaVimBuildType({
""".trimIndent().replace("\n", " ")
buildFile = ""
enableStacktrace = true
gradleParams = "--no-build-cache --configuration-cache"
gradleParams = "--build-cache --configuration-cache"
jdkHome = "/usr/lib/jvm/java-21-amazon-corretto"
}
}

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 _Self.buildTypes
import _Self.AgentSize
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 SplitModeTests : IdeaVimBuildType({
name = "Split mode tests"
description = "Tests for IdeaVim in Remote Development split mode (backend + frontend)"
artifactRules = """
+:tests/split-mode-tests/build/reports => split-mode-tests/build/reports
+:out/ide-tests/tests/**/log => out/ide-tests/log
+:out/ide-tests/tests/**/frontend/log => out/ide-tests/frontend-log
""".trimIndent()
params {
param("env.ORG_GRADLE_PROJECT_downloadIdeaSources", "false")
param("env.ORG_GRADLE_PROJECT_instrumentPluginCode", "false")
param("env.DISPLAY", ":99")
}
vcs {
root(DslContext.settingsRoot)
branchFilter = "+:<default>"
checkoutMode = CheckoutMode.AUTO
}
steps {
script {
name = "Start Xvfb and run split mode tests"
scriptContent = """
# Kill any leftover Xvfb from previous runs
pkill -f 'Xvfb :99' || true
Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp &
XVFB_PID=${'$'}!
# Wait until the display is ready
for i in $(seq 1 30); do
if xdpyinfo -display :99 >/dev/null 2>&1; then
echo "Xvfb is ready on :99"
break
fi
sleep 1
done
if ! xdpyinfo -display :99 >/dev/null 2>&1; then
echo "ERROR: Xvfb failed to start on :99"
exit 1
fi
./gradlew :tests:split-mode-tests:testSplitMode --console=plain --build-cache --configuration-cache --stacktrace
TEST_EXIT=${'$'}?
kill ${'$'}XVFB_PID 2>/dev/null || true
exit ${'$'}TEST_EXIT
""".trimIndent()
}
}
// VCS trigger disabled until Xvfb is installed on the TeamCity agent
// triggers {
// vcs {
// branchFilter = "+:<default>"
// }
// }
requirements {
// Use a larger agent for split-mode tests — they launch two full IDE instances
equals("teamcity.agent.hardware.cpuCount", AgentSize.XLARGE)
equals("teamcity.agent.os.family", "Linux")
}
})

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.
*/
@file:Suppress("ClassName")
package _Self.buildTypes
@@ -49,10 +41,10 @@ open class TestingBuildType(
steps {
gradle {
clearConditions()
tasks = "clean test -x :tests:property-tests:test -x :tests:long-running-tests:test"
tasks = "test -x :tests:property-tests:test -x :tests:long-running-tests:test"
buildFile = ""
enableStacktrace = true
gradleParams = "--no-build-cache --configuration-cache"
gradleParams = "--build-cache --configuration-cache"
jdkHome = "/usr/lib/jvm/java-21-amazon-corretto"
}
}

View File

@@ -542,10 +542,6 @@ Contributors:
[![icon][github]](https://github.com/1grzyb1)
&nbsp;
1grzyb1
* [![icon][mail]](mailto:yury@digitalby.me)
[![icon][github]](https://github.com/digitalby)
&nbsp;
digitalby
Contributors with JetBrains IP:

View File

@@ -26,8 +26,6 @@ usual beta standards.
## [To Be Released]
### Features:
* [VIM-1693](https://youtrack.jetbrains.com/issue/VIM-1693) Added `:autocmd` command - run Ex commands on editor events such as `BufRead`, `BufWrite`, `BufEnter`, `BufLeave`, `InsertEnter`, `InsertLeave`, `WinEnter`, `WinLeave`, `FocusGained`, `FocusLost`, and `FileType`; supports `augroup` and file pattern matching (e.g., `autocmd BufWritePre *.py echo "saving python"`)
* [VIM-268](https://youtrack.jetbrains.com/issue/VIM-268) Added file name completion in ex commands - press `<Tab>`/`<S-Tab>` to cycle through file matches in `:edit`, `:split`, `:vsplit`, `:write`, `:read`, `:source`, and `:find` commands; use arrow keys to navigate the completion panel
* New VimScript functions: `add()`, `call()`, `extend()`, `extendnew()`, `filter()`, `flatten()`, `flattennew()`, `foreach()`, `has_key()`, `indexof()`, `insert()`, `items()`, `keys()`, `map()`, `mapnew()`, `reduce()`, `remove()`, `slice()`, `sort()`, `uniq()`, `values()`
* [VIM-1595](https://youtrack.jetbrains.com/issue/VIM-1595) Added support for `:read` command - insert file content below current line (e.g., `:read file.txt`, `0read file.txt`)
* [VIM-1595](https://youtrack.jetbrains.com/issue/VIM-1595) Added support for `:read!` command - insert shell command output below current line (e.g., `:read! echo "hello"`)
@@ -36,71 +34,18 @@ usual beta standards.
* [VIM-566](https://youtrack.jetbrains.com/issue/VIM-566) Added support for `zm` command - decrease fold level to hide more folds
* [VIM-566](https://youtrack.jetbrains.com/issue/VIM-566) Added support for `zf` command - create fold from selection or motion
* [VIM-566](https://youtrack.jetbrains.com/issue/VIM-566) Added support for `:set foldlevel` option - control fold visibility level
* [VIM-1158](https://youtrack.jetbrains.com/issue/VIM-1158) Added `gw` command - reformat code like `gq` but preserving the cursor position
### 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-4112](https://youtrack.jetbrains.com/issue/VIM-4112) Fixed undo after block-visual edit (`<C-V>...x`, `<C-V>...c`, `<C-V>...I`) leaving stray carets in normal mode
* [VIM-4176](https://youtrack.jetbrains.com/issue/VIM-4176) Fixed race condition in single-line output panel that could cause `*` search wrapping to behave unreliably
* [VIM-4175](https://youtrack.jetbrains.com/issue/VIM-4175) Fixed search "not found" showing previous "Hit ENTER" text alongside the error - panel is now cleared before displaying errors like "E486: Pattern not found"
* [VIM-4135](https://youtrack.jetbrains.com/issue/VIM-4135) Fixed IdeaVim not loading in Rider
* [VIM-4134](https://youtrack.jetbrains.com/issue/VIM-4134) Fixed undo in commentary - `gcc`/`gc{motion}` changes are now properly grouped as a single undo step
* [VIM-4134](https://youtrack.jetbrains.com/issue/VIM-4134) Fixed `=` (format/auto-indent) action in split mode
* [VIM-4134](https://youtrack.jetbrains.com/issue/VIM-4134) Fixed global marks causing errors when used inside write actions (e.g., during document modifications)
* [VIM-4105](https://youtrack.jetbrains.com/issue/VIM-4105) Fixed `a"` `a'` `a\`` text objects to include surrounding whitespace per Vim spec
* [VIM-4097](https://youtrack.jetbrains.com/issue/VIM-4097) Fixed `<A-n>` (NextOccurrence) with text containing backslashes - e.g., selecting `\IntegerField` now works correctly
* [VIM-4094](https://youtrack.jetbrains.com/issue/VIM-4094) Fixed UninitializedPropertyAccessException when loading history
* [VIM-4016](https://youtrack.jetbrains.com/issue/VIM-4016) Fixed `:edit` command when project has no source roots
* [VIM-3948](https://youtrack.jetbrains.com/issue/VIM-3948) Improved hint generation visibility checks for better UI component detection
* [VIM-4195](https://youtrack.jetbrains.com/issue/VIM-4195) Fixed settings not being saved in remote development (split) mode
* [VIM-3473](https://youtrack.jetbrains.com/issue/VIM-3473) Fixed "Reload .ideavimrc" action in remote development (split) mode - no longer causes File Cache Conflict dialogs
* [VIM-2821](https://youtrack.jetbrains.com/issue/VIM-2821) Fixed undo grouping when repeating text insertion with `.` in remote development (split mode)
* [VIM-1705](https://youtrack.jetbrains.com/issue/VIM-1705) Fixed window-switching commands (e.g., `<C-w>h`) during macro playback
* Fixed `pumvisible()` function returning incorrect result (was inverted)
* Fixed `<Esc>` not properly exiting insert mode in Rider/CLion when canceling a completion lookup
* Fixed `<Esc>` not exiting insert mode after `<C-Space>` completion in Rider
* Fixed `<Esc>` in search bar no longer inserts `^[` literal text when search is not found - panel is now properly closed
* Fixed IdeaVim entering broken state when a VimScript extension plugin fails to initialize
* Fixed compatibility issues with external plugins (e.g., IdeaVim-EasyMotion, multicursor)
* Fixed recursive key mappings (e.g., `map b wbb`) causing an apparent infinite loop - `maxmapdepth` limit now properly terminates the entire mapping chain
* Fixed NERDTree `gs`/`gi` preview split commands to keep focus on the tree
* Fixed visual marks (`<` and `>`) position tracking after text deletion - `gv` now re-selects correctly
* Fixed `IndexOutOfBoundsException` when using text objects like `a)` at end of file
* Fixed high CPU usage while showing command line
* Fixed comparison of String and Number in VimScript expressions
* Fixed `\/`, `\?`, and `\&` in Ex command ranges now correctly report E35/E33 errors when no previous search or substitute pattern exists, instead of crashing
* [VIM-4172](https://youtrack.jetbrains.com/issue/VIM-4172) IdeaVim is now disabled in Python Console to prevent key interference
* [VIM-4113](https://youtrack.jetbrains.com/issue/VIM-4113) Fixed Visual mode commands (e.g., `:'<,'>sort`) failing when run off the Event Dispatch Thread
* [VIM-3727](https://youtrack.jetbrains.com/issue/VIM-3727) Fixed Enter and arrow keys not working in Python Console in split mode
* Fixed NERDTree navigation (`j`/`k`/`G`/`gg`/`p`/`<C-J>`/`<C-K>`) poor performance in split mode - navigation now uses Swing actions directly instead of going through backend RPC
* [VIM-4180](https://youtrack.jetbrains.com/issue/VIM-4180) Fixed ReplaceWithRegister plugin's default `gr`/`grr` mappings overriding user-defined key mappings
* Fixed `IndexOutOfBoundsException` when using `:command` with `-nargs` option but without a command name
* Fixed spurious beep when pressing `<Esc>` to cancel register selection in normal mode (after pressing `"`)
* [VIM-4202](https://youtrack.jetbrains.com/issue/VIM-4202) Fixed `<S-Tab>` being intercepted by IdeaVim - users can now remap `<S-Tab>` to other IntelliJ actions
* [VIM-4202](https://youtrack.jetbrains.com/issue/VIM-4202) Fixed `gcc`/`gc{motion}` commentary leaving editor in incorrect mode in Rider/CLion split mode
* [VIM-4115](https://youtrack.jetbrains.com/issue/VIM-4115) Fixed NullPointerException in `CommandKeyConsumer` when pressing Esc after disabling and re-enabling IdeaVim with an open command line
### Merged PRs:
* [1704](https://github.com/JetBrains/ideavim/pull/1704) by [1grzyb1](https://github.com/1grzyb1): VIM-4202 Don't intercept all <S-Tab>
* [1703](https://github.com/JetBrains/ideavim/pull/1703) by [1grzyb1](https://github.com/1grzyb1): VIM-4202 Fix state after commentary action
* [1700](https://github.com/JetBrains/ideavim/pull/1700) by [1grzyb1](https://github.com/1grzyb1): VIM-4139 Compute nesting depth for fold regions
* [1699](https://github.com/JetBrains/ideavim/pull/1699) by [1grzyb1](https://github.com/1grzyb1): VIM-4112 collapse restored carets after undo of block-visual edit
* [1696](https://github.com/JetBrains/ideavim/pull/1696) by [citizenmatt](https://github.com/citizenmatt): VIM-4197 Fix missing Vim features in Java files decompiled from Kotlin class files
* [1695](https://github.com/JetBrains/ideavim/pull/1695) by [1grzyb1](https://github.com/1grzyb1): VIM-1693 Implement autocmd
* [1690](https://github.com/JetBrains/ideavim/pull/1690) by [1grzyb1](https://github.com/1grzyb1): Make nerdtree work without calling backend actions
* [1688](https://github.com/JetBrains/ideavim/pull/1688) by [1grzyb1](https://github.com/1grzyb1): VIM-4172 Disable ideavim in Python Console
* [1687](https://github.com/JetBrains/ideavim/pull/1687) by [1grzyb1](https://github.com/1grzyb1): Restore old VimPLugin method signatures
* [1685](https://github.com/JetBrains/ideavim/pull/1685) by [1grzyb1](https://github.com/1grzyb1): VIM-3727 Fix Python console Enter and arrow keys in split mode
* [1548](https://github.com/JetBrains/ideavim/pull/1548) by [1grzyb1](https://github.com/1grzyb1): VIM-1158 Add `gw` to reformat code with preserving the cursor position
* [1682](https://github.com/JetBrains/ideavim/pull/1682) by [1grzyb1](https://github.com/1grzyb1): VIM-268 Complete file names in edit command
* [1632](https://github.com/JetBrains/ideavim/pull/1632) by [chylex](https://github.com/chylex): Fix pumvisible returning opposite result
* [1615](https://github.com/JetBrains/ideavim/pull/1615) by [1grzyb1](https://github.com/1grzyb1): Fix IndexOutOfBoundsException in findBlock when caret is at end of file
* [1613](https://github.com/JetBrains/ideavim/pull/1613) by [1grzyb1](https://github.com/1grzyb1): VIM-3473 Sync ideavim in remdev
* [1608](https://github.com/JetBrains/ideavim/pull/1608) by [1grzyb1](https://github.com/1grzyb1): VIM-4134 format using = action in split mode
* [1585](https://github.com/JetBrains/ideavim/pull/1585) by [1grzyb1](https://github.com/1grzyb1): Break in case of maximum recursion depth
* [1414](https://github.com/JetBrains/ideavim/pull/1414) by [Matt Ellis](https://github.com/citizenmatt): Refactor/functions
* [1442](https://github.com/JetBrains/ideavim/pull/1442) by [Matt Ellis](https://github.com/citizenmatt): Fix high CPU usage while showing command line
* [1665](https://github.com/JetBrains/ideavim/pull/1665) by [1grzyb1](https://github.com/1grzyb1): Fix visual selection commands failing off-EDT due to nested write-in-read action
## 2.28.0, 2025-12-09

View File

@@ -241,24 +241,6 @@ ShowHoverInfo - Quick Documentation and Error Description
QuickImplementations - Quick Definition
```
Autocmd
----------
IdeaVim supports Vims `:autocmd` for running commands on editor events, including
`InsertEnter`/`InsertLeave`, buffer events (`BufEnter`, `BufLeave`, `BufRead`,
`BufNewFile`, `BufWritePre`, `BufWritePost`), window events (`WinEnter`, `WinLeave`),
focus events (`FocusGained`, `FocusLost`), and `FileType`. Full glob patterns
(`*`, `**`, `?`, `[abc]`, `{a,b}`) and augroups are supported.
```vim
autocmd BufWritePre *.py echo "saving python"
autocmd FileType python setlocal shiftwidth=4
```
See [doc/autocmd.md](doc/autocmd.md) for the full event reference, firing order, and notes on IntelliJ-specific
differences.
Vim Script
------------

View File

@@ -2,8 +2,6 @@ IdeaVim project is licensed under MIT license except the following parts of it:
* File [ScrollViewHelper.kt](com/maddyhome/idea/vim/helper/ScrollViewHelper.kt) is licensed under Vim License.
* File [Tutor.kt](src/main/java/com/maddyhome/idea/vim/ui/Tutor.kt) is licensed under Vim License.
* File [CodeWrapper.kt](vim-engine/src/main/kotlin/com/maddyhome/idea/vim/helper/CodeWrapper.kt) is licensed under Vim
License.
```
VIM LICENSE

View File

@@ -6,7 +6,6 @@
* https://opensource.org/licenses/MIT.
*/
import org.jetbrains.intellij.platform.gradle.IntelliJPlatformType
import org.jetbrains.intellij.platform.gradle.TestFrameworkType
import org.jetbrains.intellij.platform.gradle.tasks.aware.SplitModeAware
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
@@ -27,11 +26,11 @@ buildscript {
classpath("org.eclipse.jgit:org.eclipse.jgit.ssh.apache:7.6.0.202603022253-r")
classpath("org.kohsuke:github-api:1.305")
classpath("io.ktor:ktor-client-core:3.4.3")
classpath("io.ktor:ktor-client-cio:3.4.3")
classpath("io.ktor:ktor-client-auth:3.4.3")
classpath("io.ktor:ktor-client-content-negotiation:3.4.3")
classpath("io.ktor:ktor-serialization-kotlinx-json:3.4.3")
classpath("io.ktor:ktor-client-core:3.4.1")
classpath("io.ktor:ktor-client-cio:3.4.1")
classpath("io.ktor:ktor-client-auth:3.4.1")
classpath("io.ktor:ktor-client-content-negotiation:3.4.1")
classpath("io.ktor:ktor-serialization-kotlinx-json:3.4.1")
// This comes from the changelog plugin
// classpath("org.jetbrains:markdown:0.3.1")
@@ -113,7 +112,7 @@ dependencies {
testFramework(TestFrameworkType.Platform)
testFramework(TestFrameworkType.JUnit5)
compatiblePlugin("com.intellij.classic.ui")
plugin("com.intellij.classic.ui", "261.22158.185")
pluginModule(runtimeOnly(project(":modules:ideavim-common")))
pluginModule(runtimeOnly(project(":modules:ideavim-frontend")))
@@ -226,32 +225,7 @@ tasks {
// localPath = file("/Users/{user}/Applications/WebStorm.app")
// }
val runPycharm by intellijPlatformTesting.runIde.registering {
type = IntelliJPlatformType.PyCharmProfessional
version = "2026.1"
task {
systemProperty("octopus.handler", System.getProperty("octopus.handler") ?: true)
}
}
val runWebstorm by intellijPlatformTesting.runIde.registering {
type = IntelliJPlatformType.WebStorm
version = "2025.3.2"
task {
systemProperty("octopus.handler", System.getProperty("octopus.handler") ?: true)
}
}
val runClion by intellijPlatformTesting.runIde.registering {
type = IntelliJPlatformType.CLion
version = "2026.1"
task {
systemProperty("octopus.handler", System.getProperty("octopus.handler") ?: true)
}
}
val runIdeForUiTests by intellijPlatformTesting.runIde.registering {
version = "2026.1"
task {
jvmArgumentProviders += CommandLineArgumentProvider {
listOf(
@@ -273,55 +247,6 @@ tasks {
val runIdeSplitMode by intellijPlatformTesting.runIde.registering {
splitMode = true
splitModeTarget = SplitModeAware.SplitModeTarget.BOTH
plugins {
plugin("AceJump", "3.8.22")
plugin("org.jetbrains.IdeaVim-EasyMotion", "1.16")
}
}
val runWebstormSplitMode by intellijPlatformTesting.runIde.registering {
type = IntelliJPlatformType.WebStorm
version = "2025.3.2"
splitMode = true
splitModeTarget = SplitModeAware.SplitModeTarget.BOTH
plugins {
plugin("AceJump", "3.8.22")
plugin("org.jetbrains.IdeaVim-EasyMotion", "1.16")
}
}
val runRider by intellijPlatformTesting.runIde.registering {
type = IntelliJPlatformType.Rider
version = "2026.1"
task {
systemProperty("idea.log.debug.categories", "com.maddyhome.idea.vim.handler.EditorHandlersChainLogger")
}
plugins {
plugin("AceJump", "3.8.22")
plugin("org.jetbrains.IdeaVim-EasyMotion", "1.16")
}
}
val runCLionSplitMode by intellijPlatformTesting.runIde.registering {
type = IntelliJPlatformType.CLion
version = "2025.3.2"
splitMode = true
splitModeTarget = SplitModeAware.SplitModeTarget.BOTH
plugins {
plugin("AceJump", "3.8.22")
plugin("org.jetbrains.IdeaVim-EasyMotion", "1.16")
}
}
val runPycharmSplitMode by intellijPlatformTesting.runIde.registering {
type = IntelliJPlatformType.PyCharmProfessional
version = "2026.1"
splitMode = true
splitModeTarget = SplitModeAware.SplitModeTarget.BOTH
plugins {
plugin("AceJump", "3.8.22")
plugin("org.jetbrains.IdeaVim-EasyMotion", "1.16")
}
}
// Run split mode with a JDWP debug agent on the frontend (JetBrains Client) process.
@@ -330,11 +255,6 @@ tasks {
splitMode = true
splitModeTarget = SplitModeAware.SplitModeTarget.BOTH
plugins {
plugin("AceJump", "3.8.22")
plugin("org.jetbrains.IdeaVim-EasyMotion", "1.16")
}
prepareSandboxTask {
val sandboxDir = project.layout.buildDirectory.dir("idea-sandbox").map { it.asFile }
doLast {
@@ -363,54 +283,9 @@ tasks {
}
}
val runPycharmSplitModeDebugFrontend by intellijPlatformTesting.runIde.registering {
type = IntelliJPlatformType.PyCharmProfessional
version = "2026.1"
splitMode = true
splitModeTarget = SplitModeAware.SplitModeTarget.BOTH
plugins {
plugin("AceJump", "3.8.22")
plugin("org.jetbrains.IdeaVim-EasyMotion", "1.16")
}
prepareSandboxTask {
val sandboxDir = project.layout.buildDirectory.dir("idea-sandbox").map { it.asFile }
doLast {
val debugLine = "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5006"
val vmoptions = sandboxDir.get().walkTopDown()
.filter { it.name == "jetbrains_client64.vmoptions" && it.path.contains("runPycharmSplitModeDebugFrontend") }
.firstOrNull()
?: sandboxDir.get().walkTopDown()
.filter { it.name == "jetbrains_client64.vmoptions" }
.firstOrNull()
if (vmoptions != null) {
val content = vmoptions.readText()
if (debugLine !in content) {
vmoptions.appendText("\n$debugLine\n")
logger.lifecycle("Patched frontend vmoptions with JDWP debug agent: ${vmoptions.absolutePath}")
}
logger.lifecycle("Connect a Remote JVM Debug configuration to localhost:5006")
} else {
logger.warn(
"Could not find jetbrains_client64.vmoptions in sandbox. " +
"Run `./gradlew runPycharmSplitMode` once first to populate the sandbox, then use this task."
)
}
}
}
}
val testIdeSplitMode by intellijPlatformTesting.testIde.registering {
splitMode = true
splitModeTarget = SplitModeAware.SplitModeTarget.BOTH
plugins {
plugin("AceJump", "3.8.22")
plugin("org.jetbrains.IdeaVim-EasyMotion", "1.16")
}
task {
useJUnitPlatform()
}
@@ -474,8 +349,6 @@ intellijPlatform {
changeNotes.set(
"""
<b>Features:</b><br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-1693">VIM-1693</a> Added <code>:autocmd</code> command - run Ex commands on editor events such as <code>BufRead</code>, <code>BufWrite</code>, <code>BufEnter</code>, <code>BufLeave</code>, <code>InsertEnter</code>, <code>InsertLeave</code>, <code>WinEnter</code>, <code>WinLeave</code>, <code>FocusGained</code>, <code>FocusLost</code>, and <code>FileType</code>; supports <code>augroup</code> and file pattern matching (e.g., <code>autocmd BufWritePre *.py echo "saving python"</code>)<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-268">VIM-268</a> Added file name completion in ex commands - press <code>&lt;Tab&gt;</code>/<code>&lt;S-Tab&gt;</code> to cycle through file matches in <code>:edit</code>, <code>:split</code>, <code>:vsplit</code>, <code>:write</code>, <code>:read</code>, <code>:source</code>, and <code>:find</code> commands; use arrow keys to navigate the completion panel<br>
* New VimScript functions: <code>add()</code>, <code>call()</code>, <code>extend()</code>, <code>extendnew()</code>, <code>filter()</code>, <code>flatten()</code>, <code>flattennew()</code>, <code>foreach()</code>, <code>has_key()</code>, <code>indexof()</code>, <code>insert()</code>, <code>items()</code>, <code>keys()</code>, <code>map()</code>, <code>mapnew()</code>, <code>reduce()</code>, <code>remove()</code>, <code>slice()</code>, <code>sort()</code>, <code>uniq()</code>, <code>values()</code><br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-1595">VIM-1595</a> Added support for <code>:read</code> command - insert file content below current line (e.g., <code>:read file.txt</code>, <code>0read file.txt</code>)<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-1595">VIM-1595</a> Added support for <code>:read!</code> command - insert shell command output below current line (e.g., <code>:read! echo "hello"</code>)<br>
@@ -484,71 +357,18 @@ intellijPlatform {
* <a href="https://youtrack.jetbrains.com/issue/VIM-566">VIM-566</a> Added support for <code>zm</code> command - decrease fold level to hide more folds<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-1158">VIM-1158</a> Added <code>gw</code> command - reformat code like <code>gq</code> but preserving the cursor position<br>
<br>
<b>Fixes:</b><br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4197">VIM-4197</a> Fixed Vim features (e.g., <code>f</code>, <code>w</code>, text objects) not working in Java files decompiled from Kotlin class files<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4112">VIM-4112</a> Fixed undo after block-visual edit (<code>&lt;C-V&gt;...x</code>, <code>&lt;C-V&gt;...c</code>, <code>&lt;C-V&gt;...I</code>) leaving stray carets in normal mode<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4176">VIM-4176</a> Fixed race condition in single-line output panel that could cause <code>*</code> search wrapping to behave unreliably<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4175">VIM-4175</a> Fixed search "not found" showing previous "Hit ENTER" text alongside the error - panel is now cleared before displaying errors like "E486: Pattern not found"<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4135">VIM-4135</a> Fixed IdeaVim not loading in Rider<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4134">VIM-4134</a> Fixed undo in commentary - <code>gcc</code>/<code>gc{motion}</code> changes are now properly grouped as a single undo step<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4134">VIM-4134</a> Fixed <code>=</code> (format/auto-indent) action in split mode<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4134">VIM-4134</a> Fixed global marks causing errors when used inside write actions (e.g., during document modifications)<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4105">VIM-4105</a> Fixed <code>a"</code> <code>a'</code> <code>a`</code> text objects to include surrounding whitespace per Vim spec<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4097">VIM-4097</a> Fixed <code>&lt;A-n&gt;</code> (NextOccurrence) with text containing backslashes - e.g., selecting <code>\IntegerField</code> now works correctly<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4094">VIM-4094</a> Fixed UninitializedPropertyAccessException when loading history<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4016">VIM-4016</a> Fixed <code>:edit</code> command when project has no source roots<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-3948">VIM-3948</a> Improved hint generation visibility checks for better UI component detection<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4195">VIM-4195</a> Fixed settings not being saved in remote development (split) mode<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-3473">VIM-3473</a> Fixed "Reload .ideavimrc" action in remote development (split) mode - no longer causes File Cache Conflict dialogs<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-2821">VIM-2821</a> Fixed undo grouping when repeating text insertion with <code>.</code> in remote development (split mode)<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-1705">VIM-1705</a> Fixed window-switching commands (e.g., <code>&lt;C-w&gt;h</code>) during macro playback<br>
* Fixed <code>pumvisible()</code> function returning incorrect result (was inverted)<br>
* Fixed <code>&lt;Esc&gt;</code> not properly exiting insert mode in Rider/CLion when canceling a completion lookup<br>
* Fixed <code>&lt;Esc&gt;</code> not exiting insert mode after <code>&lt;C-Space&gt;</code> completion in Rider<br>
* Fixed <code>&lt;Esc&gt;</code> in search bar no longer inserts <code>^[</code> literal text when search is not found - panel is now properly closed<br>
* Fixed IdeaVim entering broken state when a VimScript extension plugin fails to initialize<br>
* Fixed compatibility issues with external plugins (e.g., IdeaVim-EasyMotion, multicursor)<br>
* Fixed recursive key mappings (e.g., <code>map b wbb</code>) causing an apparent infinite loop - <code>maxmapdepth</code> limit now properly terminates the entire mapping chain<br>
* Fixed NERDTree <code>gs</code>/<code>gi</code> preview split commands to keep focus on the tree<br>
* Fixed visual marks (<code>&lt;</code> and <code>&gt;</code>) position tracking after text deletion - <code>gv</code> now re-selects correctly<br>
* Fixed <code>IndexOutOfBoundsException</code> when using text objects like <code>a)</code> at end of file<br>
* Fixed high CPU usage while showing command line<br>
* Fixed comparison of String and Number in VimScript expressions<br>
* Fixed <code>\/</code>, <code>\?</code>, and <code>\&</code> in Ex command ranges now correctly report E35/E33 errors when no previous search or substitute pattern exists, instead of crashing<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4172">VIM-4172</a> IdeaVim is now disabled in Python Console to prevent key interference<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4113">VIM-4113</a> Fixed Visual mode commands (e.g., <code>:'&lt;,'&gt;sort</code>) failing when run off the Event Dispatch Thread<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-3727">VIM-3727</a> Fixed Enter and arrow keys not working in Python Console in split mode<br>
* Fixed NERDTree navigation (<code>j</code>/<code>k</code>/<code>G</code>/<code>gg</code>/<code>p</code>/<code>&lt;C-J&gt;</code>/<code>&lt;C-K&gt;</code>) poor performance in split mode - navigation now uses Swing actions directly instead of going through backend RPC<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4180">VIM-4180</a> Fixed ReplaceWithRegister plugin's default <code>gr</code>/<code>grr</code> mappings overriding user-defined key mappings<br>
* Fixed <code>IndexOutOfBoundsException</code> when using <code>:command</code> with <code>-nargs</code> option but without a command name<br>
* Fixed spurious beep when pressing <code>&lt;Esc&gt;</code> to cancel register selection in normal mode (after pressing <code>"</code>)<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-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>
<br>
<b>Merged PRs:</b><br>
* <a href="https://github.com/JetBrains/ideavim/pull/1704">1704</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: VIM-4202 Don't intercept all &lt;S-Tab&gt;<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1703">1703</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: VIM-4202 Fix state after commentary action<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1700">1700</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: VIM-4139 Compute nesting depth for fold regions<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1699">1699</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: VIM-4112 collapse restored carets after undo of block-visual edit<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1696">1696</a> by <a href="https://github.com/citizenmatt">citizenmatt</a>: VIM-4197 Fix missing Vim features in Java files decompiled from Kotlin class files<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1695">1695</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: VIM-1693 Implement autocmd<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1690">1690</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: Make nerdtree work without calling backend actions<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1688">1688</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: VIM-4172 Disable ideavim in Python Console<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1687">1687</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: Restore old VimPLugin method signatures<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1685">1685</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: VIM-3727 Fix Python console Enter and arrow keys in split mode<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1548">1548</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: VIM-1158 Add <code>gw</code> to reformat code with preserving the cursor position<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1682">1682</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: VIM-268 Complete file names in edit command<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1632">1632</a> by <a href="https://github.com/chylex">chylex</a>: Fix pumvisible returning opposite result<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1615">1615</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: Fix IndexOutOfBoundsException in findBlock when caret is at end of file<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1613">1613</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: VIM-3473 Sync ideavim in remdev<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1608">1608</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: VIM-4134 format using = action in split mode<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1585">1585</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: Break in case of maximum recursion depth<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1414">1414</a> by <a href="https://github.com/citizenmatt">Matt Ellis</a>: Refactor/functions<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1442">1442</a> by <a href="https://github.com/citizenmatt">Matt Ellis</a>: Fix high CPU usage while showing command line<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1665">1665</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: Fix visual selection commands failing off-EDT due to nested write-in-read action<br>
<br>
<a href="https://youtrack.jetbrains.com/issues/VIM?q=State:%20Fixed%20Fix%20versions:%20${version.get()}">Changelog</a>
""".trimIndent()

View File

@@ -1,137 +0,0 @@
Autocommands
============
IdeaVim supports Vim's `:autocmd` for running commands on editor events.
Handlers are registered from `~/.ideavimrc` or interactively in Command-line mode.
Every effort is made to match Vim's behaviour, but some differences are inevitable
because the IDE's event model doesn't map 1:1 onto Vim's.
Syntax
------
```
autocmd [group] {event}[,{event}...] {pattern} {command}
autocmd!
autocmd! {group}
```
- `{event}` — one or more comma-separated event names (see below).
- `{pattern}` — file pattern (see "Patterns" below). For `FileType`, the pattern matches the filetype name, not the file
path.
- `{command}` — any Ex command or Vimscript expression.
- `autocmd!` — clears all registered handlers, or all handlers in the given augroup.
```vim
augroup my_group
autocmd!
autocmd BufWritePre *.py echo "saving python"
augroup END
```
Patterns
--------
Autocmd file patterns support the following glob syntax:
| Pattern | Matches |
|-------------|------------------------------------------|
| `*` | Any characters except path separators |
| `**` | Any characters including path separators |
| `?` | Any single non-separator character |
| `[abc]` | Any character in the set |
| `{foo,bar}` | Either `foo` or `bar` |
If the pattern contains `/` or `\`, it matches against the full path;
otherwise it matches against the filename only.
`FileType` is special: its pattern matches against the filetype name
(e.g. `python`, `java`) rather than the file path.
Supported events
----------------
### Insert mode
| Event | Fires when |
|---------------|----------------------|
| `InsertEnter` | Entering Insert mode |
| `InsertLeave` | Leaving Insert mode |
### Buffers
| Event | Fires when |
|----------------|-------------------------------------------------------------------|
| `BufEnter` | A buffer becomes active (every switch) |
| `BufLeave` | A buffer stops being active |
| `BufRead` | A file is loaded into a buffer for the first time |
| `BufReadPost` | Alias of `BufRead` (same event, two names) |
| `BufNewFile` | Editing a file that was just created (fires instead of `BufRead`) |
| `BufWrite` | Alias of `BufWritePre` |
| `BufWritePre` | Before the buffer is written to disk |
| `BufWritePost` | After the buffer has been written to disk |
### Windows
| Event | Fires when |
|------------|----------------------------------------|
| `WinEnter` | A window becomes active (every switch) |
| `WinLeave` | A window stops being active |
### Files
| Event | Fires when |
|------------|--------------------------------------------------------------------------------------------------|
| `FileType` | A buffer's filetype is determined (typically once per file load). Pattern matches filetype name. |
### Focus
| Event | Fires when |
|---------------|----------------------------|
| `FocusGained` | The IDE window gains focus |
| `FocusLost` | The IDE window loses focus |
### Event order
When opening a file for the first time:
```
BufRead/BufReadPost → FileType → BufEnter
```
When opening a just-created file:
```
BufNewFile → FileType → BufEnter
```
When switching buffers:
```
BufLeave → WinLeave → WinEnter → BufEnter
```
When saving:
```
BufWrite/BufWritePre → (write) → BufWritePost
```
Differences from Vim
--------------------
**`FileType` names.** IdeaVim maps IntelliJ's file type name to a Vim-style
filetype. For most languages the lowercased IJ name matches Vim's filetype
(`Python``python`, `JAVA``java`). A small override table handles cases where
Vim's convention differs: `PLAIN_TEXT``text`, `C++``cpp`, `C#``cs`,
`Shell Script``sh`, `ObjectiveC``objc`, `JavaScript``javascript`,
`TypeScript``typescript`, `Vue.js``vue`, `CMakeLists.txt``cmake`,
`Handlebars/Mustache``handlebars`.
**`BufNewFile` detection.** IdeaVim tracks files created during the session
via the VFS. When such a file is opened in an editor, `BufNewFile` fires
instead of `BufRead`. Files created by VCS pulls, build tools, or external
processes that you later open in an editor will also be treated as new files.
**`BufWritePre` / `BufWritePost` frequency.** IntelliJ auto-saves on focus
loss, tab switch, build, and other events. These autocmds fire more often
than Vim's `:w`, so handlers should be idempotent.

View File

@@ -20,7 +20,7 @@ ideaVersion=2026.1
# Values for type: https://plugins.jetbrains.com/docs/intellij/tools-gradle-intellij-plugin.html#intellij-extension-type
ideaType=IU
instrumentPluginCode=true
version=9999.58-chylex
version=chylex-53
javaVersion=21
remoteRobotVersion=0.11.23
antlrVersion=4.10.1

2
gradlew vendored
View File

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

View File

@@ -8,24 +8,23 @@
package com.maddyhome.idea.vim.group.comment
import com.intellij.codeInsight.actions.MultiCaretCodeInsightActionHandler
import com.intellij.codeInsight.generation.CommentByBlockCommentHandler
import com.intellij.codeInsight.generation.CommentByLineCommentHandler
import com.intellij.lang.LanguageCommenters
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.actionSystem.IdeActions
import com.intellij.openapi.command.CommandProcessor
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.impl.EditorId
import com.intellij.openapi.editor.impl.findEditorOrNull
import com.intellij.psi.PsiDocumentManager
import com.intellij.psi.PsiFile
import com.maddyhome.idea.vim.group.onEdt
/**
* Handlers are invoked directly rather than via `ActionManager.tryToExecute` because in
* Rider / CLion Nova the action dispatch is async — `ActionCallback` signals `done` at
* dispatch, not completion — so the action's selection survived `removeSelection()` and
* the selection listener dropped IdeaVim into Visual-Line mode.
* RPC handler for [CommentaryRemoteApi].
*
* Sets selection on the backend editor and executes the platform's comment action.
* Because this runs on the backend, [com.intellij.openapi.command.CommandProcessor]
* groups all document modifications as a single undo step.
*
* The selection is set on the backend editor only — it doesn't affect the frontend
* editor's visual state, and is cleaned up immediately after the action executes.
*/
internal class CommentaryRemoteApiImpl : CommentaryRemoteApi {
@@ -36,47 +35,40 @@ internal class CommentaryRemoteApiImpl : CommentaryRemoteApi {
val startOffset = document.getLineStartOffset(startLine)
val endOffset = document.getLineEndOffset(endLine)
runCommenter(editor, startOffset, endOffset, caretOffset, lineWise = true)
executeCommentAction(editor, startOffset, endOffset, caretOffset, IdeActions.ACTION_COMMENT_LINE)
}
override suspend fun toggleBlockComment(editorId: EditorId, startOffset: Int, endOffset: Int, caretOffset: Int) =
onEdt {
val editor = editorId.findEditorOrNull() ?: return@onEdt
runCommenter(editor, startOffset, endOffset, caretOffset, lineWise = false)
// Try block comment first, fall back to line comment
if (!executeCommentAction(editor, startOffset, endOffset, caretOffset, IdeActions.ACTION_COMMENT_BLOCK)) {
executeCommentAction(editor, startOffset, endOffset, caretOffset, IdeActions.ACTION_COMMENT_LINE)
}
}
private fun runCommenter(
private fun executeCommentAction(
editor: Editor,
startOffset: Int,
endOffset: Int,
caretOffset: Int,
lineWise: Boolean,
) {
val project = editor.project ?: return
val psiFile = PsiDocumentManager.getInstance(project).getPsiFile(editor.document) ?: return
CommandProcessor.getInstance().executeCommand(project, {
ApplicationManager.getApplication().runWriteAction {
val caret = editor.caretModel.primaryCaret
caret.setSelection(startOffset, endOffset)
try {
val handler = pickHandler(psiFile, lineWise)
handler.invoke(project, editor, caret, psiFile)
handler.postInvoke()
} finally {
caret.removeSelection()
if (caretOffset >= 0) {
caret.moveToOffset(caretOffset)
}
}
actionId: String,
): Boolean {
var result = false
// Wrap selection + action + caret reset + cleanup in a single command so everything
// is a single undo step. In remdev, undo restores pre-command editor state — if
// selection is set before the command, undo would restore it. The nested tryToExecute
// command merges into this outer command.
CommandProcessor.getInstance().executeCommand(editor.project, {
editor.selectionModel.setSelection(startOffset, endOffset)
val action = ActionManager.getInstance().getAction(actionId)
result = ActionManager.getInstance().tryToExecute(action, null, editor.contentComponent, "IdeaVim", true)
.let { it.waitFor(5_000); it.isDone }
editor.selectionModel.removeSelection()
if (caretOffset >= 0) {
editor.caretModel.moveToOffset(caretOffset)
}
}, "Commentary", null)
}
private fun pickHandler(psiFile: PsiFile, lineWise: Boolean): MultiCaretCodeInsightActionHandler {
if (lineWise) return CommentByLineCommentHandler()
val commenter = LanguageCommenters.INSTANCE.forLanguage(psiFile.language)
val hasBlock = commenter?.blockCommentPrefix != null && commenter.blockCommentSuffix != null
return if (hasBlock) CommentByBlockCommentHandler() else CommentByLineCommentHandler()
return result
}
}

View File

@@ -1,87 +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.file
import com.intellij.openapi.vfs.LocalFileSystem
import com.intellij.openapi.vfs.VirtualFile
import kotlin.io.path.Path
/**
* Resolves a user-typed path prefix into a list of matching file/directory names
* for command-line completion. Directories are suffixed with `/`.
*/
internal object FileCompletionHelper {
fun listMatchingFiles(pathPrefix: String, basePath: String?): List<String> {
val (parentDir, namePrefix) = resolveParentAndPrefix(pathPrefix, basePath)
if (parentDir == null || !parentDir.isDirectory) return emptyList()
return filterAndFormat(parentDir, namePrefix, pathPrefix)
}
private fun filterAndFormat(parentDir: VirtualFile, namePrefix: String, pathPrefix: String): List<String> {
val dirPrefix = pathPrefix.substringBeforeLast('/', "")
return parentDir.children
.filter { it.name.startsWith(namePrefix, ignoreCase = true) }
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name })
.map { formatChild(it, dirPrefix) }
}
private fun formatChild(child: VirtualFile, dirPrefix: String): String {
val name = if (child.isDirectory) child.name + "/" else child.name
if (dirPrefix.isEmpty()) return name
return "$dirPrefix/$name"
}
private fun resolveParentAndPrefix(pathPrefix: String, basePath: String?): Pair<VirtualFile?, String> {
if (pathPrefix.isEmpty()) return resolveProjectRoot(basePath)
if (pathPrefix.startsWith("~/") || pathPrefix.startsWith("~\\")) return resolveHomePath(pathPrefix)
if (Path(pathPrefix).isAbsolute) return resolveAbsolutePath(pathPrefix)
return resolveRelativePath(pathPrefix, basePath)
}
private fun resolveProjectRoot(basePath: String?): Pair<VirtualFile?, String> {
val dir = basePath?.let { LocalFileSystem.getInstance().findFileByNioFile(Path(it)) }
return dir to ""
}
private fun resolveHomePath(pathPrefix: String): Pair<VirtualFile?, String> {
val home = System.getProperty("user.home")
val relativePath = pathPrefix.substring(2)
return splitDirAndPrefix(relativePath) { dirPath ->
LocalFileSystem.getInstance().findFileByNioFile(Path(home, dirPath))
} ?: (LocalFileSystem.getInstance().findFileByNioFile(Path(home)) to relativePath)
}
private fun resolveAbsolutePath(pathPrefix: String): Pair<VirtualFile?, String> {
return splitDirAndPrefix(pathPrefix) { dirPath ->
LocalFileSystem.getInstance().findFileByNioFile(Path(dirPath.ifEmpty { "/" }))
} ?: (null to "")
}
private fun resolveRelativePath(pathPrefix: String, basePath: String?): Pair<VirtualFile?, String> {
val baseDir = basePath?.let { LocalFileSystem.getInstance().findFileByNioFile(Path(it)) }
return splitDirAndPrefix(pathPrefix) { dirPath ->
baseDir?.findFileByRelativePath(dirPath)
} ?: (baseDir to pathPrefix)
}
private fun splitDirAndPrefix(
path: String,
resolveDir: (String) -> VirtualFile?,
): Pair<VirtualFile?, String>? {
val lastSlash = path.lastIndexOf('/')
if (lastSlash < 0) return null
val dirPath = path.substring(0, lastSlash)
val prefix = path.substring(lastSlash + 1)
return resolveDir(dirPath) to prefix
}
}

View File

@@ -162,35 +162,28 @@ internal class FileRemoteApiImpl : FileRemoteApi {
if (first is TextEditor) !first.editor.isDisposed else false
}
override suspend fun listFilesForCompletion(pathPrefix: String, projectId: ProjectId?): List<String> = readAction {
val basePath = projectId?.findProjectOrNull()?.basePath
FileCompletionHelper.listMatchingFiles(pathPrefix, basePath)
}
// ======================== Private helpers ========================
private fun findFile(filename: String, project: Project): VirtualFile? {
var found: VirtualFile?
if (filename.startsWith("~/") || filename.startsWith("~\\")) {
val relativePath = filename.substring(2)
val dir = System.getProperty("user.home")
logger.debug { "home dir file" }
logger.debug { "looking for $relativePath in $dir" }
return LocalFileSystem.getInstance().refreshAndFindFileByNioFile(Path(dir, relativePath))
found = LocalFileSystem.getInstance().refreshAndFindFileByNioFile(Path(dir, relativePath))
} else {
found = VirtualFileManager.getInstance().findFileByNioPath(Path(filename))
if (found == null) {
found = findByNameInContentRoots(filename, project)
if (found == null) {
found = findByNameInProject(filename, project)
}
}
}
val basePath = project.basePath
if (basePath != null) {
val baseDir = LocalFileSystem.getInstance().refreshAndFindFileByNioFile(Path(basePath))
baseDir?.findFileByRelativePath(filename)?.let { return it }
}
VirtualFileManager.getInstance().findFileByNioPath(Path(filename))?.let { return it }
findByNameInContentRoots(filename, project)?.let { return it }
findByNameInProject(filename, project)?.let { return it }
return null
return found
}
private fun buildFileInfoMessage(editor: Editor, project: Project, fullPath: Boolean): String {

View File

@@ -44,7 +44,6 @@ interface FileRemoteApi : RemoteApi<Unit> {
suspend fun selectNextFile(count: Int, projectId: ProjectId?)
suspend fun buildFileInfoMessage(editorId: EditorId, fullPath: Boolean): String?
suspend fun selectEditor(projectId: ProjectId, documentPath: String, protocol: String): Boolean
suspend fun listFilesForCompletion(pathPrefix: String, projectId: ProjectId?): List<String>
companion object {
@JvmStatic

View File

@@ -40,8 +40,6 @@
topic="com.intellij.openapi.keymap.KeymapManagerListener"/>
<listener class="com.maddyhome.idea.vim.handler.IdeaVimCorrectorKeymapChangedListener"
topic="com.intellij.openapi.keymap.KeymapManagerListener"/>
<listener class="com.maddyhome.idea.vim.listener.VimAppActivationListener"
topic="com.intellij.openapi.application.ApplicationActivationListener"/>
</applicationListeners>
<projectListeners>
@@ -171,9 +169,6 @@
<applicationService serviceImplementation="com.maddyhome.idea.vim.group.process.IjProcessGroup"
serviceInterface="com.maddyhome.idea.vim.api.VimProcessGroup"/>
<applicationService serviceImplementation="com.maddyhome.idea.vim.autocmd.AutoCmdImpl"
serviceInterface="com.maddyhome.idea.vim.api.AutoCmdService"/>
<platform.rpc.projectRemoteTopicListener
implementation="com.maddyhome.idea.vim.group.JumpRemoteTopicListener"/>

View File

@@ -8,7 +8,7 @@
<idea-plugin>
<dependencies>
<plugin id="com.intellij.modules.rider"/>
<module name="com.intellij.modules.rider"/>
</dependencies>
<projectListeners>
<listener class="com.maddyhome.idea.vim.listener.RiderActionListener"

View File

@@ -26,11 +26,11 @@ dependencies {
testImplementation("org.junit.jupiter:junit-jupiter:6.0.3")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
implementation("io.ktor:ktor-client-core:3.4.3")
implementation("io.ktor:ktor-client-cio:3.4.3")
implementation("io.ktor:ktor-client-content-negotiation:3.4.3")
implementation("io.ktor:ktor-serialization-kotlinx-json:3.4.3")
implementation("io.ktor:ktor-client-auth:3.4.3")
implementation("io.ktor:ktor-client-core:3.4.1")
implementation("io.ktor:ktor-client-cio:3.4.1")
implementation("io.ktor:ktor-client-content-negotiation:3.4.1")
implementation("io.ktor:ktor-serialization-kotlinx-json:3.4.1")
implementation("io.ktor:ktor-client-auth:3.4.1")
implementation("org.eclipse.jgit:org.eclipse.jgit:6.6.0.202305301015-r")
// This is needed for jgit to connect to ssh

View File

@@ -106,13 +106,9 @@ internal class IjVimPluginActivator : VimPluginActivator {
}
// Use getServiceIfCreated to avoid creating the service during the dispose (this is prohibited by the platform)
val commandLineService = ApplicationManager.getApplication()
ApplicationManager.getApplication()
.getServiceIfCreated(com.maddyhome.idea.vim.api.VimCommandLineService::class.java)
// VIM-4115: close() clears editor mode, KeyHandlerState.commandLineCommandBuilder, and the panel
// together. fullReset() alone only deactivates the panel; the KeyHandler singleton retains the
// stale CMD_LINE builder across disable/enable and NPEs on the next Esc.
commandLineService?.getActiveCommandLine()?.close(refocusOwningEditor = true, resetCaret = false)
commandLineService?.fullReset()
?.fullReset()
// Unregister vim actions in command mode
RegisterActions.unregisterActions()

View File

@@ -22,11 +22,11 @@ import com.intellij.openapi.util.Disposer;
import com.maddyhome.idea.vim.api.*;
import com.maddyhome.idea.vim.config.VimState;
import com.maddyhome.idea.vim.config.migration.ApplicationConfigurationMigrator;
import com.maddyhome.idea.vim.group.*;
import com.maddyhome.idea.vim.group.visual.VisualMotionGroup;
import com.maddyhome.idea.vim.group.VimNotifications;
import com.maddyhome.idea.vim.group.VimWindowGroup;
import com.maddyhome.idea.vim.history.VimHistory;
import com.maddyhome.idea.vim.macro.VimMacro;
import com.maddyhome.idea.vim.newapi.IjVimInjectorKt;
import com.maddyhome.idea.vim.newapi.IjVimSearchGroup;
import com.maddyhome.idea.vim.newapi.VimLegacyStateLoader;
import com.maddyhome.idea.vim.newapi.VimSearchGroupLegacyLoader;
import com.maddyhome.idea.vim.put.VimPut;
@@ -46,7 +46,7 @@ import org.jetbrains.annotations.Nullable;
* This is an application level plugin meaning that all open projects will share a common instance of the plugin.
* Registers and marks are shared across open projects so you can copy and paste between files of different projects.
*/
@State(name = "VimSettings", storages = {@Storage("vim_settings.xml")})
@State(name = "VimSettings", storages = {@Storage("$APP_CONFIG$/vim_settings.xml")})
public class VimPlugin implements PersistentStateComponent<Element>, Disposable {
public static final int STATE_VERSION = 7;
@@ -85,72 +85,73 @@ public class VimPlugin implements PersistentStateComponent<Element>, Disposable
}
public static @NotNull MotionGroup getMotion() {
return ((MotionGroup)VimInjectorKt.getInjector().getMotion());
public static @NotNull VimMotionGroup getMotion() {
return VimInjectorKt.getInjector().getMotion();
}
public static @NotNull ChangeGroup getChange() {
return ((ChangeGroup)VimInjectorKt.getInjector().getChangeGroup());
public static @NotNull VimChangeGroup getChange() {
return VimInjectorKt.getInjector().getChangeGroup();
}
public static @NotNull CommandGroup getCommand() {
return ((CommandGroup)VimInjectorKt.getInjector().getCommandGroup());
public static @NotNull VimCommandGroup getCommand() {
return VimInjectorKt.getInjector().getCommandGroup();
}
public static @NotNull RegisterGroup getRegister() {
return ((RegisterGroup)VimInjectorKt.getInjector().getRegisterGroup());
public static @NotNull VimRegisterGroup getRegister() {
return VimInjectorKt.getInjector().getRegisterGroup();
}
public static @NotNull VimFile getFile() {
return VimInjectorKt.getInjector().getFile();
}
public static @NotNull IjVimSearchGroup getSearch() {
return ((IjVimSearchGroup)VimInjectorKt.getInjector().getSearchGroup());
public static @NotNull VimSearchGroup getSearch() {
return VimInjectorKt.getInjector().getSearchGroup();
}
public static @Nullable IjVimSearchGroup getSearchIfCreated() {
return ApplicationManager.getApplication().getServiceIfCreated(IjVimSearchGroup.class);
public static @Nullable VimSearchGroup getSearchIfCreated() {
VimSearchGroup searchGroup = ApplicationManager.getApplication().getServiceIfCreated(VimSearchGroup.class);
return searchGroup;
}
public static @NotNull VimProcessGroup getProcess() {
return VimInjectorKt.getInjector().getProcessGroup();
}
public static @NotNull MacroGroup getMacro() {
return ((MacroGroup)VimInjectorKt.getInjector().getMacro());
public static @NotNull VimMacro getMacro() {
return VimInjectorKt.getInjector().getMacro();
}
public static @NotNull VimDigraphGroup getDigraph() {
return VimInjectorKt.getInjector().getDigraphGroup();
}
public static @NotNull HistoryGroup getHistory() {
return ((HistoryGroup)VimInjectorKt.getInjector().getHistoryGroup());
public static @NotNull VimHistory getHistory() {
return VimInjectorKt.getInjector().getHistoryGroup();
}
public static @NotNull KeyGroup getKey() {
return ((KeyGroup)VimInjectorKt.getInjector().getKeyGroup());
public static @NotNull VimKeyGroup getKey() {
return VimInjectorKt.getInjector().getKeyGroup();
}
public static @Nullable KeyGroup getKeyIfCreated() {
return ApplicationManager.getApplication().getServiceIfCreated(KeyGroup.class);
public static @Nullable VimKeyGroup getKeyIfCreated() {
return ApplicationManager.getApplication().getServiceIfCreated(VimKeyGroup.class);
}
public static @NotNull WindowGroup getWindow() {
return ((WindowGroup)VimInjectorKt.getInjector().getWindow());
public static @NotNull VimWindowGroup getWindow() {
return VimInjectorKt.getInjector().getWindow();
}
public static @NotNull EditorGroup getEditor() {
return ((EditorGroup)VimInjectorKt.getInjector().getEditorGroup());
public static @NotNull VimEditorGroup getEditor() {
return VimInjectorKt.getInjector().getEditorGroup();
}
public static @Nullable EditorGroup getEditorIfCreated() {
return ApplicationManager.getApplication().getServiceIfCreated(EditorGroup.class);
public static @Nullable VimEditorGroup getEditorIfCreated() {
return ApplicationManager.getApplication().getServiceIfCreated(VimEditorGroup.class);
}
public static @NotNull VisualMotionGroup getVisualMotion() {
return ((VisualMotionGroup)VimInjectorKt.getInjector().getVisualMotionGroup());
public static @NotNull VimVisualMotionGroup getVisualMotion() {
return VimInjectorKt.getInjector().getVisualMotionGroup();
}
public static @NotNull YankGroupBase getYank() {
@@ -336,7 +337,7 @@ public class VimPlugin implements PersistentStateComponent<Element>, Disposable
}
}
if (element.getChild("shortcut-conflicts") != null) {
getKey().loadShortcutConflictsData(element);
((VimKeyGroupBase)getKey()).loadShortcutConflictsData(element);
}
if (element.getChild("editor") != null) {
getEditor().loadEditorStateData(element);

View File

@@ -23,7 +23,6 @@ import com.intellij.openapi.editor.impl.EditorComponentImpl
import com.intellij.openapi.progress.ProcessCanceledException
import com.intellij.openapi.project.DumbAware
import com.intellij.openapi.util.Key
import com.intellij.openapi.util.SystemInfoRt
import com.intellij.openapi.util.registry.Registry
import com.intellij.ui.KeyStrokeAdapter
import com.maddyhome.idea.vim.KeyHandler
@@ -227,9 +226,8 @@ class VimShortcutKeyAction : AnAction(), DumbAware/*, LightEditCompatible*/ {
val defaultKeyStroke = KeyStrokeAdapter.getDefaultKeyStroke(inputEvent)
val strokeCache = keyStrokeCache
if (defaultKeyStroke != null) {
val fixedKeyStroke = fixKeyStroke(defaultKeyStroke)
keyStrokeCache = inputEvent.`when` to fixedKeyStroke
return fixedKeyStroke
keyStrokeCache = inputEvent.`when` to defaultKeyStroke
return defaultKeyStroke
} else if (strokeCache.first == inputEvent.`when`) {
keyStrokeCache = null to null
return strokeCache.second
@@ -239,19 +237,6 @@ class VimShortcutKeyAction : AnAction(), DumbAware/*, LightEditCompatible*/ {
return null
}
private fun fixKeyStroke(key: KeyStroke): KeyStroke {
return if (
key.modifiers and CTRL_ALT_MASK != 0 &&
key.isOnKeyRelease &&
SystemInfoRt.isWindows &&
Registry.`is`("actionSystem.fix.alt.gr", true)
) {
KeyStroke.getKeyStroke(key.keyCode, key.modifiers)
} else {
key
}
}
private fun getEditor(e: AnActionEvent): Editor? {
return e.getData(PlatformDataKeys.EDITOR)
?: if (e.getData(PlatformDataKeys.CONTEXT_COMPONENT) is ExTextField) {
@@ -332,7 +317,6 @@ class VimShortcutKeyAction : AnAction(), DumbAware/*, LightEditCompatible*/ {
).build()
private const val ACTION_ID = "VimShortcutKeyAction"
private const val CTRL_ALT_MASK = InputEvent.CTRL_DOWN_MASK or InputEvent.ALT_DOWN_MASK
private val LOG = logger<VimShortcutKeyAction>()

View File

@@ -1,11 +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.autocmd
data class AuCommand(val command: String, val group: String?, val pattern: AutoCmdPattern = AutoCmdPattern("*"))

View File

@@ -1,64 +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.autocmd
import com.maddyhome.idea.vim.api.AutoCmdService
import com.maddyhome.idea.vim.api.VimEditor
import com.maddyhome.idea.vim.api.injector
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
class AutoCmdImpl : AutoCmdService {
private val eventHandlers: MutableMap<AutoCmdEvent, MutableList<AuCommand>> = ConcurrentHashMap()
private var currentAugroup: String? = null
override fun registerEventCommand(command: String, event: AutoCmdEvent, pattern: String) {
eventHandlers.getOrPut(event.canonical) { CopyOnWriteArrayList() }
.add(AuCommand(command, currentAugroup, AutoCmdPattern(pattern)))
}
override fun clearEvents() {
val group = currentAugroup
if (group != null) {
clearAugroup(group)
return
}
eventHandlers.clear()
}
override fun startAugroup(name: String) {
currentAugroup = name
}
override fun endAugroup() {
currentAugroup = null
}
override fun clearAugroup(name: String) {
eventHandlers.values.forEach { handlers ->
handlers.removeAll { it.group == name }
}
}
override fun handleEvent(event: AutoCmdEvent, filePath: String?, editor: VimEditor?) {
val resolvedEditor = editor ?: injector.editorGroup.getSelectedEditor() ?: return
val path = filePath ?: resolvedEditor.getPath()
eventHandlers[event.canonical]?.forEach { auCommand ->
if (auCommand.pattern.matches(path)) {
executeCommand(auCommand.command, resolvedEditor)
}
}
}
private fun executeCommand(command: String, editor: VimEditor) {
val context = injector.executionContextManager.getEditorExecutionContext(editor)
injector.vimscriptExecutor.execute(command, editor, context, skipHistory = true)
}
}

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.autocmd
/**
* Vim-style file pattern for autocmd matching.
*
* Supports glob patterns:
* - `*` matches any characters except path separators
* - `**` matches any characters including path separators
* - `?` matches a single non-separator character
* - `[abc]` matches any character in the set
* - `{foo,bar}` matches "foo" or "bar"
*
* If the pattern contains `/`, it matches against the full path.
* Otherwise, it matches against only the file name.
*/
class AutoCmdPattern(val pattern: String) {
private val matchesAll = pattern == "*"
private val matchesFullPath = '/' in pattern || '\\' in pattern
private val regex: Regex by lazy { toRegex(pattern) }
fun matches(filePath: String?): Boolean {
if (matchesAll) return true
if (filePath == null) return false
val target = if (matchesFullPath) filePath else fileName(filePath)
return regex.matches(target)
}
private fun fileName(path: String): String {
return path.substringAfterLast('/').substringAfterLast('\\')
}
companion object {
private const val REGEX_SPECIAL = "\\+^$|()"
private fun toRegex(pattern: String): Regex {
val result = StringBuilder("^")
var i = 0
var inGroup = false
while (i < pattern.length) {
when (val ch = pattern[i]) {
'*' -> if (isDoubleStar(pattern, i)) {
result.append(".*")
i++
} else {
result.append("[^/\\\\]*")
}
'?' -> result.append("[^/\\\\]")
'.' -> result.append("\\.")
'{' -> {
result.append("(?:"); inGroup = true
}
'}' -> {
result.append(")"); inGroup = false
}
',' -> if (inGroup) result.append("|") else result.append(",")
'[' -> result.append("[")
']' -> result.append("]")
in REGEX_SPECIAL -> {
result.append("\\"); result.append(ch)
}
else -> result.append(ch)
}
i++
}
result.append("$")
return Regex(result.toString())
}
private fun isDoubleStar(pattern: String, i: Int): Boolean {
return i + 1 < pattern.length && pattern[i + 1] == '*'
}
}
}

View File

@@ -1,41 +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.autocmd
import com.intellij.openapi.vfs.VirtualFile
/**
* Maps IntelliJ's [com.intellij.openapi.fileTypes.FileType] name to a Vim-style filetype string
* suitable for matching against a `FileType` autocmd pattern.
*
* Most Vim filetypes are just the lowercase form of the IntelliJ name (e.g. `JAVA` → `java`,
* `Python` → `python`). A small override table covers the common cases where the conventional
* Vim name differs from IntelliJ's, so users can write `autocmd FileType python ...` and have
* it work out of the box.
*/
object IjFileTypeMapping {
private val overrides: Map<String, String> = mapOf(
"PLAIN_TEXT" to "text",
"C++" to "cpp",
"C#" to "cs",
"ObjectiveC" to "objc",
"Shell Script" to "sh",
"JavaScript" to "javascript",
"TypeScript" to "typescript",
"Vue.js" to "vue",
"Handlebars/Mustache" to "handlebars",
"CMakeLists.txt" to "cmake",
)
fun toVimFileType(virtualFile: VirtualFile?): String? {
val name = virtualFile?.fileType?.name ?: return null
return overrides[name] ?: name.lowercase()
}
}

View File

@@ -11,7 +11,6 @@ import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.diagnostic.logger
import com.intellij.openapi.extensions.ExtensionPointListener
import com.intellij.openapi.extensions.PluginDescriptor
import com.intellij.vim.api.VimInitApi
import com.maddyhome.idea.vim.api.VimExtensionRegistrator
import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.api.setToggleOption
@@ -21,6 +20,7 @@ import com.maddyhome.idea.vim.key.MappingOwner.Plugin.Companion.remove
import com.maddyhome.idea.vim.options.OptionAccessScope
import com.maddyhome.idea.vim.options.OptionDeclaredScope
import com.maddyhome.idea.vim.options.ToggleOption
import com.intellij.vim.api.VimInitApi
import com.maddyhome.idea.vim.statistic.ExtensionTracking
import com.maddyhome.idea.vim.thinapi.VimApiImpl
@@ -106,13 +106,9 @@ class VimExtensionRegistrar : VimExtensionRegistrator {
override fun enableDelayedExtensions() {
delayedExtensionEnabling.forEach {
val name = it.name ?: it.instance.name
try {
val initApi = createVimApi(name)
it.instance.init(initApi)
logger.info("IdeaVim extension '$name' initialized")
} catch (e: Throwable) {
logger.error("Failed to initialize IdeaVim extension '$name'", e)
}
val initApi = createVimApi(name)
it.instance.init(initApi)
logger.info("IdeaVim extension '$name' initialized")
}
delayedExtensionEnabling.clear()
}

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
* license that can be found in the LICENSE.txt file or at
@@ -54,13 +54,10 @@ fun MutableMap<List<KeyStroke>, NerdTreeAction>.register(mapping: String, action
*/
val navigationMappings: Map<List<KeyStroke>, NerdTreeAction> = mutableMapOf<List<KeyStroke>, NerdTreeAction>().apply {
// TODO support going [count] lines upward/downward or to line [count]
// Delegate to JTree's Swing ActionMap (same path as native arrow keys via TreeAction/DefaultTreeUI).
// This avoids ActionManager.tryToExecute which can RPC to backend in split mode,
// while preserving platform features (separator skipping, cycle scrolling, loading node handling).
register("k", NerdTreeAction.swing("selectPrevious"))
register("j", NerdTreeAction.swing("selectNext"))
register("G", NerdTreeAction.swing("selectLast"))
register("gg", NerdTreeAction.swing("selectFirst"))
register("k", NerdTreeAction.ij("Tree-selectPrevious"))
register("j", NerdTreeAction.ij("Tree-selectNext"))
register("G", NerdTreeAction.ij("Tree-selectLast"))
register("gg", NerdTreeAction.ij("Tree-selectFirst"))
// FIXME lazy loaded tree nodes are not expanded
register("NERDTreeMapOpenRecursively", "O", NerdTreeAction.ij("FullyExpandTreeNode"))
@@ -105,7 +102,7 @@ val navigationMappings: Map<List<KeyStroke>, NerdTreeAction> = mutableMapOf<List
tree.selectionPath = path
tree.scrollPathToVisible(path)
})
register("NERDTreeMapJumpParent", "p", NerdTreeAction.swing("selectParentNoCollapse"))
register("NERDTreeMapJumpParent", "p", NerdTreeAction.ij("Tree-selectParentNoCollapse"))
register(
"NERDTreeMapJumpFirstChild",
"K",
@@ -132,8 +129,8 @@ val navigationMappings: Map<List<KeyStroke>, NerdTreeAction> = mutableMapOf<List
tree.scrollPathToVisible(path)
},
)
register("NERDTreeMapJumpNextSibling", "<C-J>", NerdTreeAction.swing("selectNextSibling"))
register("NERDTreeMapJumpPrevSibling", "<C-K>", NerdTreeAction.swing("selectPreviousSibling"))
register("NERDTreeMapJumpNextSibling", "<C-J>", NerdTreeAction.ij("Tree-selectNextSibling"))
register("NERDTreeMapJumpPrevSibling", "<C-K>", NerdTreeAction.ij("Tree-selectPreviousSibling"))
register("/", NerdTreeAction.ij("SpeedSearch"))
register("<ESC>", NerdTreeAction { _, _ -> })

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
* license that can be found in the LICENSE.txt file or at
@@ -11,7 +11,6 @@ package com.maddyhome.idea.vim.extension.nerdtree
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.application.ApplicationManager
import com.intellij.ui.SwingActionDelegate
import com.intellij.ui.treeStructure.Tree
import com.maddyhome.idea.vim.VimPlugin
import com.maddyhome.idea.vim.api.ExecutionContext
@@ -48,11 +47,5 @@ class NerdTreeAction(val action: (AnActionEvent, Tree) -> Unit) {
* @return An [NerdTreeAction] that runs the specified action when triggered.
*/
fun ij(id: String) = NerdTreeAction { event, _ -> callAction(null, id, event.dataContext.vim) }
/**
* Creates an [NerdTreeAction] that delegates to the JTree's Swing ActionMap.
*/
fun swing(swingActionId: String) =
NerdTreeAction { _, tree -> SwingActionDelegate.performAction(swingActionId, tree) }
}
}

View File

@@ -9,8 +9,6 @@
package com.maddyhome.idea.vim.extension.replacewithregister
import com.intellij.vim.api.VimInitApi
import com.intellij.vim.api.scopes.nmapPluginAction
import com.intellij.vim.api.scopes.vmapPluginAction
import com.maddyhome.idea.vim.extension.VimExtension
internal class ReplaceWithRegister : VimExtension {
@@ -19,15 +17,21 @@ internal class ReplaceWithRegister : VimExtension {
override fun init(initApi: VimInitApi) {
initApi.mappings {
nmapPluginAction("gr", RWR_OPERATOR, keepDefaultMapping = true) {
// Step 1: Non-recursive <Plug> → action mappings
nnoremap(RWR_OPERATOR) {
rewriteMotion()
}
nmapPluginAction("grr", RWR_LINE, keepDefaultMapping = true) {
nnoremap(RWR_LINE) {
rewriteLine()
}
vmapPluginAction("gr", RWR_VISUAL, keepDefaultMapping = true) {
vnoremap(RWR_VISUAL) {
rewriteVisual()
}
// Step 2: Recursive key → <Plug> mappings
nmap("gr", RWR_OPERATOR)
nmap("grr", RWR_LINE)
vmap("gr", RWR_VISUAL)
}
initApi.commands {

View File

@@ -16,23 +16,27 @@ import com.intellij.vim.api.models.Mode
import com.intellij.vim.api.models.Range
import com.intellij.vim.api.models.TextType
import com.intellij.vim.api.scopes.editor.caret.CaretTransaction
import com.intellij.vim.api.scopes.nmapPluginAction
import com.intellij.vim.api.scopes.vmapPluginAction
private const val PLUGIN_NAME: String = "ReplaceWithRegisterNew"
@VimPlugin(name = PLUGIN_NAME)
fun VimInitApi.init() {
mappings {
nmapPluginAction("gr", RWR_OPERATOR, keepDefaultMapping = true) {
// Step 1: Non-recursive <Plug> → action mappings
nnoremap(RWR_OPERATOR) {
rewriteMotion()
}
nmapPluginAction("grr", RWR_LINE, keepDefaultMapping = true) {
nnoremap(RWR_LINE) {
rewriteLine()
}
vmapPluginAction("gr", RWR_VISUAL, keepDefaultMapping = true) {
vnoremap(RWR_VISUAL) {
rewriteVisual()
}
// Step 2: Recursive key → <Plug> mappings
nmap("gr", RWR_OPERATOR)
nmap("grr", RWR_LINE)
vmap("gr", RWR_VISUAL)
}
commands {

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2003-2023 The IdeaVim authors
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
@@ -7,273 +7,181 @@
*/
package com.maddyhome.idea.vim.extension.textobjindent
import com.intellij.openapi.editor.Caret
import com.maddyhome.idea.vim.KeyHandler.Companion.getInstance
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.MappingMode
import com.maddyhome.idea.vim.command.OperatorArguments
import com.maddyhome.idea.vim.command.TextObjectVisualType
import com.maddyhome.idea.vim.common.TextRange
import com.maddyhome.idea.vim.extension.ExtensionHandler
import com.intellij.vim.api.VimApi
import com.intellij.vim.api.VimInitApi
import com.intellij.vim.api.scopes.TextObjectRange
import com.maddyhome.idea.vim.extension.VimExtension
import com.maddyhome.idea.vim.extension.VimExtensionFacade.putExtensionHandlerMapping
import com.maddyhome.idea.vim.extension.VimExtensionFacade.putKeyMapping
import com.maddyhome.idea.vim.group.visual.vimSetSelection
import com.maddyhome.idea.vim.handler.TextObjectActionHandler
import com.maddyhome.idea.vim.helper.moveToInlayAwareOffset
import com.maddyhome.idea.vim.listener.SelectionVimListenerSuppressor
import com.maddyhome.idea.vim.newapi.IjVimCaret
import com.maddyhome.idea.vim.newapi.IjVimEditor
import com.maddyhome.idea.vim.state.mode.Mode.OP_PENDING
import com.maddyhome.idea.vim.state.mode.Mode.VISUAL
import kotlin.math.max
/**
* Port of vim-indent-object:
* [vim-indent-object](https://github.com/michaeljsmith/vim-indent-object)
*
*
*
*
* vim-indent-object provides these text objects based on the cursor line's indentation:
*
*
* * `ai` **A**n **I**ndentation level and line above.
* * `ii` **I**nner **I**ndentation level (no line above).
* * `aI` **A**n **I**ndentation level and lines above and below.
* * `iI` **I**nner **I**ndentation level (no lines above and below). Synonym of `ii`
*
*
*
* See also the reference manual for more details at:
* [indent-object.txt](https://github.com/michaeljsmith/vim-indent-object/blob/master/doc/indent-object.txt)
*/
class VimIndentObject : VimExtension {
override fun getName(): String {
return "textobj-indent"
}
override fun getName(): String = "textobj-indent"
override fun init() {
putExtensionHandlerMapping(
MappingMode.XO, injector.parser.parseKeys("<Plug>textobj-indent-ai"), getOwner(),
IndentObject(true, false), false
)
putExtensionHandlerMapping(
MappingMode.XO, injector.parser.parseKeys("<Plug>textobj-indent-aI"), getOwner(),
IndentObject(true, true), false
)
putExtensionHandlerMapping(
MappingMode.XO, injector.parser.parseKeys("<Plug>textobj-indent-ii"), getOwner(),
IndentObject(false, false), false
)
putKeyMapping(
MappingMode.XO,
injector.parser.parseKeys("ai"),
getOwner(),
injector.parser.parseKeys("<Plug>textobj-indent-ai"),
true
)
putKeyMapping(
MappingMode.XO,
injector.parser.parseKeys("aI"),
getOwner(),
injector.parser.parseKeys("<Plug>textobj-indent-aI"),
true
)
putKeyMapping(
MappingMode.XO,
injector.parser.parseKeys("ii"),
getOwner(),
injector.parser.parseKeys("<Plug>textobj-indent-ii"),
true
)
}
internal class IndentObject(val includeAbove: Boolean, val includeBelow: Boolean) : ExtensionHandler {
override val isRepeatable: Boolean
get() = false
internal class IndentObjectHandler(val includeAbove: Boolean, val includeBelow: Boolean) :
TextObjectActionHandler() {
override fun getRange(
editor: VimEditor,
caret: ImmutableVimCaret,
context: ExecutionContext,
count: Int,
rawCount: Int,
): TextRange {
val charSequence = (editor as IjVimEditor).editor.getDocument().getCharsSequence()
val caretOffset = (caret as IjVimCaret).caret.getOffset()
// Part 1: Find the start of the caret line.
var caretLineStartOffset = caretOffset
var accumulatedWhitespace = 0
while (--caretLineStartOffset >= 0) {
val ch = charSequence.get(caretLineStartOffset)
if (ch == ' ' || ch == '\t') {
++accumulatedWhitespace
} else if (ch == '\n') {
++caretLineStartOffset
break
} else {
accumulatedWhitespace = 0
}
}
if (caretLineStartOffset < 0) {
caretLineStartOffset = 0
}
// `caretLineStartOffset` points to the first character in the line where the caret is located.
// Part 2: Compute the indentation level of the caret line.
// This is done as a separate step so that it works even when the caret is inside the indentation.
var offset = caretLineStartOffset
var indentSize = 0
while (offset < charSequence.length) {
val ch = charSequence.get(offset)
if (ch == ' ' || ch == '\t') {
++indentSize
++offset
} else {
break
}
}
// `indentSize` contains the amount of indent to be used for the text object range to be returned.
var upperBoundaryOffset: Int? = null
// Part 3: Find a line above the caret line, that has an indentation lower than `indentSize`.
var pos1 = caretLineStartOffset - 1
var isUpperBoundaryFound = false
while (upperBoundaryOffset == null) {
// 3.1: Going backwards from `caretLineStartOffset`, find the first non-whitespace character.
while (--pos1 >= 0) {
val ch = charSequence.get(pos1)
if (ch != ' ' && ch != '\t' && ch != '\n') {
break
}
}
// 3.2: Find the indent size of the line with this non-whitespace character and check against `indentSize`.
accumulatedWhitespace = 0
while (--pos1 >= 0) {
val ch = charSequence.get(pos1)
if (ch == ' ' || ch == '\t') {
++accumulatedWhitespace
} else if (ch == '\n') {
if (accumulatedWhitespace < indentSize) {
upperBoundaryOffset = pos1 + 1
isUpperBoundaryFound = true
}
break
} else {
accumulatedWhitespace = 0
}
}
if (pos1 < 0) {
// Reached start of the buffer.
upperBoundaryOffset = 0
isUpperBoundaryFound = accumulatedWhitespace < indentSize
}
}
// Now `upperBoundaryOffset` marks the beginning of an `ai` text object.
if (isUpperBoundaryFound && !includeAbove) {
while (++upperBoundaryOffset < charSequence.length) {
val ch = charSequence.get(upperBoundaryOffset)
if (ch == '\n') {
++upperBoundaryOffset
break
}
}
while (charSequence.get(upperBoundaryOffset) == '\n') {
++upperBoundaryOffset
}
}
// Part 4: Find the start of the caret line.
var caretLineEndOffset = caretOffset
while (++caretLineEndOffset < charSequence.length) {
val ch = charSequence.get(caretLineEndOffset)
if (ch == '\n') {
++caretLineEndOffset
break
}
}
// `caretLineEndOffset` points to the first charater in the line below caret line.
var lowerBoundaryOffset: Int? = null
// Part 5: Find a line below the caret line, that has an indentation lower than `indentSize`.
var pos2 = caretLineEndOffset - 1
var isLowerBoundaryFound = false
while (lowerBoundaryOffset == null) {
var accumulatedWhitespace2 = 0
var lastNewlinePos = caretLineEndOffset - 1
var isInIndent = true
while (++pos2 < charSequence.length) {
val ch = charSequence.get(pos2)
if (isIndentChar(ch) && isInIndent) {
++accumulatedWhitespace2
} else if (ch == '\n') {
accumulatedWhitespace2 = 0
lastNewlinePos = pos2
isInIndent = true
} else {
if (isInIndent && accumulatedWhitespace2 < indentSize) {
lowerBoundaryOffset = lastNewlinePos
isLowerBoundaryFound = true
break
}
isInIndent = false
}
}
if (pos2 >= charSequence.length) {
// Reached end of the buffer.
lowerBoundaryOffset = charSequence.length - 1
}
}
// Now `lowerBoundaryOffset` marks the end of an `ii` text object.
if (isLowerBoundaryFound && includeBelow) {
while (++lowerBoundaryOffset < charSequence.length) {
val ch = charSequence.get(lowerBoundaryOffset)
if (ch == '\n') {
break
}
}
}
return TextRange(upperBoundaryOffset, lowerBoundaryOffset)
}
override val visualType: TextObjectVisualType
get() = TextObjectVisualType.LINE_WISE
private fun isIndentChar(ch: Char): Boolean {
return ch == ' ' || ch == '\t'
}
}
override fun execute(editor: VimEditor, context: ExecutionContext, operatorArguments: OperatorArguments) {
val vimEditor = editor as IjVimEditor
val keyHandlerState = getInstance().keyHandlerState
val textObjectHandler = IndentObjectHandler(includeAbove, includeBelow)
if (editor.mode !is OP_PENDING) {
val count0 = operatorArguments.count0
editor.editor.getCaretModel().runForEachCaret { caret: Caret ->
val range = textObjectHandler.getRange(vimEditor, IjVimCaret(caret), context, max(1, count0), count0)
SelectionVimListenerSuppressor.lock().use { ignored ->
if (editor.mode is VISUAL) {
IjVimCaret(caret).vimSetSelection(range.startOffset, range.endOffset - 1, true)
} else {
caret.moveToInlayAwareOffset(range.startOffset)
}
}
}
} else {
keyHandlerState.commandBuilder.addAction(textObjectHandler)
}
override fun init(initApi: VimInitApi) {
initApi.textObjects {
register("ai") { _ -> findIndentRange(includeAbove = true, includeBelow = false) }
register("aI") { _ -> findIndentRange(includeAbove = true, includeBelow = true) }
register("ii") { _ -> findIndentRange(includeAbove = false, includeBelow = false) }
}
}
}
private suspend fun VimApi.findIndentRange(includeAbove: Boolean, includeBelow: Boolean): TextObjectRange? {
val charSequence = editor { read { text } }
val caretOffset = editor { read { withPrimaryCaret { offset } } }
// Part 1: Find the start of the caret line.
var caretLineStartOffset = caretOffset
var accumulatedWhitespace = 0
while (--caretLineStartOffset >= 0) {
val ch = charSequence[caretLineStartOffset]
if (ch == ' ' || ch == '\t') {
++accumulatedWhitespace
} else if (ch == '\n') {
++caretLineStartOffset
break
} else {
accumulatedWhitespace = 0
}
}
if (caretLineStartOffset < 0) {
caretLineStartOffset = 0
}
// `caretLineStartOffset` points to the first character in the line where the caret is located.
// Part 2: Compute the indentation level of the caret line.
// This is done as a separate step so that it works even when the caret is inside the indentation.
var offset = caretLineStartOffset
var indentSize = 0
while (offset < charSequence.length) {
val ch = charSequence[offset]
if (ch == ' ' || ch == '\t') {
++indentSize
++offset
} else {
break
}
}
// `indentSize` contains the amount of indent to be used for the text object range to be returned.
var upperBoundaryOffset: Int? = null
// Part 3: Find a line above the caret line, that has an indentation lower than `indentSize`.
var pos1 = caretLineStartOffset - 1
var isUpperBoundaryFound = false
while (upperBoundaryOffset == null) {
// 3.1: Going backwards from `caretLineStartOffset`, find the first non-whitespace character.
while (--pos1 >= 0) {
val ch = charSequence[pos1]
if (ch != ' ' && ch != '\t' && ch != '\n') {
break
}
}
// 3.2: Find the indent size of the line with this non-whitespace character and check against `indentSize`.
accumulatedWhitespace = 0
while (--pos1 >= 0) {
val ch = charSequence[pos1]
if (ch == ' ' || ch == '\t') {
++accumulatedWhitespace
} else if (ch == '\n') {
if (accumulatedWhitespace < indentSize) {
upperBoundaryOffset = pos1 + 1
isUpperBoundaryFound = true
}
break
} else {
accumulatedWhitespace = 0
}
}
if (pos1 < 0) {
// Reached start of the buffer.
upperBoundaryOffset = 0
isUpperBoundaryFound = accumulatedWhitespace < indentSize
}
}
// Now `upperBoundaryOffset` marks the beginning of an `ai` text object.
if (isUpperBoundaryFound && !includeAbove) {
while (++upperBoundaryOffset < charSequence.length) {
val ch = charSequence[upperBoundaryOffset]
if (ch == '\n') {
++upperBoundaryOffset
break
}
}
while (charSequence[upperBoundaryOffset] == '\n') {
++upperBoundaryOffset
}
}
// Part 4: Find the end of the caret line.
var caretLineEndOffset = caretOffset
while (++caretLineEndOffset < charSequence.length) {
val ch = charSequence[caretLineEndOffset]
if (ch == '\n') {
++caretLineEndOffset
break
}
}
// `caretLineEndOffset` points to the first character in the line below caret line.
var lowerBoundaryOffset: Int? = null
// Part 5: Find a line below the caret line, that has an indentation lower than `indentSize`.
var pos2 = caretLineEndOffset - 1
var isLowerBoundaryFound = false
while (lowerBoundaryOffset == null) {
var accumulatedWhitespace2 = 0
var lastNewlinePos = caretLineEndOffset - 1
var isInIndent = true
while (++pos2 < charSequence.length) {
val ch = charSequence[pos2]
if (isIndentChar(ch) && isInIndent) {
++accumulatedWhitespace2
} else if (ch == '\n') {
accumulatedWhitespace2 = 0
lastNewlinePos = pos2
isInIndent = true
} else {
if (isInIndent && accumulatedWhitespace2 < indentSize) {
lowerBoundaryOffset = lastNewlinePos
isLowerBoundaryFound = true
break
}
isInIndent = false
}
}
if (pos2 >= charSequence.length) {
// Reached end of the buffer.
lowerBoundaryOffset = charSequence.length - 1
}
}
// Now `lowerBoundaryOffset` marks the end of an `ii` text object.
if (isLowerBoundaryFound && includeBelow) {
while (++lowerBoundaryOffset < charSequence.length) {
val ch = charSequence[lowerBoundaryOffset]
if (ch == '\n') {
break
}
}
}
// Convert offsets to line numbers for LineWise result
val startLine = editor { read { getLine(upperBoundaryOffset).number } }
val endLine = editor { read { getLine(lowerBoundaryOffset).number } }
return TextObjectRange.LineWise(startLine, endLine)
}
private fun isIndentChar(ch: Char): Boolean = ch == ' ' || ch == '\t'

View File

@@ -13,7 +13,6 @@ import com.intellij.openapi.project.ProjectManager
import com.intellij.openapi.wm.ToolWindowManager
import com.intellij.openapi.wm.ToolWindowType
import com.maddyhome.idea.vim.extension.VimExtension
import com.maddyhome.idea.vim.helper.EditorHelper
import java.awt.Component
import java.awt.KeyboardFocusManager
import java.beans.PropertyChangeListener
@@ -32,7 +31,7 @@ internal class ToolWindowNavEverywhere : VimExtension {
val oldFocusOwner = evt.oldValue as? JComponent
val dispatcher = service<ToolWindowNavDispatcher>()
if (newFocusOwner != null && isInsideToolWindow(newFocusOwner) && !isPythonConsoleComponent(newFocusOwner)) {
if (newFocusOwner != null && isInsideToolWindow(newFocusOwner)) {
dispatcher.register(newFocusOwner)
}
@@ -52,18 +51,6 @@ internal class ToolWindowNavEverywhere : VimExtension {
super.dispose()
}
private fun isPythonConsoleComponent(component: Component): Boolean {
for (project in ProjectManager.getInstance().openProjects) {
if (project.isDisposed) continue
val toolWindowManager = ToolWindowManager.getInstance(project)
val tw = toolWindowManager.getToolWindow(EditorHelper.PYTHON_CONSOLE_TOOL_WINDOW_ID) ?: continue
if (SwingUtilities.isDescendingFrom(component, tw.component)) {
return true
}
}
return false
}
private fun isInsideToolWindow(component: Component): Boolean {
for (project in ProjectManager.getInstance().openProjects) {
if (project.isDisposed) continue

View File

@@ -23,7 +23,6 @@ import com.intellij.psi.codeStyle.CodeStyleManager
import com.intellij.psi.util.PsiUtilBase
import com.maddyhome.idea.vim.EventFacade
import com.maddyhome.idea.vim.api.ExecutionContext
import com.maddyhome.idea.vim.api.Options
import com.maddyhome.idea.vim.api.VimCaret
import com.maddyhome.idea.vim.api.VimChangeGroupBase
import com.maddyhome.idea.vim.api.VimEditor
@@ -32,15 +31,11 @@ import com.maddyhome.idea.vim.common.TextRange
import com.maddyhome.idea.vim.group.change.ChangeRemoteApi
import com.maddyhome.idea.vim.group.format.FormatRemoteApi
import com.maddyhome.idea.vim.handler.commandContinuation
import com.maddyhome.idea.vim.helper.CodeWrapper
import com.maddyhome.idea.vim.helper.CommentLeaderParser
import com.maddyhome.idea.vim.helper.inInsertMode
import com.maddyhome.idea.vim.key.KeyHandlerKeeper
import com.maddyhome.idea.vim.listener.VimInsertListener
import com.maddyhome.idea.vim.newapi.IjVimEditor
import com.maddyhome.idea.vim.newapi.ij
import com.maddyhome.idea.vim.newapi.ijOptions
import com.maddyhome.idea.vim.options.OptionAccessScope
import com.maddyhome.idea.vim.state.mode.Mode
import com.maddyhome.idea.vim.undo.VimKeyBasedUndoService
import com.maddyhome.idea.vim.undo.VimTimestampBasedUndoService
@@ -134,8 +129,6 @@ class ChangeGroup : VimChangeGroupBase() {
}
override fun repeatInsertText(editor: VimEditor, context: ExecutionContext, count: Int) {
if (count <= 0) return
val ijEditor = (editor as IjVimEditor).editor
val editorId = ijEditor.editorId()
@@ -159,39 +152,6 @@ class ChangeGroup : VimChangeGroupBase() {
injector.application.runWriteAction {
CodeStyleManager.getInstance(project).reformatText(file, listOf(textRange))
}
wrapText(editor, start, end)
}
private fun wrapText(editor: IjVimEditor, start: Int, end: Int) {
val textwidth = injector.ijOptions(editor).textwidth
if (textwidth <= 0) {
return
}
wrapTextToWidth(editor, start, end, textwidth)
}
private fun wrapTextToWidth(editor: IjVimEditor, start: Int, end: Int, width: Int) {
val ijEditor = editor.editor
val document = ijEditor.document
val text = document.getText(com.intellij.openapi.util.TextRange.create(start, end))
val commentsValue = injector.optionGroup
.getOptionValue(Options.comments, OptionAccessScope.LOCAL(editor))
.value
val wrapper = CodeWrapper(
width = width,
tabWidth = ijEditor.settings.getTabSize(ijEditor.project),
leaders = CommentLeaderParser.parse(commentsValue),
)
val wrapped = wrapper.wrap(text)
if (wrapped == text) {
return
}
injector.application.runWriteAction {
document.replaceString(start, end, wrapped)
}
}
override fun autoIndentRange(

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.group
import com.intellij.lang.LanguageCommenters
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.fileEditor.FileDocumentManager
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.psi.PsiDocumentManager
import com.maddyhome.idea.vim.api.Options
import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.autocmd.IjFileTypeMapping
import com.maddyhome.idea.vim.helper.CommenterMarkers
import com.maddyhome.idea.vim.helper.CommenterToComments
import com.maddyhome.idea.vim.helper.FiletypePresets
import com.maddyhome.idea.vim.newapi.vim
import com.maddyhome.idea.vim.vimscript.model.datatypes.VimString
/**
* Resolves a buffer-local `'comments'` value when an editor is created.
*
* Delegates to [OptionGroup.setBufferLocalDefaultIfUntouched], which preserves
* any value the user explicitly set via `.ideavimrc` or interactive `:set`.
*/
object CommentsOptionInitializer {
fun initializeForEditor(editor: Editor) {
val optionGroup = injector.optionGroup as? OptionGroup ?: return
val resolved = resolveComments(editor) ?: return
optionGroup.setBufferLocalDefaultIfUntouched(
Options.comments,
editor.vim,
VimString(resolved),
)
}
private fun resolveComments(editor: Editor): String? {
val filetypeName = filetypeOf(editor) ?: return null
return FiletypePresets.presetFor(filetypeName) ?: deriveFromCommenter(editor)
}
private fun filetypeOf(editor: Editor): String? {
val virtualFile: VirtualFile = FileDocumentManager.getInstance().getFile(editor.document) ?: return null
return IjFileTypeMapping.toVimFileType(virtualFile)
}
private fun deriveFromCommenter(editor: Editor): String? {
val project = editor.project ?: return null
val psiFile = PsiDocumentManager.getInstance(project).getPsiFile(editor.document) ?: return null
val commenter = LanguageCommenters.INSTANCE.forLanguage(psiFile.language) ?: return null
return CommenterToComments.derive(
CommenterMarkers(
linePrefix = commenter.lineCommentPrefix,
blockPrefix = commenter.blockCommentPrefix,
blockSuffix = commenter.blockCommentSuffix,
),
)
}
}

View File

@@ -55,7 +55,7 @@ import static com.maddyhome.idea.vim.newapi.IjVimInjectorKt.ijOptions;
/**
* @author vlan
*/
@State(name = "VimEditorSettings", storages = {@Storage(value = "vim_settings.xml")})
@State(name = "VimEditorSettings", storages = {@Storage(value = "$APP_CONFIG$/vim_settings.xml")})
public class EditorGroup implements PersistentStateComponent<Element>, VimEditorGroup {
public static final @NonNls String EDITOR_STORE_ELEMENT = "editor";
private final CaretListener myLineNumbersCaretListener = new CaretListener() {
@@ -321,18 +321,6 @@ public class EditorGroup implements PersistentStateComponent<Element>, VimEditor
return null;
}
@Override
public @Nullable VimEditor getSelectedEditor() {
for (Project project : ProjectManager.getInstance().getOpenProjects()) {
if (project.isDisposed()) continue;
Editor selectedEditor = FileEditorManager.getInstance(project).getSelectedTextEditor();
if (selectedEditor != null) {
return new IjVimEditor(selectedEditor);
}
}
return null;
}
@Override
public @NotNull Collection<VimEditor> getEditorsRaw() {
return getLocalEditors().map(IjVimEditor::new).collect(Collectors.toList());

View File

@@ -164,10 +164,6 @@ class IjFileGroup : VimFileBase() {
return if (editor != null) editor.vim else null
}
override fun listFilesForCompletion(pathPrefix: String, context: ExecutionContext): List<String> {
return rpc { FileRemoteApi.getInstance().listFilesForCompletion(pathPrefix, extractProjectId(context)) }
}
override fun getProjectId(project: Any): String {
require(project is Project)
return project.projectId().serializeToString()

View File

@@ -42,7 +42,6 @@ internal class JumpRemoteTopicListener : ProjectRemoteTopicListener<JumpInfo> {
if (event.added) {
jumpService.addJump(projectId, jump, true)
injector.markService.setJumpMark(event.filepath, event.protocol, event.line, event.col)
} else {
jumpService.removeJump(projectId, jump)
}

View File

@@ -28,8 +28,6 @@ import com.maddyhome.idea.vim.action.change.LazyVimCommand;
import com.maddyhome.idea.vim.api.*;
import com.maddyhome.idea.vim.command.MappingMode;
import com.maddyhome.idea.vim.extension.VimExtensionFacade;
import com.maddyhome.idea.vim.helper.EditorHelper;
import com.maddyhome.idea.vim.helper.EditorHelperRt;
import com.maddyhome.idea.vim.helper.ShortcutHelper;
import com.maddyhome.idea.vim.key.*;
import com.maddyhome.idea.vim.newapi.IjNativeAction;
@@ -53,7 +51,7 @@ import static java.util.stream.Collectors.toList;
/**
* @author vlan
*/
@State(name = "VimKeySettings", storages = {@Storage(value = "vim_settings.xml")})
@State(name = "VimKeySettings", storages = {@Storage(value = "$APP_CONFIG$/vim_settings.xml")})
public class KeyGroup extends VimKeyGroupBase implements PersistentStateComponent<Element> {
public static final @NonNls String SHORTCUT_CONFLICTS_ELEMENT = "shortcut-conflicts";
private static final @NonNls String SHORTCUT_CONFLICT_ELEMENT = "shortcut-conflict";
@@ -182,15 +180,9 @@ public class KeyGroup extends VimKeyGroupBase implements PersistentStateComponen
@Override
public void registerRequiredShortcutKeys(@NotNull VimEditor editor) {
Editor ijEditor = ((IjVimEditor)editor).getEditor();
if (EditorHelperRt.isIdeaVimDisabledHere(ijEditor)) return;
var vf = editor.getVirtualFile();
if (vf != null && vf.getPath().contains(EditorHelper.PYTHON_CONSOLE_FILE_NAME)) return;
EventFacade.getInstance().registerCustomShortcutSet(VimShortcutKeyAction.getInstance(),
ShortcutHelper.toShortcutSet(getRequiredShortcutKeys()),
ijEditor.getContentComponent());
((IjVimEditor)editor).getEditor().getContentComponent());
}
@Override

View File

@@ -146,22 +146,6 @@ class OptionGroup : VimOptionGroupBase(), IjVimOptionGroup, InternalOptionValueA
super.setOptionValueInternal(option, scope, value)
}
/**
* Sets the buffer-local value of [option] as a Vim default — but only if the
* current value is still a [OptionValue.Default]. Preserves any value the user
* explicitly set via `.ideavimrc` or interactive `:set`/`:setlocal`.
*/
fun <T : VimDataType> setBufferLocalDefaultIfUntouched(
option: Option<T>,
editor: VimEditor,
value: T,
) {
val scope = OptionAccessScope.LOCAL(editor)
val current = getOptionValueInternal(option, scope)
if (current !is OptionValue.Default) return
setOptionValueInternal(option, scope, OptionValue.Default(value))
}
companion object {
fun editorReleased(editor: Editor) {
// Vim always has at least one window; it's not possible to close it. Editing a new file will open a new buffer in

View File

@@ -21,6 +21,7 @@ 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.fileEditor.FileDocumentManager
import com.intellij.openapi.util.Key
import com.intellij.openapi.util.UserDataHolder
import com.intellij.openapi.util.removeUserData
@@ -118,7 +119,11 @@ internal abstract class OctopusHandler(private val nextHandler: EditorActionHand
private fun executeInInvokeLater(editor: Editor): Boolean {
// Currently we have a workaround for the PY console VIM-3157
if (EditorHelper.isPythonConsole(editor)) return false
val fileName = FileDocumentManager.getInstance().getFile(editor.document)?.name
if (
fileName == "Python Console.py" || // This is the name in 232+
fileName == "Python Console" // This is the name in 231
) return false
return (editor.caretModel as? CaretModelImpl)?.isIteratingOverCarets ?: true
}
@@ -236,17 +241,13 @@ internal class VimEscHandler(nextHandler: EditorActionHandler) : VimKeyHandler(n
/**
* 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.
* designer 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>"

View File

@@ -13,7 +13,6 @@ import com.intellij.openapi.editor.*;
import com.intellij.openapi.editor.ex.util.EditorUtil;
import com.intellij.openapi.editor.impl.EditorImpl;
import com.intellij.openapi.fileEditor.FileDocumentManager;
import com.intellij.openapi.util.Key;
import com.intellij.openapi.util.SystemInfo;
import com.intellij.openapi.util.registry.Registry;
import com.intellij.openapi.vfs.VirtualFile;
@@ -46,9 +45,6 @@ public class EditorHelper {
// mitigates the visible area bouncing around too much and even pushing the cursor line off screen with large
// multiline rendered doc comments, while still providing some visibility of the block inlay (e.g. Rider's single line
// Code Vision)
public static final String PYTHON_CONSOLE_FILE_NAME = "Python Console.py";
public static final String PYTHON_CONSOLE_TOOL_WINDOW_ID = "Python Console";
private static final int BLOCK_INLAY_MAX_LINE_HEIGHT = 3;
public static @NotNull Rectangle getVisibleArea(final @NotNull Editor editor) {
@@ -683,48 +679,6 @@ public class EditorHelper {
return editor.getEditorKind() == EditorKind.DIFF;
}
/**
* Checks if the editor is the Python console, so we can disable Vim features
*/
public static boolean isPythonConsole(@NotNull Editor editor) {
if (editor.getVirtualFile() == null) return false;
// In split mode, the projected VirtualFile may have a different getName() result,
// so we also check getPath() to reliably detect the Python console.
return editor.getVirtualFile().getName().contains(PYTHON_CONSOLE_FILE_NAME)
|| editor.getVirtualFile().getPath().contains(PYTHON_CONSOLE_FILE_NAME);
}
/**
* Checks if the editor is hosted in the Commit tool window, so we can enable Vim features
*/
public static boolean isCommitWindowEditor(@NotNull Editor editor) {
// The best heuristic we have is the file name, which is Dummy.txt
var file = editor.getVirtualFile();
return file != null && file.getName().contains("Dummy.txt");
}
/**
* Checks if the editor is a Kotlin class file decompiled to a Java file, so we can enable Vim features
* <p>
* The platform changed the implementation of decompiling a Kotlin .class file to Java in 2026.2. Previously, it
* used a dummy virtual file implementation. Now it uses an instance of {@link LightVirtualFile}. Typically, this
* means an in-memory file that we don't want to have Vim features for, but in this case, we do.
* </p>
* <p>
* To test, open a .class file generated from a Kotlin file. Then use the "Decompile to Java" action to create a
* separate (in-memory) `.decompiled.java` file. Java-based .class files are decompiled directly in the document for
* the .class file, so the editor is always backed by a valid file.
* </p>
* <p>
* Perhaps a future implementation would have an allow-list for {@link VirtualFile#getFileType()} and allow "JAVA"?
* </p>
*/
public static boolean isKotlinClassDecompiledToJavaFile(@NotNull Editor editor) {
@SuppressWarnings("deprecation") @Nullable Key<?> key = Key.findKeyByName("IS_KOTLIN_DECOMPILED_FILE");
var file = editor.getVirtualFile();
return file != null && key != null && editor.getVirtualFile().getUserData(key) == Boolean.TRUE;
}
/**
* Checks if the document in the editor is modified.
*/

View File

@@ -17,7 +17,6 @@ import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.EditorKind
import com.intellij.openapi.editor.ex.util.EditorUtil
import com.intellij.openapi.fileEditor.ex.FileEditorManagerEx
import com.intellij.openapi.ui.popup.util.PopupUtil
import com.intellij.util.ui.table.JBTableRowEditor
import com.maddyhome.idea.vim.VimPlugin
import com.maddyhome.idea.vim.api.StringListOptionValue
@@ -54,7 +53,7 @@ internal val Editor.isIdeaVimDisabledHere: Boolean
!ClientId.isCurrentlyUnderLocalId || // CWM-927
(ideaVimDisabledForSingleLine(ideaVimSupportValue) && isSingleLine()) ||
IdeaVimDisablerExtensionPoint.isDisabledForEditor(this) ||
!isAllowedFileEditor()
isNotFileEditorExceptAllowed()
}
/**
@@ -66,21 +65,18 @@ internal val Editor.isIdeaVimDisabledHere: Boolean
* Here are issues when non-file editors were supported:
* AI Chat VIM-3786
* Debug evaluate console VIM-3929
* Python console - VIM-4172
*
* We do want to support Vim actions in some windows, such as the commit window, diff windows, and decompiled Java
* files. We don't support the Python console.
* However, we still support IdeaVim in a commit window because it works fine there, and removing vim from this place will
* be quite a visible change for users.
* We detect the commit window by the name of the editor (Dummy.txt). If this causes issues, let's disable IdeaVim
* in the commit window as well.
*
* Also, we support IdeaVim in diff viewers.
*/
private fun Editor.isAllowedFileEditor(): Boolean {
if (EditorHelper.getVirtualFile(this)?.name?.contains("Dummy.txt") == true) {
return PopupUtil.getPopupContainerFor(component) == null
}
if (EditorHelper.isPythonConsole(this)) return false
return EditorHelper.isCommitWindowEditor(this)
|| EditorHelper.isKotlinClassDecompiledToJavaFile(this)
|| EditorHelper.isDiffEditor(this)
|| EditorHelper.isFileEditor(this)
private fun Editor.isNotFileEditorExceptAllowed(): Boolean {
if (EditorHelper.getVirtualFile(this)?.name?.contains("Dummy.txt") == true) return false
if (EditorHelper.isDiffEditor(this)) return false
return !EditorHelper.isFileEditor(this)
}
private fun ideaVimDisabledInDialog(ideaVimSupportValue: StringListOptionValue): Boolean {

View File

@@ -29,7 +29,6 @@ import com.maddyhome.idea.vim.newapi.vim
import com.maddyhome.idea.vim.state.mode.inCommandLineModeWithVisual
import com.maddyhome.idea.vim.state.mode.inVisualMode
import org.jetbrains.annotations.Contract
import java.awt.Color
import java.awt.Font
import java.util.*
import javax.swing.Timer
@@ -88,7 +87,7 @@ fun addSubstitutionConfirmationHighlight(editor: Editor, start: Int, end: Int):
}
val removeHighlightsEditors = mutableListOf<Editor>()
val removeHighlightsTimer = Timer(450) {
val removeHighlightsTimer = Timer(400) {
removeHighlightsEditors.forEach(::removeSearchHighlights)
removeHighlightsEditors.clear()
}
@@ -162,7 +161,7 @@ private fun updateSearchHighlights(
if (editor === currentEditor?.ij) {
currentMatchOffset = findClosestMatch(results, initialOffset, count1, forwards)
}
highlightSearchResults(editor, results, currentMatchOffset)
highlightSearchResults(editor, pattern, results, currentMatchOffset)
if (!isSearching) {
removeHighlightsEditors.add(editor)
removeHighlightsTimer.restart()
@@ -185,7 +184,7 @@ private fun updateSearchHighlights(
if (result != null) {
if (!it.inVisualMode && !it.inCommandLineModeWithVisual) {
val results = listOf(result)
highlightSearchResults(editor, results, result.startOffset)
highlightSearchResults(editor, pattern, results, result.startOffset)
}
currentMatchOffset = result.startOffset
}
@@ -266,18 +265,9 @@ private fun findClosestMatch(
return sortedResults[nextIndex % results.size].startOffset
}
@Suppress("UseJBColor")
private val DEFAULT_RESULT_ATTRIBUTES = TextAttributes().apply {
backgroundColor = Color(50, 81, 61)
}
@Suppress("UseJBColor")
private val NEARBY_RESULT_ATTRIBUTES = TextAttributes().apply {
backgroundColor = Color(89, 80, 50)
}
fun highlightSearchResults(
editor: Editor,
pattern: String,
results: List<TextRange>,
currentMatchOffset: Int,
) {
@@ -286,28 +276,38 @@ fun highlightSearchResults(
highlighters = mutableListOf()
editor.vimLastHighlighters = highlighters
}
val allCaretOffsets = editor.caretModel.allCarets.map { it.offset }
for ((index, range) in results.withIndex()) {
if (allCaretOffsets.any { range.startOffset == it }) {
continue
}
val attributes = if (allCaretOffsets.any { (index > 0 && results[index - 1].startOffset == it) || (index < results.lastIndex && results[index + 1].startOffset == it) })
NEARBY_RESULT_ATTRIBUTES
else
DEFAULT_RESULT_ATTRIBUTES
highlighters.add(highlightMatch(editor, range.startOffset, range.endOffset, attributes))
for (range in results) {
val current = range.startOffset == currentMatchOffset
val highlighter = highlightMatch(editor, range.startOffset, range.endOffset, current, pattern)
highlighters.add(highlighter)
}
editor.vimIncsearchCurrentMatchOffset = currentMatchOffset
}
private fun highlightMatch(editor: Editor, start: Int, end: Int, attributes: TextAttributes): RangeHighlighter {
private fun highlightMatch(editor: Editor, start: Int, end: Int, current: Boolean, tooltip: String): RangeHighlighter {
val layer = HighlighterLayer.SELECTION - 1
val targetArea = HighlighterTargetArea.EXACT_RANGE
return editor.markupModel.addRangeHighlighter(start, end, layer, attributes, targetArea)
if (!current) {
// If we use a text attribute key, it will update automatically when the editor's colour scheme changes
val highlighter =
editor.markupModel.addRangeHighlighter(EditorColors.TEXT_SEARCH_RESULT_ATTRIBUTES, start, end, layer, targetArea)
highlighter.errorStripeTooltip = tooltip
return highlighter
}
// There isn't a text attribute key for current selection. This means we won't update automatically when the editor's
// colour scheme changes. However, this is only used during incsearch, so it should be replaced pretty quickly. It's a
// small visual glitch that will fix itself quickly. Let's not bother implementing an editor colour scheme listener
// just for this.
// These are the same modifications that the Find live preview does. We could look at using LivePreviewPresentation,
// which might also be useful for text attributes in selection (if we supported that)
val attributes = editor.colorsScheme.getAttributes(EditorColors.TEXT_SEARCH_RESULT_ATTRIBUTES).clone().apply {
effectType = EffectType.ROUNDED_BOX
effectColor = editor.colorsScheme.getColor(EditorColors.CARET_COLOR)
}
return editor.markupModel.addRangeHighlighter(start, end, layer, attributes, targetArea).apply {
errorStripeTooltip = tooltip
}
}
/**

View File

@@ -29,7 +29,6 @@ import com.maddyhome.idea.vim.common.InsertSequence
import com.maddyhome.idea.vim.newapi.IjVimCaret
import com.maddyhome.idea.vim.newapi.globalIjOptions
import com.maddyhome.idea.vim.newapi.ij
import com.maddyhome.idea.vim.state.mode.Mode
import com.maddyhome.idea.vim.state.mode.SelectionType
import com.maddyhome.idea.vim.state.mode.inVisualMode
import com.maddyhome.idea.vim.undo.VimTimestampBasedUndoService
@@ -55,7 +54,6 @@ class UndoRedoHelper : VimTimestampBasedUndoService {
val textEditor = getTextEditor(editor.ij)
val undoManager = UndoManager.getInstance(project)
if (undoManager.isUndoAvailable(textEditor)) {
val caretCountBeforeUndo = editor.ij.caretModel.allCarets.size
val scrollingModel = editor.getScrollingModel()
scrollingModel.accumulateViewportChanges()
@@ -63,8 +61,6 @@ class UndoRedoHelper : VimTimestampBasedUndoService {
scrollingModel.flushViewportChanges()
collapseRestoredBlockVisualCarets(editor, caretCountBeforeUndo)
return true
}
return false
@@ -195,23 +191,6 @@ class UndoRedoHelper : VimTimestampBasedUndoService {
}
}
/**
* VIM-4112. IntelliJ's undo restores the pre-edit `CaretState`; for a block-visual edit that
* means one caret per block row. A 1 → N caret-count jump across undo uniquely identifies
* this, since [com.maddyhome.idea.vim.helper.exitVisualMode] is the only flow that collapses
* multi-carets to one. The remaining caret is placed at the block's top-left, matching Vim's
* convention of cursor-at-start-of-undone-change.
*/
private fun collapseRestoredBlockVisualCarets(editor: VimEditor, caretCountBeforeUndo: Int) {
val caretModel = editor.ij.caretModel
val restoredExtraCarets = caretCountBeforeUndo == 1 && caretModel.allCarets.size > 1
if (!restoredExtraCarets || editor.mode !is Mode.NORMAL) return
val blockTopOffset = caretModel.allCarets.minOf { it.offset }
caretModel.removeSecondaryCarets()
caretModel.primaryCaret.moveToOffset(blockTopOffset)
}
private fun removeSelections(editor: VimEditor) {
editor.carets().forEach {
val ijCaret = it.ij

View File

@@ -29,14 +29,12 @@ import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.AnActionResult
import com.intellij.openapi.actionSystem.AnActionWrapper
import com.intellij.openapi.actionSystem.CommonDataKeys
import com.intellij.openapi.actionSystem.IdeActions
import com.intellij.openapi.actionSystem.ex.AnActionListener
import com.intellij.openapi.actionSystem.impl.ProxyShortcutSet
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.RangeMarker
import com.intellij.openapi.editor.actions.EnterAction
import com.intellij.openapi.editor.impl.ScrollingModelImpl
import com.intellij.openapi.fileEditor.FileEditorManager
import com.intellij.openapi.keymap.KeymapManager
import com.intellij.openapi.project.DumbAwareToggleAction
import com.intellij.openapi.util.TextRange
@@ -51,9 +49,8 @@ import com.maddyhome.idea.vim.group.NotificationService
import com.maddyhome.idea.vim.group.visual.IdeaSelectionControl
import com.maddyhome.idea.vim.helper.exitSelectMode
import com.maddyhome.idea.vim.helper.exitVisualMode
import com.maddyhome.idea.vim.helper.hasVisualSelection
import com.maddyhome.idea.vim.helper.isIdeaVimDisabledHere
import com.maddyhome.idea.vim.ide.isClionNova
import com.maddyhome.idea.vim.ide.isRider
import com.maddyhome.idea.vim.newapi.globalIjOptions
import com.maddyhome.idea.vim.newapi.initInjector
import com.maddyhome.idea.vim.newapi.vim
@@ -89,11 +86,6 @@ internal object IdeaSpecifics {
caretOffset = hostEditor.caretModel.offset
}
val actionId = ActionManager.getInstance().getId(action)
if (isGotoAction(actionId)) {
saveJumpBeforeGoto(event, editor)
}
val isVimAction = (action as? AnActionWrapper)?.delegate is VimShortcutKeyAction
if (!isVimAction && injector.vimState.mode == Mode.INSERT && action !is EnterAction) {
val undoService = injector.undo as VimTimestampBasedUndoService
@@ -213,20 +205,6 @@ internal object IdeaSpecifics {
this.completionData = null
}
private fun isGotoAction(actionId: String?): Boolean =
actionId == IdeActions.ACTION_GOTO_BACK || actionId == IdeActions.ACTION_GOTO_FORWARD
private fun saveJumpBeforeGoto(event: AnActionEvent, editor: Editor?) {
val project = event.dataContext.getData(CommonDataKeys.PROJECT)
val currentEditor = editor
?: event.dataContext.getData(CommonDataKeys.EDITOR)
?: project?.let { VimListenerManager.VimLastSelectedEditorTracker.getLastSelectedEditor(it) }
?: project?.let { FileEditorManager.getInstance(it).selectedTextEditor }
if (currentEditor != null && !currentEditor.isIdeaVimDisabledHere) {
injector.jumpService.saveJumpLocation(currentEditor.vim)
}
}
private data class CompletionData(
val completionStartMarker: RangeMarker,
val originalStartOffset: Int,
@@ -355,6 +333,23 @@ internal object IdeaSpecifics {
vimEditor.exitMode()
vimEditor.mode = Mode.NORMAL()
}
} else {
// IdeaSelectionControl will not be called if we're moving to a new variable with no change in selection.
// And if we're moving to the end of the template, the change in selection will reset us to Normal because
// IdeaSelectionControl will be called when the template is no longer active.
if ((!editor.selectionModel.hasSelection() && !vimEditor.mode.hasVisualSelection) || newIndex == -1) {
if (vimEditor.isIdeaRefactorModeSelect) {
if (vimEditor.mode !is Mode.INSERT) {
vimEditor.exitMode()
injector.application.runReadAction {
val context = injector.executionContextManager.getEditorExecutionContext(editor.vim)
VimPlugin.getChange().insertBeforeCaret(editor.vim, context)
}
}
} else {
vimEditor.mode = Mode.NORMAL()
}
}
}
}
}
@@ -373,16 +368,6 @@ internal object IdeaSpecifics {
if (newLookup.editor.isIdeaVimDisabledHere) return
(VimPlugin.getKey() as VimKeyGroupBase).registerShortcutsForLookup(newLookup)
// In Rider/CLion Nova, octopus is disabled (VIM-3815) and Escape is consumed by the popup manager
// (due to LookupSummaryInfo popup) before the action system runs, so IdeaVim never sees it.
// Listen for explicit lookup cancellation (Escape) to exit insert mode.
// Note: we check isRider/isClionNova specifically, not !isOctopusEnabled(), because
// JetBrains Client (split mode) also has octopus disabled but doesn't need this workaround,
// and isCanceledExplicitly can be true for non-Escape keys (e.g. space) in that environment.
if (isRider() || isClionNova()) {
newLookup.addLookupListener(RiderEscLookupListener(newLookup.editor))
}
}
// Lookup closed
@@ -394,20 +379,6 @@ internal object IdeaSpecifics {
}
}
}
/**
* In Rider/CLion Nova, octopus is disabled (VIM-3815) and Escape is consumed by the popup manager
* (due to LookupSummaryInfo parameter info popup) before the action system runs, so IdeaVim never sees it.
* This listener exits insert mode when the lookup is explicitly cancelled (Escape).
*/
private class RiderEscLookupListener(private val editor: Editor) : com.intellij.codeInsight.lookup.LookupListener {
override fun lookupCanceled(event: com.intellij.codeInsight.lookup.LookupEvent) {
if (event.isCanceledExplicitly && editor.vim.mode is Mode.INSERT) {
editor.vim.exitInsertMode(injector.executionContextManager.getEditorExecutionContext(editor.vim))
KeyHandler.getInstance().reset(editor.vim)
}
}
}
//endregion
//region Hide Vim search highlights when showing IntelliJ search results

View File

@@ -1,33 +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.listener
import com.intellij.openapi.application.ApplicationActivationListener
import com.intellij.openapi.wm.IdeFrame
import com.maddyhome.idea.vim.VimPlugin
import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.autocmd.AutoCmdEvent
/**
* Fires FocusGained/FocusLost autocmd events when the IDE window gains or loses OS-level focus.
* This matches Vim's behavior where these events fire on application-level focus changes (e.g., alt-tab),
* not on editor-level focus changes within the IDE.
*/
class VimAppActivationListener : ApplicationActivationListener {
override fun applicationActivated(ideFrame: IdeFrame) {
if (VimPlugin.isNotEnabled()) return
injector.autoCmd.handleEvent(AutoCmdEvent.FocusGained)
}
override fun applicationDeactivated(ideFrame: IdeFrame) {
if (VimPlugin.isNotEnabled()) return
injector.autoCmd.handleEvent(AutoCmdEvent.FocusLost)
}
}

View File

@@ -9,17 +9,13 @@
package com.maddyhome.idea.vim.listener
import com.intellij.codeWithMe.ClientId
import com.intellij.codeWithMe.ClientId.Companion.isLocal
import com.intellij.ide.ui.UISettings
import com.intellij.openapi.Disposable
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.diagnostic.trace
import com.intellij.openapi.editor.Caret
import com.intellij.openapi.editor.ClientEditorManager
import com.intellij.openapi.editor.Document
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.EditorFactory
import com.intellij.openapi.editor.EditorKind
import com.intellij.openapi.editor.actionSystem.TypedAction
import com.intellij.openapi.editor.event.CaretEvent
@@ -38,8 +34,6 @@ import com.intellij.openapi.editor.ex.DocumentEx
import com.intellij.openapi.editor.ex.EditorEx
import com.intellij.openapi.editor.ex.FocusChangeListener
import com.intellij.openapi.editor.impl.EditorComponentImpl
import com.intellij.openapi.fileEditor.FileDocumentManager
import com.intellij.openapi.fileEditor.FileDocumentManagerListener
import com.intellij.openapi.fileEditor.FileEditor
import com.intellij.openapi.fileEditor.FileEditorManager
import com.intellij.openapi.fileEditor.FileEditorManagerEvent
@@ -57,10 +51,6 @@ import com.intellij.openapi.util.Disposer
import com.intellij.openapi.util.Key
import com.intellij.openapi.util.removeUserData
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.openapi.vfs.VirtualFileManager
import com.intellij.openapi.vfs.newvfs.BulkFileListener
import com.intellij.openapi.vfs.newvfs.events.VFileCreateEvent
import com.intellij.openapi.vfs.newvfs.events.VFileEvent
import com.intellij.util.ExceptionUtil
import com.intellij.util.SlowOperations
import com.maddyhome.idea.vim.EventFacade
@@ -75,12 +65,7 @@ import com.maddyhome.idea.vim.api.coerceOffset
import com.maddyhome.idea.vim.api.getLineEndForOffset
import com.maddyhome.idea.vim.api.getLineStartForOffset
import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.autocmd.AutoCmdEvent
import com.maddyhome.idea.vim.autocmd.IjFileTypeMapping
import com.maddyhome.idea.vim.common.ModeChangeListener
import com.maddyhome.idea.vim.common.ModeWillChangeListener
import com.maddyhome.idea.vim.group.ChangeGroup
import com.maddyhome.idea.vim.group.CommentsOptionInitializer
import com.maddyhome.idea.vim.group.FileGroupHelper
import com.maddyhome.idea.vim.group.IjOptions
import com.maddyhome.idea.vim.group.IjVimRedrawService
@@ -127,7 +112,6 @@ import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.lang.ref.WeakReference
import java.util.*
import java.util.concurrent.ConcurrentHashMap
import javax.swing.SwingUtilities
/**
@@ -184,9 +168,6 @@ object VimListenerManager {
val insertTimeRecorder = InsertTimeRecorder()
injector.listenersNotifier.modeChangeListeners.add(insertTimeRecorder)
injector.listenersNotifier.modeWillChangeListeners.add(AutoCmdInsertEnterListener())
injector.listenersNotifier.modeChangeListeners.add(AutoCmdInsertLeaveListener())
val modeWidgetListener = ModeWidgetListener()
injector.listenersNotifier.modeChangeListeners.add(modeWidgetListener)
injector.listenersNotifier.myEditorListeners.add(modeWidgetListener)
@@ -240,8 +221,6 @@ object VimListenerManager {
val busConnection =
ApplicationManager.getApplication().messageBus.connect(VimPlugin.getInstance().onOffDisposable)
busConnection.subscribe(FileOpenedSyncListener.TOPIC, VimEditorFactoryListener)
busConnection.subscribe(VirtualFileManager.VFS_CHANGES, BufNewFileTracker)
busConnection.subscribe(FileDocumentManagerListener.TOPIC, BufWriteListener)
}
fun disable() {
@@ -258,8 +237,6 @@ object VimListenerManager {
optionGroup.removeGlobalOptionChangeListener(Options.showmode, modeWidgetOptionListener)
optionGroup.removeGlobalOptionChangeListener(Options.showmode, macroWidgetOptionListener)
optionGroup.removeEffectiveOptionValueChangeListener(Options.guicursor, GuicursorChangeListener)
BufNewFileTracker.clear()
}
}
@@ -348,7 +325,6 @@ object VimListenerManager {
injector.editorGroup.editorCreated(IjVimEditor(editor))
(VimPlugin.getChange() as ChangeGroup).editorCreated(IjVimEditor(editor), perEditorDisposable)
CommentsOptionInitializer.initializeForEditor(editor)
(editor as EditorEx).addFocusListener(VimFocusListener, perEditorDisposable)
@@ -359,18 +335,15 @@ object VimListenerManager {
injector.editorGroup.editorDeinit(editor.vim)
}
}
ApplicationManager.getApplication().invokeLater {
if (vimDisabled(editor)) {
remove(editor)
}
}
}
fun remove(editor: Editor) {
val editorDisposable = editor.removeUserData(editorListenersDisposableKey)
if (editorDisposable != null) {
Disposer.dispose(editorDisposable)
} else {
// We definitely do not expect this to happen
StrictMode.fail("Editor doesn't have disposable attached. $editor")
}
}
}
@@ -450,17 +423,8 @@ object VimListenerManager {
// Breaks relativenumber for some reason
// injector.scroll.scrollCaretIntoView(editor.vim)
}
// Vim order: BufLeave → WinLeave → WinEnter → BufEnter
// Buf events only fire when the buffer (file) actually changes
val bufferChanged = event.oldFile?.path != event.newFile?.path
if (bufferChanged) {
injector.autoCmd.handleEvent(AutoCmdEvent.BufLeave, event.oldFile?.path)
}
injector.autoCmd.handleEvent(AutoCmdEvent.WinLeave, event.oldFile?.path)
injector.autoCmd.handleEvent(AutoCmdEvent.WinEnter, event.newFile?.path)
if (bufferChanged) {
injector.autoCmd.handleEvent(AutoCmdEvent.BufEnter, event.newFile?.path)
}
injector.outputPanel.getCurrentOutputPanel()?.close()
MotionGroup.fileEditorManagerSelectionChangedCallback(event)
FileGroupHelper.fileEditorManagerSelectionChangedCallback(event)
// (VimPlugin.getSearch() as IjVimSearchGroup).fileEditorManagerSelectionChangedCallback(event)
@@ -544,7 +508,6 @@ object VimListenerManager {
EditorListeners.remove(event.editor)
injector.listenersNotifier.notifyEditorReleased(vimEditor)
injector.markService.editorReleased(vimEditor)
injector.autoCmd.handleEvent(AutoCmdEvent.BufLeave, event.editor.virtualFile?.path)
// This ticket will have a different stack trace, but it's the same problem. Originally, we tracked the last
// editor closing based on file selection (closing an editor would select the next editor - so a null selection
@@ -604,8 +567,6 @@ object VimListenerManager {
}
EditorListeners.add(editor, openingEditor?.editor?.vim ?: injector.fallbackWindow, scenario)
firstEditorInitialised = true
fireBufferLoadedEvents(editor)
}
}
}
@@ -964,122 +925,3 @@ private object MouseEventsDataHolder {
const val allowedSkippedDragEvents = 3
var dragEventCount = allowedSkippedDragEvents
}
/**
* Fires autocmd events that correspond to Vim's "load a buffer" sequence.
*/
private fun fireBufferLoadedEvents(editor: Editor) {
val virtualFile = editor.virtualFile ?: return
val vimEditor = editor.vim
val path = virtualFile.path
if (BufNewFileTracker.consumeIfNew(path)) {
injector.autoCmd.handleEvent(AutoCmdEvent.BufNewFile, path, vimEditor)
} else {
injector.autoCmd.handleEvent(AutoCmdEvent.BufReadPost, path, vimEditor)
}
val vimFileType = IjFileTypeMapping.toVimFileType(virtualFile)
if (vimFileType != null) {
injector.autoCmd.handleEvent(AutoCmdEvent.FileType, vimFileType, vimEditor)
}
}
/**
* Fires Vim's buffer write events when IntelliJ saves a document to disk.
*
* `BufWritePre` (== `BufWrite` in Vim) fires before the write; `BufWritePost` after.
* Note: IntelliJ auto-saves aggressively (focus loss, tab switch, build, etc.), so these
* fire more often than Vim's `:w`. Handlers should be idempotent.
*/
private object BufWriteListener : FileDocumentManagerListener {
override fun beforeDocumentSaving(document: Document) {
fireWriteEvent(document, pre = true)
}
override fun afterDocumentSaved(document: Document) {
fireWriteEvent(document, pre = false)
}
private fun fireWriteEvent(document: Document, pre: Boolean) {
val virtualFile = FileDocumentManager.getInstance().getFile(document) ?: return
val editor = getMainEditor(document) ?: return
val vimEditor = IjVimEditor(editor)
val path = virtualFile.path
if (pre) {
injector.autoCmd.handleEvent(AutoCmdEvent.BufWritePre, path, vimEditor)
} else {
injector.autoCmd.handleEvent(AutoCmdEvent.BufWritePost, path, vimEditor)
}
}
private fun getMainEditor(document: Document): Editor? = EditorFactory.getInstance().getEditors(document)
.firstOrNull { ed ->
ed.editorKind != EditorKind.CONSOLE &&
ed.editorKind != EditorKind.DIFF &&
ClientEditorManager.getClientId(ed).isLocal
}
}
/**
* Tracks paths of newly-created VirtualFiles so that when a file is subsequently opened we can fire Vim's `BufNewFile`
* event instead of `BufRead`. Entries are removed on first matching open; files created but never opened stay in the
* set (bounded by a TTL and max size).
*/
internal object BufNewFileTracker : BulkFileListener {
private const val ENTRY_TTL_MILLIS = 60_000L
private const val MAX_ENTRIES = 256
private val createdFiles = ConcurrentHashMap<String, Long>()
@TestOnly
internal var clock: () -> Long = System::currentTimeMillis
override fun after(events: List<VFileEvent>) {
val now = clock()
for (event in events) {
if (event !is VFileCreateEvent || event.isDirectory) continue
if (event.isFromRefresh || event.requestor == null) continue
createdFiles[event.path] = now
}
if (createdFiles.size > MAX_ENTRIES) sweepStale(now)
}
fun consumeIfNew(path: String): Boolean {
val timestamp = createdFiles.remove(path) ?: return false
return clock() - timestamp < ENTRY_TTL_MILLIS
}
fun clear() {
createdFiles.clear()
}
@TestOnly
internal fun size(): Int = createdFiles.size
private fun sweepStale(now: Long) {
createdFiles.entries.removeIf { now - it.value > ENTRY_TTL_MILLIS }
}
}
private class AutoCmdInsertEnterListener : ModeWillChangeListener {
override fun modeWillChange(editor: VimEditor, oldMode: Mode, newMode: Mode) {
if (!oldMode.isInsertish && newMode.isInsertish) {
injector.autoCmd.handleEvent(AutoCmdEvent.InsertEnter, editor.getPath(), editor)
}
}
}
private class AutoCmdInsertLeaveListener : ModeChangeListener {
override fun modeChanged(editor: VimEditor, oldMode: Mode) {
if (oldMode.isInsertish && !editor.mode.isInsertish) {
injector.autoCmd.handleEvent(AutoCmdEvent.InsertLeave, editor.getPath(), editor)
}
}
}
// Vim fires InsertEnter/Leave for both Insert and Replace modes (`:help InsertEnter`).
private val Mode.isInsertish: Boolean
get() = this == Mode.INSERT || this == Mode.REPLACE

View File

@@ -1,80 +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.newapi
import com.intellij.openapi.editor.FoldRegion
/**
* Computes nesting depth for each fold region in O(N log N).
*
* A fold's depth is the count of other folds that contain it by offset range,
* excluding folds with an identical (start, end) range.
*/
internal object FoldDepthCalculator {
fun computeDepths(folds: Array<FoldRegion>): IntArray {
if (folds.isEmpty()) return IntArray(0)
val ranges = FoldRanges.from(folds)
return ranges.sweepDepths(ranges.orderOuterFirst())
}
}
private class FoldRanges(private val starts: IntArray, private val ends: IntArray) {
val size: Int get() = starts.size
fun orderOuterFirst(): IntArray =
(0 until size).sortedWith(byStartAscendingEndDescending()).toIntArray()
fun sweepDepths(orderedFolds: IntArray): IntArray {
val depths = IntArray(size)
val openFolds = IntArray(size)
var openCount = 0
for (fold in orderedFolds) {
openCount = dropFoldsClosedBefore(openFolds, openCount, fold)
val duplicates = countDuplicatesAtTop(openFolds, openCount, fold)
depths[fold] = openCount - duplicates
openFolds[openCount++] = fold
}
return depths
}
private fun byStartAscendingEndDescending() = Comparator<Int> { a, b ->
val byStart = starts[a].compareTo(starts[b])
if (byStart != 0) byStart else ends[b].compareTo(ends[a])
}
private fun dropFoldsClosedBefore(stack: IntArray, stackSize: Int, fold: Int): Int {
var size = stackSize
val foldStart = starts[fold]
while (size > 0 && ends[stack[size - 1]] <= foldStart) size--
return size
}
private fun countDuplicatesAtTop(stack: IntArray, stackSize: Int, fold: Int): Int {
var count = 0
var i = stackSize - 1
while (i >= 0 && hasSameRange(stack[i], fold)) {
count++
i--
}
return count
}
private fun hasSameRange(a: Int, b: Int): Boolean =
starts[a] == starts[b] && ends[a] == ends[b]
companion object {
fun from(folds: Array<FoldRegion>): FoldRanges {
val starts = IntArray(folds.size) { folds[it].startOffset }
val ends = IntArray(folds.size) { folds[it].endOffset }
return FoldRanges(starts, ends)
}
}
}

View File

@@ -12,7 +12,6 @@ import com.intellij.openapi.editor.Caret
import com.intellij.openapi.editor.LogicalPosition
import com.intellij.openapi.editor.VisualPosition
import com.maddyhome.idea.vim.api.BufferPosition
import com.maddyhome.idea.vim.api.ImmutableVimCaret
import com.maddyhome.idea.vim.api.LocalMarkStorage
import com.maddyhome.idea.vim.api.SelectionInfo
import com.maddyhome.idea.vim.api.VimCaret
@@ -199,12 +198,3 @@ class IjVimCaret(val caret: Caret) : VimCaretBase() {
override fun hashCode(): Int = this.caret.hashCode()
}
val Caret.vim: VimCaret
get() = VimEditorFactory.getInstance().createVimCaret(this)
val VimCaret.ij: Caret
get() = VimEditorFactory.getInstance().extractCaret(this)
val ImmutableVimCaret.ij: Caret
get() = VimEditorFactory.getInstance().extractCaret(this)

View File

@@ -514,11 +514,12 @@ class IjVimEditor(editor: Editor) : MutableLinearEditor, VimEditorBase() {
val allFolds = editor.foldingModel.allFoldRegions
if (allFolds.isEmpty()) return
val depths = FoldDepthCalculator.computeDepths(allFolds)
editor.foldingModel.runBatchFoldingOperation {
for (i in allFolds.indices) {
allFolds[i].isExpanded = depths[i] < foldLevel
// I'm aware it's O(n^2) comparison here,
// but it doesn't affect performance even on a large amount of fold
allFolds.forEach { fold ->
val depth = calculateFoldDepth(fold, allFolds)
fold.isExpanded = depth < foldLevel
}
}
}
@@ -527,10 +528,9 @@ class IjVimEditor(editor: Editor) : MutableLinearEditor, VimEditorBase() {
val allFolds = editor.foldingModel.allFoldRegions
if (allFolds.isEmpty()) return 0
val depths = FoldDepthCalculator.computeDepths(allFolds)
var max = 0
for (d in depths) if (d > max) max = d
return max
return allFolds.maxOfOrNull { fold ->
calculateFoldDepth(fold, allFolds)
} ?: 0
}
override fun createFoldRegion(startOffset: Int, endOffset: Int, collapse: Boolean): VimFoldRegion? {
@@ -589,6 +589,27 @@ class IjVimEditor(editor: Editor) : MutableLinearEditor, VimEditorBase() {
.minByOrNull { fold -> fold.endOffset - fold.startOffset }
}
private fun calculateFoldDepth(fold: FoldRegion, allFolds: Array<FoldRegion>): Int {
return allFolds.count { otherFold ->
isWrappedBy(fold, otherFold)
}
}
/**
* Returns true if the inner fold is completely contained by the outer fold (allowing matching boundaries)
* but excludes identical folds.
*/
private fun isWrappedBy(inner: FoldRegion, outer: FoldRegion): Boolean {
return outer.startOffset <= inner.startOffset &&
outer.endOffset >= inner.endOffset &&
areDifferentFolds(inner, outer)
}
private fun areDifferentFolds(
first: FoldRegion,
second: FoldRegion,
): Boolean = first.startOffset != second.startOffset || first.endOffset != second.endOffset
private fun toVimFoldRegion(ijFoldRegion: FoldRegion): VimFoldRegion {
return IjVimFoldRegion(ijFoldRegion, editor)
}
@@ -650,12 +671,3 @@ class IjVimEditor(editor: Editor) : MutableLinearEditor, VimEditorBase() {
}
}
val Editor.vim: VimEditor
get() = VimEditorFactory.getInstance().createVimEditor(this)
val VimEditor.ij: Editor
get() = VimEditorFactory.getInstance().extractEditor(this)
val com.intellij.openapi.util.TextRange.vim: TextRange
get() = TextRange(this.startOffset, this.endOffset)

View File

@@ -12,7 +12,6 @@ import com.intellij.openapi.components.service
import com.intellij.openapi.components.serviceIfCreated
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.editor.textarea.TextComponentEditorImpl
import com.maddyhome.idea.vim.api.AutoCmdService
import com.maddyhome.idea.vim.api.EngineEditorHelper
import com.maddyhome.idea.vim.api.ExecutionContextManager
import com.maddyhome.idea.vim.api.LocalOptionInitialisationScenario
@@ -63,7 +62,6 @@ import com.maddyhome.idea.vim.api.VimscriptFunctionService
import com.maddyhome.idea.vim.api.VimscriptParser
import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.api.isInjectorInitialized
import com.maddyhome.idea.vim.autocmd.AutoCmdImpl
import com.maddyhome.idea.vim.diagnostic.VimLogger
import com.maddyhome.idea.vim.extension.ExtensionLoader
import com.maddyhome.idea.vim.extension.JsonExtensionProvider
@@ -219,8 +217,6 @@ internal class IjVimInjector : VimInjectorBase() {
get() = service()
override val pluginActivator: VimPluginActivator
get() = service()
override val autoCmd: AutoCmdService get() = service<AutoCmdService>()
}
/**

View File

@@ -25,17 +25,10 @@ internal class IjVimMessages : VimMessagesBase() {
private var lastBeepTimeMillis = 0L
override fun showMessage(editor: VimEditor, message: String?) {
injector.outputPanel.clear(editor, injector.executionContextManager.getEditorExecutionContext(editor))
showMessageInternal(editor, message, MessageType.STANDARD)
}
override fun showErrorMessage(editor: VimEditor, message: String?) {
injector.outputPanel.clear(editor, injector.executionContextManager.getEditorExecutionContext(editor))
showMessageInternal(editor, message, MessageType.ERROR)
indicateError()
}
override fun appendErrorMessage(editor: VimEditor, message: String?) {
showMessageInternal(editor, message, MessageType.ERROR)
indicateError()
}

View File

@@ -78,7 +78,7 @@ open class IjVimSearchGroup : VimSearchGroupBase(), PersistentStateComponent<Ele
editor, pattern, startLine, endLine,
shouldIgnoreCase(pattern, lastIgnoreSmartCase)
)
highlightSearchResults(editor.ij, results, -1)
highlightSearchResults(editor.ij, pattern, results, -1)
}
}

View File

@@ -14,6 +14,7 @@ import com.intellij.openapi.editor.Editor
import com.maddyhome.idea.vim.api.ImmutableVimCaret
import com.maddyhome.idea.vim.api.VimCaret
import com.maddyhome.idea.vim.api.VimEditor
import com.maddyhome.idea.vim.common.TextRange
interface VimEditorFactory {
fun createVimEditor(editor: Editor): VimEditor
@@ -26,3 +27,21 @@ interface VimEditorFactory {
fun getInstance(): VimEditorFactory = service()
}
}
val Editor.vim: VimEditor
get() = VimEditorFactory.getInstance().createVimEditor(this)
val VimEditor.ij: Editor
get() = VimEditorFactory.getInstance().extractEditor(this)
val Caret.vim: VimCaret
get() = VimEditorFactory.getInstance().createVimCaret(this)
val VimCaret.ij: Caret
get() = VimEditorFactory.getInstance().extractCaret(this)
val ImmutableVimCaret.ij: Caret
get() = VimEditorFactory.getInstance().extractCaret(this)
val com.intellij.openapi.util.TextRange.vim: TextRange
get() = TextRange(this.startOffset, this.endOffset)

View File

@@ -11,7 +11,6 @@ import com.intellij.ide.ui.LafManager
import com.intellij.ide.ui.LafManagerListener
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.wm.ex.ToolWindowManagerListener
import com.intellij.openapi.wm.impl.IdeBackgroundUtil
import com.intellij.openapi.wm.impl.ToolWindowManagerImpl
import com.intellij.ui.ClientProperty
@@ -19,7 +18,6 @@ import com.intellij.ui.JBColor
import com.intellij.ui.components.JBPanel
import com.intellij.ui.components.JBScrollPane
import com.intellij.util.IJSwingUtilities
import com.intellij.util.messages.MessageBusConnection
import com.maddyhome.idea.vim.KeyHandler.Companion.getInstance
import com.maddyhome.idea.vim.VimPlugin
import com.maddyhome.idea.vim.api.ExecutionContext
@@ -66,7 +64,6 @@ class OutputPanel private constructor(
private var glassPane: JComponent? = null
private var originalLayout: LayoutManager? = null
private var wasOpaque = false
private var toolWindowListenerConnection: MessageBusConnection? = null
var active: Boolean = false
private val segments = mutableListOf<TextLine>()
@@ -94,6 +91,7 @@ class OutputPanel private constructor(
// Suppress the fancy frame background used in the Islands theme
ClientProperty.putRecursive(this, IdeBackgroundUtil.NO_BACKGROUND, true)
putClientProperty(ToolWindowManagerImpl.PARENT_COMPONENT, editor.component)
// Initialize panel
setLayout(BorderLayout(0, 0))
@@ -103,7 +101,6 @@ class OutputPanel private constructor(
val keyListener = OutputPanelKeyListener()
addKeyListener(keyListener)
textPane.addKeyListener(keyListener)
editor?.let { putClientProperty(ToolWindowManagerImpl.PARENT_COMPONENT, it.component) }
updateUI()
}
@@ -254,15 +251,19 @@ class OutputPanel private constructor(
return textPane.getBackground()
}
fun deactivate() {
/**
* Turns off the output panel and optionally puts the focus back to the original component.
*/
fun deactivate(refocusOwningEditor: Boolean) {
if (!active) return
active = false
clearText()
textPane.text = ""
if (refocusOwningEditor) {
requestFocus(editor.contentComponent)
}
if (glassPane != null) {
glassPane!!.removeComponentListener(resizeAdapter)
toolWindowListenerConnection?.disconnect()
toolWindowListenerConnection = null
glassPane!!.isVisible = false
glassPane!!.remove(this)
glassPane!!.setOpaque(wasOpaque)
@@ -284,8 +285,6 @@ class OutputPanel private constructor(
}
active = true
if (isSingleLine) return
requestFocus(textPane)
}
@@ -301,11 +300,6 @@ class OutputPanel private constructor(
glassPane!!.setOpaque(false)
glassPane!!.add(this)
glassPane!!.addComponentListener(resizeAdapter)
val project = editor.project
if (project != null) {
toolWindowListenerConnection = project.messageBus.connect()
toolWindowListenerConnection!!.subscribe(ToolWindowManagerListener.TOPIC, ToolWindowPositioningListener { positionPanel() })
}
}
override fun close() {
@@ -315,7 +309,7 @@ class OutputPanel private constructor(
fun close(key: KeyStroke?) {
val passKeyBack = isSingleLine
ApplicationManager.getApplication().invokeLater {
deactivate()
deactivate(true)
val project = editor.project
// For single line messages, pass any key back to the editor (including Enter)
// For multi-line messages, don't pass Enter back (it was used to dismiss)

View File

@@ -1,23 +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.ui
import com.intellij.openapi.wm.ToolWindowManager
import com.intellij.openapi.wm.ex.ToolWindowManagerListener
import javax.swing.SwingUtilities
/**
* Repositions a panel whenever a tool window visibility state changes.
* Shared between [com.maddyhome.idea.vim.ui.ex.ExEntryPanel] and [OutputPanel].
*/
internal class ToolWindowPositioningListener(private val reposition: () -> Unit) : ToolWindowManagerListener {
override fun stateChanged(toolWindowManager: ToolWindowManager) {
SwingUtilities.invokeLater(reposition)
}
}

View File

@@ -1,165 +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.ui.ex
import com.intellij.ui.JBColor
import com.intellij.ui.SideBorder
import com.intellij.util.ui.JBUI
import java.awt.BorderLayout
import java.awt.Color
import java.awt.FlowLayout
import java.awt.Font
import javax.swing.JLabel
import javax.swing.JPanel
import javax.swing.border.EmptyBorder
/**
* Single-row panel showing completion candidates above the command line.
* Paginates when items don't fit, keeping the selected item always visible.
*/
internal class ExCompletionPanel : JPanel(BorderLayout()) {
private val itemsPanel = JPanel(FlowLayout(FlowLayout.LEFT, 0, 0))
private var items: List<String> = emptyList()
private var selectedIndex: Int? = null
private var pageStart: Int = 0
private var pageEnd: Int = 0
private var itemFont: Font = font
private var normalFg: Color = JBColor.foreground()
private var normalBg: Color = JBColor.background()
init {
itemsPanel.isOpaque = true
isOpaque = true
border = SideBorder(JBColor.border(), SideBorder.TOP or SideBorder.BOTTOM)
add(itemsPanel, BorderLayout.CENTER)
}
fun setItems(matches: List<String>, selected: Int?) {
items = matches
selectedIndex = selected
rebuildPage()
}
fun setSelectedIndex(index: Int) {
if (index == selectedIndex) return
selectedIndex = index
if (isOnCurrentPage(index)) updateHighlight() else rebuildPage()
}
fun updateColors(fg: Color, bg: Color) {
normalFg = fg
normalBg = bg
background = normalBg
itemsPanel.background = normalBg
}
fun updateFont(font: Font) {
itemFont = font
}
private fun rebuildPage() {
itemsPanel.removeAll()
if (items.isEmpty()) return refreshLayout()
if (items.size == 1) return
calculateVisibleRange()
addLabelsForRange()
refreshLayout()
}
private fun calculateVisibleRange() {
val selected = selectedIndex
var start = if (selected != null && selected < pageStart) selected else pageStart
var end = fitForward(start)
if (selected != null && selected >= end) {
end = selected + 1
start = fitBackward(end)
}
pageStart = start
pageEnd = end
}
private fun fitForward(from: Int): Int {
var usedWidth = 0
var end = from
while (end < items.size) {
val w = measureItem(items[end])
if (usedWidth + w > availableWidth() && end > from) break
usedWidth += w
end++
}
return end
}
private fun fitBackward(from: Int): Int {
var usedWidth = 0
var start = from
while (start > 0) {
val w = measureItem(items[start - 1])
if (usedWidth + w > availableWidth() && start < from) break
usedWidth += w
start--
}
return start
}
private fun addLabelsForRange() {
for (i in pageStart until pageEnd) {
itemsPanel.add(createLabel(items[i], isSelected = i == selectedIndex))
}
}
// --- Highlight ---
private fun updateHighlight() {
for ((i, comp) in itemsPanel.components.withIndex()) {
if (comp is JLabel) styleLabel(comp, isSelected = pageStart + i == selectedIndex)
}
repaint()
}
// --- Label factory ---
private fun createLabel(text: String, isSelected: Boolean): JLabel {
return JLabel(text).apply {
font = itemFont
isOpaque = true
border = ITEM_BORDER
styleLabel(this, isSelected)
}
}
private fun styleLabel(label: JLabel, isSelected: Boolean) {
label.foreground = if (isSelected) normalBg else normalFg
label.background = if (isSelected) normalFg else normalBg
}
private fun isOnCurrentPage(index: Int) = index in pageStart until pageEnd
private fun measureItem(text: String) = getFontMetrics(itemFont).stringWidth(text) + ITEM_PADDING
private fun availableWidth() = if (width > 0) width else Int.MAX_VALUE
private fun refreshLayout() {
revalidate()
repaint()
}
companion object {
private const val ITEM_PADDING = 12
private val ITEM_BORDER = JBUI.Borders.empty(2, 6)
}
}

View File

@@ -15,16 +15,13 @@ import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.colors.EditorColors
import com.intellij.openapi.wm.IdeFocusManager
import com.intellij.openapi.wm.ex.ToolWindowManagerListener
import com.intellij.openapi.wm.impl.ToolWindowManagerImpl
import com.intellij.ui.DocumentAdapter
import com.intellij.util.IJSwingUtilities
import com.intellij.util.messages.MessageBusConnection
import com.maddyhome.idea.vim.EventFacade
import com.maddyhome.idea.vim.KeyHandler.Companion.getInstance
import com.maddyhome.idea.vim.VimPlugin
import com.maddyhome.idea.vim.action.VimShortcutKeyAction
import com.maddyhome.idea.vim.api.CommandLineCompletion
import com.maddyhome.idea.vim.api.VimCommandLine
import com.maddyhome.idea.vim.api.VimCommandLineCaret
import com.maddyhome.idea.vim.api.VimEditor
@@ -42,7 +39,6 @@ import com.maddyhome.idea.vim.key.interceptors.VimInputInterceptor
import com.maddyhome.idea.vim.newapi.IjVimCaret
import com.maddyhome.idea.vim.newapi.IjVimEditor
import com.maddyhome.idea.vim.ui.ExPanelBorder
import com.maddyhome.idea.vim.ui.ToolWindowPositioningListener
import com.maddyhome.idea.vim.vimscript.model.commands.Command
import com.maddyhome.idea.vim.vimscript.model.commands.GlobalCommand
import com.maddyhome.idea.vim.vimscript.model.commands.SubstituteCommand
@@ -82,46 +78,6 @@ class ExEntryPanel private constructor() : JPanel(), VimCommandLine {
var context: DataContext? = null
override var histIndex: Int = 0
override var lastEntry: String? = null
override var activeCompletion: CommandLineCompletion? = null
override fun isExCommand(): Boolean {
return getLabel().startsWith(":")
}
override fun showCompletionBar(completion: CommandLineCompletion) {
if (ApplicationManager.getApplication().isUnitTestMode) return
val editor = this.ijEditor ?: return
completionPanel.updateColors(editor.colorsScheme.defaultForeground, entry.getBackground())
completionPanel.updateFont(entry.getFont())
completionPanel.setItems(completion.displayNames, completion.currentIndex)
if (!isCompletionBarVisible) {
oldGlass?.add(completionPanel)
isCompletionBarVisible = true
}
positionCompletionPanel()
}
override fun selectCompletionItem(selectedIndex: Int?) {
if (!isCompletionBarVisible || selectedIndex == null) return
completionPanel.setSelectedIndex(selectedIndex)
}
override fun hideCompletionBar() {
if (!isCompletionBarVisible) return
isCompletionBarVisible = false
oldGlass?.remove(completionPanel)
oldGlass?.repaint()
}
private fun dismissCompletionIfTextChanged() {
val completion = activeCompletion ?: return
if (text != completion.expectedText) {
activeCompletion = null
hideCompletionBar()
}
}
val ijEditor: Editor?
get() = if (weakEditor != null) weakEditor!!.get() else null
@@ -187,11 +143,6 @@ class ExEntryPanel private constructor() : JPanel(), VimCommandLine {
glassPane.setOpaque(false)
glassPane.add(this)
glassPane.addComponentListener(resizePanelListener)
val project = editor.project
if (project != null) {
toolWindowListenerConnection = project.messageBus.connect()
toolWindowListenerConnection!!.subscribe(ToolWindowManagerListener.TOPIC, ToolWindowPositioningListener { positionPanel() })
}
positionPanel()
glassPane.isVisible = true
putClientProperty(ToolWindowManagerImpl.PARENT_COMPONENT, parent)
@@ -212,8 +163,6 @@ class ExEntryPanel private constructor() : JPanel(), VimCommandLine {
if (!this.isActive) return
clearPromptCharacter()
hideCompletionBar()
activeCompletion = null
try {
entry.document.removeDocumentListener(fontListener)
// incsearch won't change in the lifetime of this activation
@@ -246,8 +195,6 @@ class ExEntryPanel private constructor() : JPanel(), VimCommandLine {
putClientProperty(ToolWindowManagerImpl.PARENT_COMPONENT, null)
oldGlass!!.removeComponentListener(resizePanelListener)
toolWindowListenerConnection?.disconnect()
toolWindowListenerConnection = null
oldGlass!!.isVisible = false
oldGlass!!.remove(this)
oldGlass!!.setOpaque(wasOpaque)
@@ -296,7 +243,6 @@ class ExEntryPanel private constructor() : JPanel(), VimCommandLine {
}
}
private val incSearchDocumentListener: DocumentListener = object : DocumentAdapter() {
override fun textChanged(e: DocumentEvent) {
try {
@@ -532,25 +478,6 @@ class ExEntryPanel private constructor() : JPanel(), VimCommandLine {
setBounds(bounds)
repaint()
}
if (isCompletionBarVisible) {
positionCompletionPanel()
}
}
private fun positionCompletionPanel() {
val myBounds = bounds
if (myBounds.width == 0) return
val completionHeight = completionPanel.preferredSize.height
completionPanel.setBounds(
myBounds.x,
myBounds.y - completionHeight,
myBounds.width,
completionHeight,
)
completionPanel.revalidate()
completionPanel.repaint()
}
private val isIncSearchEnabled: Boolean
@@ -571,8 +498,6 @@ class ExEntryPanel private constructor() : JPanel(), VimCommandLine {
private var oldGlass: JComponent? = null
private var oldLayout: LayoutManager? = null
private var wasOpaque = false
private val completionPanel = ExCompletionPanel()
private var isCompletionBarVisible = false
// incsearch stuff
private var verticalOffset = 0
@@ -585,8 +510,6 @@ class ExEntryPanel private constructor() : JPanel(), VimCommandLine {
}
}
private var toolWindowListenerConnection: MessageBusConnection? = null
init {
val layout = GridBagLayout()
@@ -621,13 +544,10 @@ class ExEntryPanel private constructor() : JPanel(), VimCommandLine {
entry.updateText(string)
if (updateLastEntry) entry.saveLastEntry()
caret.offset = min(offset, text.length)
dismissCompletionIfTextChanged()
}
override fun deleteText(offset: Int, length: Int) {
entry.deleteText(offset, length)
dismissCompletionIfTextChanged()
}
override fun insertText(offset: Int, string: String) {
@@ -636,7 +556,6 @@ class ExEntryPanel private constructor() : JPanel(), VimCommandLine {
entry.deleteText(offset, string.length)
}
entry.insertText(offset, string)
dismissCompletionIfTextChanged()
}
override fun clearCurrentAction() {

View File

@@ -194,7 +194,7 @@ class ExTextField internal constructor(private val myParentPanel: ExEntryPanel)
// handler adds all non-control characters to the text field. We want to add all characters, so if we have an
// actual character, just add it. Anything else, we'll pass to the super class like before (even though it's unclear
// what it will do with the keystroke)
if (stroke.keyChar != KeyEvent.CHAR_UNDEFINED && !isKeyCharEnterOrEscape(stroke.keyChar)) {
if (stroke.keyChar != KeyEvent.CHAR_UNDEFINED) {
replaceSelection(stroke.keyChar.toString())
} else {
val event = KeyEvent(

View File

@@ -1,43 +0,0 @@
package com.maddyhome.idea.vim.vimscript.model.functions.handlers
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.actionSystem.impl.PresentationFactory
import com.intellij.openapi.actionSystem.impl.Utils
import com.intellij.openapi.keymap.impl.ActionProcessor
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.newapi.ij
import com.maddyhome.idea.vim.vimscript.model.VimLContext
import com.maddyhome.idea.vim.vimscript.model.datatypes.VimInt
import com.maddyhome.idea.vim.vimscript.model.datatypes.asVimInt
import com.maddyhome.idea.vim.vimscript.model.functions.BuiltinFunctionHandler
import java.awt.event.KeyEvent
@VimscriptFunction(name = "isactionenabled")
internal class IsActionEnabled : BuiltinFunctionHandler<VimInt>() {
override fun doFunction(
arguments: Arguments,
editor: VimEditor,
context: ExecutionContext,
vimContext: VimLContext,
): VimInt {
val action = ActionManager.getInstance().getAction(arguments.getString(0).value)
if (action == null) {
return false.asVimInt()
}
val presentationFactory = PresentationFactory()
val wrappedContext = Utils.createAsyncDataContext(context.ij)
val actionProcessor = object : ActionProcessor() {}
val inputEventAdjusted = KeyEvent(editor.ij.contentComponent, KeyEvent.KEY_PRESSED, 0L, 0, KeyEvent.VK_UNDEFINED, '\u0000')
val updateEvent = Utils.runUpdateSessionForInputEvent(listOf(action), inputEventAdjusted, wrappedContext, "IdeaVim", actionProcessor, presentationFactory) { _, updater, events ->
val presentation = updater(action)
events[presentation]
}
val result = updateEvent != null && updateEvent.presentation.isEnabled
return result.asVimInt()
}
}

View File

@@ -20,5 +20,5 @@ import com.maddyhome.idea.vim.vimscript.model.functions.BuiltinFunctionHandler
@VimscriptFunction(name = "pumvisible")
internal class PopupMenuVisibleFunctionHandler : BuiltinFunctionHandler<VimInt>() {
override fun doFunction(arguments: Arguments, editor: VimEditor, context: ExecutionContext, vimContext: VimLContext) =
(CompletionService.getCompletionService().currentCompletion != null).asVimInt()
(CompletionService.getCompletionService().currentCompletion == null).asVimInt()
}

View File

@@ -1,17 +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.
-->
<!-- Split-mode (Remote Dev) specific registrations.
This module only loads when intellij.platform.frontend.split is available,
which provides access to intellij.rd.client and its extension points. -->
<idea-plugin package="com.maddyhome.idea.vim">
<dependencies>
<module name="intellij.platform.frontend.split"/>
<module name="IdeaVIM.ideavim-frontend"/>
</dependencies>
</idea-plugin>

View File

@@ -1,5 +1,4 @@
{
"has": "com.maddyhome.idea.vim.vimscript.model.functions.handlers.HasFunctionHandler",
"isactionenabled": "com.maddyhome.idea.vim.vimscript.model.functions.handlers.IsActionEnabled",
"pumvisible": "com.maddyhome.idea.vim.vimscript.model.functions.handlers.PopupMenuVisibleFunctionHandler"
}

View File

@@ -19,7 +19,6 @@ import org.jetbrains.plugins.ideavim.TestWithoutNeovim
import org.jetbrains.plugins.ideavim.VimTestCase
import org.jetbrains.plugins.ideavim.waitAndAssert
import org.junit.jupiter.api.Test
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
@@ -146,33 +145,12 @@ class CopyActionTest : VimTestCase() {
one <caret>two
three
four
""".trimIndent(),
)
assertTrue(KeyHandler.getInstance().keyHandlerState.commandBuilder.isEmpty)
}
// Regression test: CommandBuilder.isEmpty must return false while waiting for a register character.
// Previously, isRegisterPending was not checked in isEmpty, so `"<Esc>` would incorrectly trigger
// an error indicator (beep) because EditorResetConsumer treated the builder as empty.
@TestWithoutNeovim(reason = SkipNeovimReason.NOT_VIM_TESTING)
@Test
fun `test command builder is not empty while register is pending`() {
configureByText("hello world")
// Typing `"` starts register selection - command builder should NOT be empty
typeText("\"")
assertFalse(
KeyHandler.getInstance().keyHandlerState.commandBuilder.isEmpty,
"Command builder must not be empty while waiting for register character",
)
// Pressing Escape cancels register selection - command builder should be empty again
typeText("<Esc>")
assertTrue(
KeyHandler.getInstance().keyHandlerState.commandBuilder.isEmpty,
"Command builder must be empty after cancelling register selection",
)
}
@Test
fun testWrongYankQuoteYankLine() {
assertPluginError(false)

View File

@@ -1,37 +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
import com.maddyhome.idea.vim.action.VimShortcutKeyAction
import org.jetbrains.plugins.ideavim.SkipNeovimReason
import org.jetbrains.plugins.ideavim.TestWithoutNeovim
import org.jetbrains.plugins.ideavim.VimTestCase
import org.junit.jupiter.api.Test
import java.awt.event.InputEvent
import java.awt.event.KeyEvent
import javax.swing.KeyStroke
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class VimShortcutKeyActionTest : VimTestCase() {
@TestWithoutNeovim(SkipNeovimReason.NOT_VIM_TESTING)
@Test
fun `plain Tab is a Vim-only editor key`() {
val tab = KeyStroke.getKeyStroke(KeyEvent.VK_TAB, 0)
assertTrue(VimShortcutKeyAction.VIM_ONLY_EDITOR_KEYS.contains(tab))
}
@TestWithoutNeovim(SkipNeovimReason.NOT_VIM_TESTING)
@Test
fun `S-Tab is not a Vim-only editor key so sethandler can release it to the IDE`() {
val shiftTab = KeyStroke.getKeyStroke(KeyEvent.VK_TAB, InputEvent.SHIFT_DOWN_MASK)
assertFalse(VimShortcutKeyAction.VIM_ONLY_EDITOR_KEYS.contains(shiftTab))
}
}

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
* license that can be found in the LICENSE.txt file or at
@@ -65,34 +65,6 @@ class UndoActionTest : VimTestCase() {
}
}
@Test
fun `test undo after visual block mode delete clears leftover native carets`() {
configureByText(
"""
${c}1. Item
2. Item
3. Item
""".trimIndent()
)
typeText("<C-V>jjllx")
typeText("u")
assertState(
"""
${c}1. Item
2. Item
3. Item
""".trimIndent()
)
assertMode(Mode.NORMAL())
ApplicationManager.getApplication().runReadAction {
kotlin.test.assertFalse(hasSelection())
kotlin.test.assertEquals(1, fixture.editor.caretModel.allCarets.size)
}
}
@Test
fun `test undo with count`() {
val keys = listOf("dwdwdw", "2u")

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
* license that can be found in the LICENSE.txt file or at
@@ -8,16 +8,11 @@
package org.jetbrains.plugins.ideavim.action.motion.search
import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.state.mode.Mode
import com.maddyhome.idea.vim.state.mode.SelectionType
import com.maddyhome.idea.vim.ui.ex.ExEntryPanel
import org.jetbrains.plugins.ideavim.VimTestCase
import org.junit.jupiter.api.Disabled
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class SearchEntryFwdActionTest : VimTestCase() {
@Test
@@ -29,14 +24,6 @@ class SearchEntryFwdActionTest : VimTestCase() {
assertStatusLineCleared()
}
@Test
fun `test search not found shows only error message on output panel`() {
configureByText("lorem ipsum dolor sit amet")
enterSearch("nonexistent")
val panelText = injector.outputPanel.getCurrentOutputPanel()?.text ?: ""
assertEquals("E486: Pattern not found: nonexistent", panelText)
}
@Test
fun `search in visual mode`() {
doTest(
@@ -123,23 +110,6 @@ class SearchEntryFwdActionTest : VimTestCase() {
)
}
@Test
fun `test escape after search not found closes panel without inserting escape char`() {
configureByText("lorem ipsum dolor sit amet")
typeText("/notfound")
val panel = ExEntryPanel.getOrCreatePanelInstance()
assertTrue(panel.isActive)
typeText("<Esc>")
assertFalse(panel.isActive)
assertMode(Mode.NORMAL())
// The panel text should not contain ^[ (escape character written as text)
assertFalse(panel.text.contains("\u001B"), "Panel text should not contain escape character")
assertFalse(panel.text.contains("^["), "Panel text should not contain ^[ literal")
}
@Disabled("Ctrl-o doesn't work yet in select mode")
@Test
fun `search in one time from select mode`() {

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
* license that can be found in the LICENSE.txt file or at
@@ -65,49 +65,6 @@ class SearchWholeWordForwardActionTest : VimTestCase() {
)
}
@Test
fun `test repeated star search wraps around`() {
configureByText(
"""
aaa
abc
def
abc
dfg
abc
agg
abc
xyz
""".trimIndent(),
)
typeText("5j") // move to line 5, "abc"
assertPosition(5, 0)
typeText("*")
assertPosition(7, 0) // next "abc" forward
typeText("*")
assertPosition(1, 0) // wraps to first "abc"
typeText("*")
assertPosition(3, 0)
typeText("*")
assertPosition(5, 0)
typeText("*")
assertPosition(7, 0)
typeText("*")
assertPosition(1, 0) // wraps again
typeText("*")
assertPosition(3, 0)
typeText("*")
assertPosition(5, 0)
}
@Test
fun `test last word`() {
doTest(

View File

@@ -1,113 +0,0 @@
/*
* Copyright 2003-2025 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.autocmd
import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.state.mode.Mode
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 AugroupTest : VimTestCase() {
@BeforeEach
override fun setUp(testInfo: TestInfo) {
super.setUp(testInfo)
configureByText("\n")
enterCommand("autocmd!")
}
@Test
fun `should register autocmd inside augroup`() {
enterCommand("augroup TestGroup")
enterCommand("autocmd InsertEnter * echo 23")
enterCommand("augroup END")
typeText(injector.parser.parseKeys("i"))
assertExOutput("23")
}
@Test
fun `autocmd bang inside augroup should clear only that group`() {
enterCommand("augroup G1")
enterCommand("autocmd InsertEnter * echo 1")
enterCommand("augroup END")
enterCommand("augroup G2")
enterCommand("autocmd InsertEnter * echo 2")
enterCommand("augroup END")
enterCommand("augroup G1")
enterCommand("autocmd!")
enterCommand("augroup END")
typeText(injector.parser.parseKeys("i"))
assertExOutput("2")
}
@Test
fun `augroup bang should remove all handlers from group`() {
enterCommand("augroup TestGroup")
enterCommand("autocmd InsertEnter * echo 23")
enterCommand("augroup END")
enterCommand("augroup! TestGroup")
typeText(injector.parser.parseKeys("i"))
assertNoExOutput()
}
@Test
fun `augroup should allow redefining group without bang (append handlers)`() {
enterCommand("augroup TestGroup")
enterCommand("autocmd InsertEnter * echo 1")
enterCommand("augroup END")
enterCommand("augroup TestGroup")
enterCommand("autocmd InsertEnter * echo 2")
enterCommand("augroup END")
typeText(injector.parser.parseKeys("i"))
assertExOutput("1\n2")
}
@Test
fun `augroup bang should redefine group (drop previous handlers)`() {
enterCommand("augroup TestGroup")
enterCommand("autocmd InsertEnter * echo 1")
enterCommand("augroup END")
enterCommand("augroup! TestGroup")
enterCommand("augroup TestGroup")
enterCommand("autocmd InsertEnter * echo 2")
enterCommand("augroup END")
typeText(injector.parser.parseKeys("i"))
assertExOutput("2")
}
@Test
fun `should keep groups independent`() {
enterCommand("augroup G1")
enterCommand("autocmd InsertEnter * echo 1")
enterCommand("augroup END")
enterCommand("augroup G2")
enterCommand("autocmd InsertLeave * echo 2")
enterCommand("augroup END")
typeText(injector.parser.parseKeys("i"))
assertExOutput("1")
typeText(injector.parser.parseKeys("<esc>"))
assertState(Mode.NORMAL())
assertExOutput("2")
}
}

View File

@@ -1,122 +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.autocmd
import com.maddyhome.idea.vim.vimscript.model.commands.AutoCmdCommand
import com.maddyhome.idea.vim.vimscript.parser.VimscriptParser
import com.maddyhome.idea.vim.vimscript.parser.errors.IdeavimErrorListener
import org.jetbrains.plugins.ideavim.VimTestCase
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInfo
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertTrue
class AutoCmdParseTest : VimTestCase() {
@BeforeEach
override fun setUp(testInfo: TestInfo) {
super.setUp(testInfo)
configureByText("\n")
}
private fun parseAutocmd(text: String): AutoCmdCommand {
val script = VimscriptParser.parse(text)
assertTrue(IdeavimErrorListener.testLogger.isEmpty(), "Parser errors: ${IdeavimErrorListener.testLogger}")
assertEquals(1, script.units.size)
return assertIs<AutoCmdCommand>(script.units.first())
}
@Test
fun `parse single event with star pattern`() {
val cmd = parseAutocmd("autocmd InsertEnter * echo hi")
assertEquals(listOf("InsertEnter"), cmd.eventNames)
assertEquals("*", cmd.filePattern)
assertEquals("echo hi", cmd.commandText)
}
@Test
fun `parse single event with extension pattern`() {
val cmd = parseAutocmd("autocmd InsertEnter *.py echo hi")
assertEquals(listOf("InsertEnter"), cmd.eventNames)
assertEquals("*.py", cmd.filePattern)
assertEquals("echo hi", cmd.commandText)
}
@Test
fun `parse comma-separated events with pattern`() {
val cmd = parseAutocmd("autocmd InsertEnter,InsertLeave *.txt echo hi")
assertEquals(listOf("InsertEnter", "InsertLeave"), cmd.eventNames)
assertEquals("*.txt", cmd.filePattern)
assertEquals("echo hi", cmd.commandText)
}
@Test
fun `parse events with spaces around commas`() {
val cmd = parseAutocmd("autocmd InsertEnter , InsertLeave * echo hi")
assertEquals(listOf("InsertEnter", "InsertLeave"), cmd.eventNames)
assertEquals("*", cmd.filePattern)
assertEquals("echo hi", cmd.commandText)
}
@Test
fun `parse brace pattern`() {
val cmd = parseAutocmd("autocmd InsertEnter *.{py,txt} echo hi")
assertEquals(listOf("InsertEnter"), cmd.eventNames)
assertEquals("*.{py,txt}", cmd.filePattern)
assertEquals("echo hi", cmd.commandText)
}
@Test
fun `parse bang has no events or pattern`() {
val cmd = parseAutocmd("autocmd!")
assertTrue(cmd.eventNames.isEmpty())
assertEquals(null, cmd.filePattern)
assertEquals(null, cmd.commandText)
}
@Test
fun `parse command with multiple spaces`() {
val cmd = parseAutocmd("autocmd InsertEnter * echo \"hello world\"")
assertEquals("*", cmd.filePattern)
assertEquals("echo \"hello world\"", cmd.commandText)
}
@Test
fun `parse exact filename pattern`() {
val cmd = parseAutocmd("autocmd InsertEnter Makefile echo hi")
assertEquals(listOf("InsertEnter"), cmd.eventNames)
assertEquals("Makefile", cmd.filePattern)
assertEquals("echo hi", cmd.commandText)
}
@Test
fun `parse unknown event name without errors`() {
val script = VimscriptParser.parse("autocmd BufReadPost * echo hi")
assertTrue(IdeavimErrorListener.testLogger.isEmpty())
assertEquals(1, script.units.size)
val cmd = assertIs<AutoCmdCommand>(script.units.first())
assertEquals(listOf("BufReadPost"), cmd.eventNames)
}
@Test
fun `parse multiline autocmd without errors`() {
val script = VimscriptParser.parse(
"""
autocmd BufReadPost *
\ if line("'\"") > 0 && line ("'\"") <= line("$") |
\ exe "normal! g'\"" |
\ endif
""".trimIndent(),
)
assertEquals(1, script.units.size)
assertTrue(IdeavimErrorListener.testLogger.isEmpty())
}
}

View File

@@ -1,146 +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.autocmd
import com.maddyhome.idea.vim.autocmd.AutoCmdPattern
import org.junit.jupiter.api.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class AutoCmdPatternTest {
@Test
fun `star matches any file`() {
assertTrue(AutoCmdPattern("*").matches("/path/to/file.txt"))
}
@Test
fun `star matches null path`() {
assertTrue(AutoCmdPattern("*").matches(null))
}
@Test
fun `non-star pattern does not match null path`() {
assertFalse(AutoCmdPattern("*.py").matches(null))
}
@Test
fun `extension pattern matches correct extension`() {
assertTrue(AutoCmdPattern("*.py").matches("/path/to/script.py"))
}
@Test
fun `extension pattern does not match wrong extension`() {
assertFalse(AutoCmdPattern("*.py").matches("/path/to/script.txt"))
}
@Test
fun `extension pattern matches file name only`() {
assertTrue(AutoCmdPattern("*.py").matches("/some/deep/path/test.py"))
}
@Test
fun `brace alternation matches first option`() {
assertTrue(AutoCmdPattern("*.{py,txt}").matches("/path/to/file.py"))
}
@Test
fun `brace alternation matches second option`() {
assertTrue(AutoCmdPattern("*.{py,txt}").matches("/path/to/file.txt"))
}
@Test
fun `brace alternation does not match unlisted extension`() {
assertFalse(AutoCmdPattern("*.{py,txt}").matches("/path/to/file.kt"))
}
@Test
fun `question mark matches single character`() {
assertTrue(AutoCmdPattern("?.txt").matches("/path/to/a.txt"))
}
@Test
fun `question mark does not match multiple characters`() {
assertFalse(AutoCmdPattern("?.txt").matches("/path/to/ab.txt"))
}
@Test
fun `exact filename matches`() {
assertTrue(AutoCmdPattern("Makefile").matches("/path/to/Makefile"))
}
@Test
fun `exact filename does not match different name`() {
assertFalse(AutoCmdPattern("Makefile").matches("/path/to/Rakefile"))
}
@Test
fun `pattern with path matches full path`() {
assertTrue(AutoCmdPattern("/home/user/*.py").matches("/home/user/script.py"))
}
@Test
fun `pattern with path does not match different directory`() {
assertFalse(AutoCmdPattern("/home/user/*.py").matches("/other/path/script.py"))
}
@Test
fun `double star matches across directories`() {
assertTrue(AutoCmdPattern("**/*.py").matches("/some/deep/path/script.py"))
}
@Test
fun `star does not match path separators`() {
assertFalse(AutoCmdPattern("src/*.py").matches("src/sub/script.py"))
}
@Test
fun `double star matches path separators`() {
assertTrue(AutoCmdPattern("src/**/*.py").matches("src/sub/script.py"))
}
@Test
fun `bracket character class matches`() {
assertTrue(AutoCmdPattern("*.[ch]").matches("/path/to/file.c"))
assertTrue(AutoCmdPattern("*.[ch]").matches("/path/to/file.h"))
}
@Test
fun `bracket character class does not match unlisted`() {
assertFalse(AutoCmdPattern("*.[ch]").matches("/path/to/file.o"))
}
@Test
fun `dot in extension is escaped properly`() {
assertFalse(AutoCmdPattern("*.py").matches("/path/to/file_py"))
}
@Test
fun `prefix pattern matches`() {
assertTrue(AutoCmdPattern("test*").matches("/path/to/test_file.py"))
}
@Test
fun `prefix pattern does not match different prefix`() {
assertFalse(AutoCmdPattern("test*").matches("/path/to/prod_file.py"))
}
@Test
fun `multiple extensions with brace`() {
val pattern = AutoCmdPattern("*.{c,h,cpp,hpp}")
assertTrue(pattern.matches("/path/to/main.cpp"))
assertTrue(pattern.matches("/path/to/main.h"))
assertFalse(pattern.matches("/path/to/main.py"))
}
@Test
fun `simple filename without extension`() {
assertTrue(AutoCmdPattern("*").matches("/path/to/Makefile"))
}
}

View File

@@ -1,324 +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.autocmd
import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.state.mode.Mode
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 AutoCmdTest : VimTestCase() {
@BeforeEach
override fun setUp(testInfo: TestInfo) {
super.setUp(testInfo)
configureByText("\n")
enterCommand("autocmd!")
injector.outputPanel.getCurrentOutputPanel()?.close()
}
@Test
fun `should execute command on InsertEnter`() {
enterCommand("autocmd InsertEnter * echo \"hi\"")
typeText(injector.parser.parseKeys("i"))
assertExOutput("hi")
}
@Test
fun `should do nothing on invalid syntax`() {
enterCommand("autocmd InsertEnter echo 23")
typeText(injector.parser.parseKeys("i"))
assertNoExOutput()
}
@Test
fun `should execute command on InsertLeave`() {
enterCommand("autocmd InsertLeave * echo 23")
typeText(injector.parser.parseKeys("i"))
typeText(injector.parser.parseKeys("<esc>"))
assertState(Mode.NORMAL())
assertExOutput("23")
}
@Test
fun `should fire InsertEnter when entering Replace mode`() {
enterCommand("autocmd InsertEnter * echo \"enter\"")
typeText(injector.parser.parseKeys("R"))
assertState(Mode.REPLACE)
assertExOutput("enter")
}
@Test
fun `should fire InsertLeave when leaving Replace mode`() {
enterCommand("autocmd InsertLeave * echo \"leave\"")
typeText(injector.parser.parseKeys("R"))
typeText(injector.parser.parseKeys("<esc>"))
assertState(Mode.NORMAL())
assertExOutput("leave")
}
@Test
fun `should clear commands`() {
enterCommand("autocmd InsertEnter * echo 23")
enterCommand("autocmd!")
typeText(injector.parser.parseKeys("i"))
assertNoExOutput()
}
@Test
fun `should do nothing when pattern does not match file`() {
enterCommand("autocmd InsertEnter *.py echo 23")
typeText(injector.parser.parseKeys("i"))
assertNoExOutput()
}
@Test
fun `should execute command every time InsertEnter is triggered`() {
enterCommand("autocmd InsertEnter * echo 23")
typeText(injector.parser.parseKeys("i"))
typeText(injector.parser.parseKeys("<esc>"))
assertExOutput("23")
typeText(injector.parser.parseKeys("i"))
typeText(injector.parser.parseKeys("<esc>"))
assertExOutput("23")
}
@Test
fun `should not execute InsertLeave command if insert mode is not left`() {
enterCommand("autocmd InsertLeave * echo 23")
typeText(injector.parser.parseKeys("i"))
assertNoExOutput()
}
@Test
fun `should execute multiple handlers for same event`() {
enterCommand("autocmd InsertEnter * echo 1")
enterCommand("autocmd InsertEnter * echo 2")
typeText(injector.parser.parseKeys("i"))
assertExOutput("1\n2")
}
@Test
fun `should execute only matching event handlers`() {
enterCommand("autocmd InsertEnter * echo 1")
enterCommand("autocmd InsertLeave * echo 2")
typeText(injector.parser.parseKeys("i"))
assertExOutput("1")
typeText(injector.parser.parseKeys("<esc>"))
assertExOutput("2")
}
@Test
fun `autocmd bang should clear all event handlers`() {
enterCommand("autocmd InsertEnter * echo 1")
enterCommand("autocmd InsertLeave * echo 2")
enterCommand("autocmd!")
typeText(injector.parser.parseKeys("i"))
typeText(injector.parser.parseKeys("<esc>"))
assertNoExOutput()
}
@Test
fun `should execute InsertEnter when entering insert from visual mode with c`() {
configureByText("hello world")
enterCommand("autocmd InsertEnter * echo \"entering insert\"")
typeText(injector.parser.parseKeys("viw")) // select word
typeText(injector.parser.parseKeys("c")) // change (enters insert)
assertExOutput("entering insert")
assertState(Mode.INSERT)
}
@Test
fun `should execute InsertEnter when entering insert from visual mode with s`() {
configureByText("hello world")
enterCommand("autocmd InsertEnter * echo \"substitute\"")
typeText(injector.parser.parseKeys("viw")) // select word
typeText(injector.parser.parseKeys("s")) // substitute (enters insert)
assertExOutput("substitute")
assertState(Mode.INSERT)
}
@Test
fun `should execute InsertLeave after entering from visual mode`() {
configureByText("hello world")
enterCommand("autocmd InsertLeave * echo \"leaving insert\"")
typeText(injector.parser.parseKeys("viw")) // select word
typeText(injector.parser.parseKeys("c")) // change (enters insert)
typeText(injector.parser.parseKeys("<esc>"))
assertExOutput("leaving insert")
assertState(Mode.NORMAL())
}
@Test
fun `should execute both InsertEnter and InsertLeave from visual mode`() {
configureByText("hello world")
enterCommand("autocmd InsertEnter * echo \"enter\"")
enterCommand("autocmd InsertLeave * echo \"leave\"")
typeText(injector.parser.parseKeys("viw")) // select word
typeText(injector.parser.parseKeys("c")) // change (enters insert)
assertExOutput("enter")
typeText(injector.parser.parseKeys("<esc>"))
assertExOutput("leave")
}
@Test
fun `should register multiple events with comma-separated syntax`() {
enterCommand("autocmd InsertEnter,InsertLeave * echo \"triggered\"")
typeText(injector.parser.parseKeys("i"))
assertExOutput("triggered")
typeText(injector.parser.parseKeys("<esc>"))
assertExOutput("triggered")
}
@Test
fun `should handle spaces around commas in multiple events`() {
enterCommand("autocmd InsertEnter , InsertLeave * echo \"triggered\"")
typeText(injector.parser.parseKeys("i"))
assertExOutput("triggered")
typeText(injector.parser.parseKeys("<esc>"))
assertExOutput("triggered")
}
@Test
fun `should register three events with comma-separated syntax`() {
configureByText("hello")
enterCommand("autocmd InsertEnter,InsertLeave,BufEnter * echo \"event\"")
// InsertEnter
typeText(injector.parser.parseKeys("i"))
assertExOutput("event")
// InsertLeave
typeText(injector.parser.parseKeys("<esc>"))
assertExOutput("event")
}
@Test
fun `should fail gracefully with invalid event in comma-separated list`() {
enterCommand("autocmd InsertEnter,InvalidEvent * echo \"test\"")
typeText(injector.parser.parseKeys("i"))
assertNoExOutput()
}
@Test
fun `should match file extension pattern`() {
configureByFileName("test.txt")
enterCommand("autocmd!")
enterCommand("autocmd InsertEnter *.txt echo \"matched\"")
typeText(injector.parser.parseKeys("i"))
assertExOutput("matched")
}
@Test
fun `should not match wrong file extension pattern`() {
configureByFileName("test.txt")
enterCommand("autocmd!")
enterCommand("autocmd InsertEnter *.py echo \"matched\"")
typeText(injector.parser.parseKeys("i"))
assertNoExOutput()
}
@Test
fun `should match brace alternation pattern`() {
configureByFileName("test.txt")
enterCommand("autocmd!")
enterCommand("autocmd InsertEnter *.{py,txt} echo \"matched\"")
typeText(injector.parser.parseKeys("i"))
assertExOutput("matched")
}
@Test
fun `should not match brace alternation when extension not listed`() {
configureByFileName("test.kt")
enterCommand("autocmd!")
enterCommand("autocmd InsertEnter *.{py,txt} echo \"matched\"")
typeText(injector.parser.parseKeys("i"))
assertNoExOutput()
}
@Test
fun `should match question mark single char pattern`() {
configureByFileName("test.py")
enterCommand("autocmd!")
enterCommand("autocmd InsertEnter *.?y echo \"matched\"")
typeText(injector.parser.parseKeys("i"))
assertExOutput("matched")
}
@Test
fun `should match exact filename pattern`() {
configureByFileName("Makefile")
enterCommand("autocmd!")
enterCommand("autocmd InsertEnter Makefile echo \"matched\"")
typeText(injector.parser.parseKeys("i"))
assertExOutput("matched")
}
@Test
fun `should not match different exact filename`() {
configureByFileName("Rakefile")
enterCommand("autocmd!")
enterCommand("autocmd InsertEnter Makefile echo \"matched\"")
typeText(injector.parser.parseKeys("i"))
assertNoExOutput()
}
@Test
fun `should match star pattern on any file`() {
configureByFileName("anything.xyz")
enterCommand("autocmd!")
enterCommand("autocmd InsertEnter * echo \"matched\"")
typeText(injector.parser.parseKeys("i"))
assertExOutput("matched")
}
@Test
fun `should execute only commands with matching pattern`() {
configureByFileName("test.py")
enterCommand("autocmd!")
enterCommand("autocmd InsertEnter *.py echo \"python\"")
enterCommand("autocmd InsertEnter *.txt echo \"text\"")
typeText(injector.parser.parseKeys("i"))
assertExOutput("python")
}
@Test
fun `should execute commands with star and specific pattern`() {
configureByFileName("test.py")
enterCommand("autocmd!")
enterCommand("autocmd InsertEnter * echo \"all\"")
enterCommand("autocmd InsertEnter *.py echo \"python\"")
typeText(injector.parser.parseKeys("i"))
assertExOutput("all\npython")
}
}

View File

@@ -1,109 +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.autocmd
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.command.WriteCommandAction
import com.intellij.openapi.components.ComponentManagerEx
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.fileEditor.FileEditorManager
import com.intellij.openapi.fileEditor.impl.FileEditorManagerImpl
import com.intellij.platform.util.coroutines.childScope
import com.intellij.testFramework.fixtures.CodeInsightTestFixture
import com.intellij.testFramework.fixtures.IdeaTestFixtureFactory
import com.intellij.testFramework.replaceService
import org.jetbrains.plugins.ideavim.SkipNeovimReason
import org.jetbrains.plugins.ideavim.TestWithoutNeovim
import org.jetbrains.plugins.ideavim.VimTestCase
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInfo
@TestWithoutNeovim(reason = SkipNeovimReason.NOT_VIM_TESTING)
class BufNewFileAutoCmdTest : VimTestCase() {
private lateinit var fileEditorManager: FileEditorManagerImpl
@BeforeEach
override fun setUp(testInfo: TestInfo) {
super.setUp(testInfo)
fileEditorManager =
FileEditorManagerImpl(
fixture.project,
(fixture.project as ComponentManagerEx)
.getCoroutineScope()
.childScope(name = "BufNewFileAutoCmdTestScope")
)
fixture.project.replaceService(FileEditorManager::class.java, fileEditorManager, fixture.testRootDisposable)
ApplicationManager.getApplication().invokeAndWait {
configureByText("\n")
}
enterCommand("autocmd!")
}
@AfterEach
override fun tearDown(testInfo: TestInfo) {
try {
enterCommand("autocmd!")
} finally {
super.tearDown(testInfo)
}
}
override fun createFixture(factory: IdeaTestFixtureFactory): CodeInsightTestFixture {
val fixture = factory.createFixtureBuilder("IdeaVim").fixture
return factory.createCodeInsightFixture(fixture)
}
@Test
fun `should fire BufNewFile when creating and opening a new file`() {
enterCommand("autocmd BufNewFile * echo \"new\"")
openNewFile("fresh.txt")
assertExOutput("new")
}
@Test
fun `should not fire BufRead for a newly created file`() {
enterCommand("autocmd BufNewFile * echo \"new\"")
enterCommand("autocmd BufRead * echo \"read\"")
openNewFile("fresh.txt")
// Vim semantics: only BufNewFile fires for new files, BufRead is suppressed
assertExOutput("new")
}
@Test
fun `should fire FileType alongside BufNewFile`() {
enterCommand("autocmd BufNewFile * echo \"1-new\"")
enterCommand("autocmd FileType * echo \"2-filetype\"")
openNewFile("fresh.txt")
assertExOutput("1-new\n2-filetype")
}
@Test
fun `should match BufNewFile against file pattern`() {
enterCommand("autocmd BufNewFile *.py echo \"py\"")
openNewFile("fresh.txt")
assertNoExOutput()
}
private fun openNewFile(filename: String): Editor {
ApplicationManager.getApplication().invokeAndWait {
val parent = fixture.tempDirFixture.getFile(".") ?: error("temp dir unavailable")
val file = WriteCommandAction.runWriteCommandAction<com.intellij.openapi.vfs.VirtualFile>(fixture.project) {
parent.createChildData(this, filename).apply { setBinaryContent("lorem ipsum".toByteArray()) }
}
fixture.openFileInEditor(file)
}
return fixture.editor
}
}

View File

@@ -1,116 +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.autocmd
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.components.ComponentManagerEx
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.fileEditor.FileEditorManager
import com.intellij.openapi.fileEditor.impl.FileEditorManagerImpl
import com.intellij.platform.util.coroutines.childScope
import com.intellij.testFramework.fixtures.CodeInsightTestFixture
import com.intellij.testFramework.fixtures.IdeaTestFixtureFactory
import com.intellij.testFramework.replaceService
import com.maddyhome.idea.vim.listener.BufNewFileTracker
import org.jetbrains.plugins.ideavim.SkipNeovimReason
import org.jetbrains.plugins.ideavim.TestWithoutNeovim
import org.jetbrains.plugins.ideavim.VimTestCase
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInfo
@TestWithoutNeovim(reason = SkipNeovimReason.NOT_VIM_TESTING)
class BufReadAutoCmdTest : VimTestCase() {
private lateinit var fileEditorManager: FileEditorManagerImpl
@BeforeEach
override fun setUp(testInfo: TestInfo) {
super.setUp(testInfo)
fileEditorManager =
FileEditorManagerImpl(
fixture.project,
(fixture.project as ComponentManagerEx)
.getCoroutineScope()
.childScope(name = "BufReadAutoCmdTestScope")
)
fixture.project.replaceService(FileEditorManager::class.java, fileEditorManager, fixture.testRootDisposable)
ApplicationManager.getApplication().invokeAndWait {
configureByText("\n")
}
enterCommand("autocmd!")
}
@AfterEach
override fun tearDown(testInfo: TestInfo) {
try {
enterCommand("autocmd!")
} finally {
super.tearDown(testInfo)
}
}
override fun createFixture(factory: IdeaTestFixtureFactory): CodeInsightTestFixture {
val fixture = factory.createFixtureBuilder("IdeaVim").fixture
return factory.createCodeInsightFixture(fixture)
}
@Test
fun `should fire BufRead when opening a file`() {
enterCommand("autocmd BufRead * echo \"read\"")
openFile("hello.txt")
assertExOutput("read")
}
@Test
fun `should fire BufReadPost when opening a file`() {
enterCommand("autocmd BufReadPost * echo \"post\"")
openFile("hello.txt")
assertExOutput("post")
}
@Test
fun `should match BufRead against file extension`() {
enterCommand("autocmd BufRead *.txt echo \"txt\"")
openFile("hello.txt")
assertExOutput("txt")
}
@Test
fun `should not fire BufRead for non-matching pattern`() {
enterCommand("autocmd BufRead *.py echo \"py\"")
openFile("hello.txt")
assertNoExOutput()
}
@Test
fun `should fire BufRead BufReadPost and FileType in vim order`() {
// Vim order for opening an existing file: BufRead == BufReadPost → FileType → BufEnter
enterCommand("autocmd BufRead * echo \"1-read\"")
enterCommand("autocmd BufReadPost * echo \"2-readpost\"")
enterCommand("autocmd FileType * echo \"3-filetype\"")
openFile("hello.txt")
assertExOutput("1-read\n2-readpost\n3-filetype")
}
private fun openFile(filename: String): Editor {
ApplicationManager.getApplication().invokeAndWait {
val file = fixture.createFile(filename, "lorem ipsum")
// Simulate opening an existing (already on-disk) file: clear the "newly created"
// marker so the open fires BufRead/BufReadPost instead of BufNewFile.
BufNewFileTracker.consumeIfNew(file.path)
fixture.openFileInEditor(file)
}
return fixture.editor
}
}

View File

@@ -1,120 +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.autocmd
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.command.WriteCommandAction
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.fileEditor.FileDocumentManager
import com.maddyhome.idea.vim.listener.BufNewFileTracker
import org.jetbrains.plugins.ideavim.SkipNeovimReason
import org.jetbrains.plugins.ideavim.TestWithoutNeovim
import org.jetbrains.plugins.ideavim.VimTestCase
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInfo
@TestWithoutNeovim(reason = SkipNeovimReason.NOT_VIM_TESTING)
class BufWriteAutoCmdTest : VimTestCase() {
@BeforeEach
override fun setUp(testInfo: TestInfo) {
super.setUp(testInfo)
ApplicationManager.getApplication().invokeAndWait {
configureByText("\n")
}
enterCommand("autocmd!")
}
@AfterEach
override fun tearDown(testInfo: TestInfo) {
try {
enterCommand("autocmd!")
} finally {
super.tearDown(testInfo)
}
}
@Test
fun `should fire BufWritePre on save`() {
enterCommand("autocmd BufWritePre * echo \"pre\"")
modifyAndSave(openFile("hello.txt"))
assertExOutput("pre")
}
@Test
fun `should fire BufWritePost on save`() {
enterCommand("autocmd BufWritePost * echo \"post\"")
modifyAndSave(openFile("hello.txt"))
assertExOutput("post")
}
@Test
fun `BufWrite should be alias for BufWritePre`() {
enterCommand("autocmd BufWrite * echo \"write\"")
modifyAndSave(openFile("hello.txt"))
assertExOutput("write")
}
@Test
fun `should fire BufWritePre before BufWritePost`() {
enterCommand("autocmd BufWritePre * echo \"1-pre\"")
enterCommand("autocmd BufWritePost * echo \"2-post\"")
modifyAndSave(openFile("hello.txt"))
assertExOutput("1-pre\n2-post")
}
@Test
fun `should not fire for non-matching pattern`() {
enterCommand("autocmd BufWritePre *.py echo \"py\"")
modifyAndSave(openFile("hello.txt"))
assertNoExOutput()
}
@Test
fun `should match pattern against file extension`() {
enterCommand("autocmd BufWritePre *.txt echo \"txt\"")
modifyAndSave(openFile("hello.txt"))
assertExOutput("txt")
}
@Test
fun `should fire autocmd against saved document, not focused editor`() {
// a.py: 1 line. b.py: 5 lines. Opening b.py last makes it the focused editor.
val aEditor = openFile("a.py", "one-line")
openFile("b.py", "l1\nl2\nl3\nl4\nl5")
// line('$') reports the line count of the editor the autocmd runs against.
enterCommand("autocmd BufWritePre * echo line('$')")
modifyAndSave(aEditor)
// If the handler mistakenly ran against the focused b.py, output would be "5".
assertExOutput("1")
}
private fun openFile(filename: String, content: String = "initial content"): Editor {
ApplicationManager.getApplication().invokeAndWait {
val file = fixture.createFile(filename, content)
// Clear newly-created marker so this isn't treated as BufNewFile.
BufNewFileTracker.consumeIfNew(file.path)
fixture.openFileInEditor(file)
}
return fixture.editor
}
private fun modifyAndSave(editor: Editor) {
ApplicationManager.getApplication().invokeAndWait {
WriteCommandAction.runWriteCommandAction(fixture.project) {
editor.document.insertString(0, "x")
}
FileDocumentManager.getInstance().saveDocument(editor.document)
}
}
}

View File

@@ -1,198 +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.autocmd
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.components.ComponentManagerEx
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.fileEditor.FileEditorManager
import com.intellij.openapi.fileEditor.TextEditor
import com.intellij.openapi.fileEditor.impl.EditorWindow
import com.intellij.openapi.fileEditor.impl.FileEditorManagerImpl
import com.intellij.platform.util.coroutines.childScope
import com.intellij.testFramework.fixtures.CodeInsightTestFixture
import com.intellij.testFramework.fixtures.IdeaTestFixtureFactory
import com.intellij.testFramework.replaceService
import org.jetbrains.plugins.ideavim.SkipNeovimReason
import org.jetbrains.plugins.ideavim.TestWithoutNeovim
import org.jetbrains.plugins.ideavim.VimTestCase
import org.jetbrains.plugins.ideavim.waitUntil
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInfo
import javax.swing.SwingConstants
@TestWithoutNeovim(reason = SkipNeovimReason.NOT_VIM_TESTING)
class BuffAutoCmdTest : VimTestCase() {
private lateinit var fileEditorManager: FileEditorManagerImpl
private lateinit var mainWindow: Editor
private lateinit var otherBufferWindow: Editor
private lateinit var splitWindow: Editor
@BeforeEach
override fun setUp(testInfo: TestInfo) {
super.setUp(testInfo)
fileEditorManager =
FileEditorManagerImpl(
fixture.project,
(fixture.project as ComponentManagerEx)
.getCoroutineScope()
.childScope(name = "BuffAutoCmdTestScope")
)
fixture.project.replaceService(FileEditorManager::class.java, fileEditorManager, fixture.testRootDisposable)
// Create a new editor that will represent a new buffer in a separate window. It will have default values
otherBufferWindow = openNewBufferWindow("bbb.txt")
var curWindow: EditorWindow? = null
ApplicationManager.getApplication().invokeAndWait {
// Create the original editor last, so that fixture.editor will point to this file
// It is STRONGLY RECOMMENDED to use mainWindow instead of fixture.editor, so we know which editor we're using
mainWindow = configureByText("\n") // aaa.txt
curWindow = fileEditorManager.currentWindow
}
curWindow.let {
// Split the original editor into a new window, then reset the focus back to the originalEditor's EditorWindow
// We do this before setting any custom state, so it will have default values for everything
splitWindow = openSplitWindow(mainWindow) // aaa.txt
fileEditorManager.currentWindow = it
}
// Start each test with a clean autocmd list
enterCommand("autocmd!")
}
@AfterEach
override fun tearDown(testInfo: TestInfo) {
try {
enterCommand("autocmd!")
} finally {
super.tearDown(testInfo)
}
}
override fun createFixture(factory: IdeaTestFixtureFactory): CodeInsightTestFixture {
val fixture = factory.createFixtureBuilder("IdeaVim").fixture
return factory.createCodeInsightFixture(fixture)
}
@Test
fun `should support BufEnter event`() {
enterCommand("autocmd BufEnter * echo 2")
openNewBufferWindow("test.txt")
assertExOutput("2")
}
@Test
fun `should support BufLeave event`() {
enterCommand("autocmd BufLeave * echo 3")
closeWindow(otherBufferWindow)
assertExOutput("3")
}
@Test
fun `should fire WinEnter when switching to different file`() {
enterCommand("autocmd WinEnter * echo \"win\"")
openNewBufferWindow("test.txt")
assertExOutput("win")
}
@Test
fun `should fire WinLeave when switching to different file`() {
enterCommand("autocmd WinLeave * echo \"left\"")
openNewBufferWindow("test.txt")
assertExOutput("left")
}
@Test
fun `should not fire BufEnter when switching to different file with only WinEnter registered`() {
// Only WinEnter is registered — BufEnter should not produce output
enterCommand("autocmd WinEnter * echo \"win\"")
openNewBufferWindow("test.txt")
assertExOutput("win") // only WinEnter output, no BufEnter
}
@Test
fun `should fire events in vim order when switching to different buffer`() {
// Vim order: BufLeave → WinLeave → WinEnter → BufEnter
enterCommand("autocmd BufLeave * echo \"1-BufLeave\"")
enterCommand("autocmd WinLeave * echo \"2-WinLeave\"")
enterCommand("autocmd WinEnter * echo \"3-WinEnter\"")
enterCommand("autocmd BufEnter * echo \"4-BufEnter\"")
openNewBufferWindow("test.txt")
assertExOutput("1-BufLeave\n2-WinLeave\n3-WinEnter\n4-BufEnter")
}
@Test
fun `should not fire BufEnter or BufLeave when reopening same buffer`() {
// Opening a file that's already the current buffer should not fire Buf events
// (oldFile and newFile are the same path)
val currentFile = mainWindow.virtualFile!!
enterCommand("autocmd BufEnter * echo \"bufenter\"")
enterCommand("autocmd BufLeave * echo \"bufleave\"")
ApplicationManager.getApplication().invokeAndWait {
fileEditorManager.openFile(currentFile, true)
}
assertNoExOutput()
}
private fun openNewBufferWindow(filename: String): Editor {
ApplicationManager.getApplication().invokeAndWait {
fixture.openFileInEditor(fixture.createFile(filename, "lorem ipsum"))
}
return fixture.editor
}
private fun openSplitWindow(editor: Editor): Editor {
var splitWindow: EditorWindow? = null
ApplicationManager.getApplication().invokeAndWait {
val currentWindow = fileEditorManager.currentWindow
splitWindow = currentWindow!!.split(
SwingConstants.VERTICAL,
true,
editor.virtualFile,
false
)
}
waitUntil {
splitWindow!!.allComposites.first().selectedEditor != null
}
return (splitWindow!!.allComposites.first().selectedEditor as TextEditor).editor
}
/**
* Closes the given editor
*/
private fun closeWindow(editor: Editor) {
ApplicationManager.getApplication().invokeAndWait {
// Just using fileEditorManager.closeFile(editor.virtualFile) can cause weird side effects, like opening a
// different buffer in an open editor. See FileGroup.closeFile
// But we can't just rely on the current EditorWindow. E.g., if we're trying to close a file that's not currently
// open in the current window, or is open in a split while we want to close the *other* editor...
val editorWindow = fileEditorManager.windows.first { window ->
window.allComposites.any { composite ->
composite.allEditors
.filterIsInstance<TextEditor>()
.any { textEditor -> textEditor.editor == editor }
}
}
val virtualFile = editor.virtualFile
if (virtualFile != null) {
editorWindow.closeFile(virtualFile)
editorWindow.requestFocus(true)
}
}
}
}

View File

@@ -1,109 +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.autocmd
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.components.ComponentManagerEx
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.fileEditor.FileEditorManager
import com.intellij.openapi.fileEditor.impl.FileEditorManagerImpl
import com.intellij.platform.util.coroutines.childScope
import com.intellij.testFramework.fixtures.CodeInsightTestFixture
import com.intellij.testFramework.fixtures.IdeaTestFixtureFactory
import com.intellij.testFramework.replaceService
import org.jetbrains.plugins.ideavim.SkipNeovimReason
import org.jetbrains.plugins.ideavim.TestWithoutNeovim
import org.jetbrains.plugins.ideavim.VimTestCase
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInfo
@TestWithoutNeovim(reason = SkipNeovimReason.NOT_VIM_TESTING)
class FileTypeAutoCmdTest : VimTestCase() {
private lateinit var fileEditorManager: FileEditorManagerImpl
@BeforeEach
override fun setUp(testInfo: TestInfo) {
super.setUp(testInfo)
fileEditorManager =
FileEditorManagerImpl(
fixture.project,
(fixture.project as ComponentManagerEx)
.getCoroutineScope()
.childScope(name = "FileTypeAutoCmdTestScope")
)
fixture.project.replaceService(FileEditorManager::class.java, fileEditorManager, fixture.testRootDisposable)
ApplicationManager.getApplication().invokeAndWait {
configureByText("\n")
}
enterCommand("autocmd!")
}
@AfterEach
override fun tearDown(testInfo: TestInfo) {
try {
enterCommand("autocmd!")
} finally {
super.tearDown(testInfo)
}
}
override fun createFixture(factory: IdeaTestFixtureFactory): CodeInsightTestFixture {
val fixture = factory.createFixtureBuilder("IdeaVim").fixture
return factory.createCodeInsightFixture(fixture)
}
@Test
fun `should fire FileType when opening a file`() {
enterCommand("autocmd FileType text echo \"text-file\"")
openFile("hello.txt")
assertExOutput("text-file")
}
@Test
fun `should match FileType pattern against filetype name not file path`() {
// Pattern `*.txt` matches file paths, not filetype names, so it should NOT fire
enterCommand("autocmd FileType *.txt echo \"path\"")
openFile("hello.txt")
assertNoExOutput()
}
@Test
fun `should match FileType with wildcard pattern`() {
enterCommand("autocmd FileType * echo \"any\"")
openFile("hello.txt")
assertExOutput("any")
}
@Test
fun `should match FileType with alternation pattern`() {
enterCommand("autocmd FileType {text,python} echo \"matched\"")
openFile("hello.txt")
assertExOutput("matched")
}
@Test
fun `should not fire FileType for non-matching filetype`() {
enterCommand("autocmd FileType python echo \"py\"")
openFile("hello.txt")
assertNoExOutput()
}
private fun openFile(filename: String): Editor {
ApplicationManager.getApplication().invokeAndWait {
fixture.openFileInEditor(fixture.createFile(filename, "lorem ipsum"))
}
return fixture.editor
}
}

View File

@@ -169,31 +169,4 @@ class AddressTest : VimTestCase() {
typeText(commandToKeys("/bar//foo/d"))
assertState("a\nfoo\nbar\nbar\nbaz\n")
}
@Test
fun `test backslash-slash range without previous search reports E35`() {
// Before this fix, using \/ with no previous search caused a NullPointerException instead of E35
configureByText("1\n2\n3\n")
typeText(commandToKeys("\\/d"))
assertPluginError(true)
assertPluginErrorMessage("E35: No previous regular expression")
}
@Test
fun `test backslash-question range without previous search reports E35`() {
// Before this fix, using \? with no previous search caused a NullPointerException instead of E35
configureByText("1\n2\n3\n")
typeText(commandToKeys("\\?d"))
assertPluginError(true)
assertPluginErrorMessage("E35: No previous regular expression")
}
@Test
fun `test backslash-ampersand range without previous substitute reports E33`() {
// Before this fix, using \& with no previous substitute caused a NullPointerException instead of E33
configureByText("1\n2\n3\n")
typeText(commandToKeys("\\&d"))
assertPluginError(true)
assertPluginErrorMessage("E33: No previous substitute regular expression")
}
}

View File

@@ -361,7 +361,7 @@ class CommandParserTest : VimTestCase() {
\ endif
""".trimIndent(),
)
assertEquals(1, script.units.size)
assertEquals(0, script.units.size)
assertTrue(IdeavimErrorListener.testLogger.isEmpty())
script = VimscriptParser.parse(
@@ -369,7 +369,7 @@ class CommandParserTest : VimTestCase() {
autocmd BufReadPost * echo "oh, hi Mark"
""".trimIndent(),
)
assertEquals(1, script.units.size)
assertEquals(0, script.units.size)
assertTrue(IdeavimErrorListener.testLogger.isEmpty())
}

View File

@@ -9,20 +9,13 @@
package org.jetbrains.plugins.ideavim.ex
import com.intellij.openapi.application.ApplicationManager
import com.intellij.testFramework.LoggedErrorProcessor
import com.maddyhome.idea.vim.KeyHandler
import com.maddyhome.idea.vim.VimPlugin
import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.newapi.vim
import com.maddyhome.idea.vim.state.mode.Mode
import org.jetbrains.plugins.ideavim.OnlyThrowLoggedErrorProcessor
import org.jetbrains.plugins.ideavim.action.ex.VimExTestCase
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertDoesNotThrow
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class ExEntryTest : VimExTestCase() {
@@ -217,56 +210,4 @@ class ExEntryTest : VimExTestCase() {
typeText(":echo <C-V>x80")
assertRenderedExText("echo <80>")
}
// VIM-4115: closing the command line alongside fullReset() must clear editor mode and the
// KeyHandler's commandLineCommandBuilder, not just deactivate the panel. Without close(), the
// KeyHandler singleton retains the CMD_LINE builder across plugin disable/enable and the next
// Esc NPEs in CommandKeyConsumer.
@Test
fun `test VIM-4115 close before fullReset clears all command line state`() {
typeText(":set incsearch")
assertExIsActive()
assertTrue(fixture.editor.vim.mode is Mode.CMD_LINE)
assertNotNull(KeyHandler.getInstance().keyHandlerState.commandLineCommandBuilder)
ApplicationManager.getApplication().invokeAndWait {
val commandLine = injector.commandLine
commandLine.getActiveCommandLine()?.close(refocusOwningEditor = true, resetCaret = false)
commandLine.fullReset()
}
assertExIsDeactivated()
assertFalse(fixture.editor.vim.mode is Mode.CMD_LINE)
assertNull(KeyHandler.getInstance().keyHandlerState.commandLineCommandBuilder)
}
// VIM-4115: if some other path still desyncs command-line state (panel gone but
// commandLineCommandBuilder set with the CMD_LINE trie), Esc in the editor must not NPE. The
// defensive branch logs an error and clears the leftover builder.
@Test
fun `test VIM-4115 escape with stale command line builder does not crash`() {
typeText(":set incsearch")
assertExIsActive()
// Reproduce the pre-fix plugin-disable state: panel gone, builder and mode left behind. Use
// INSERT (not NORMAL) so EditorResetConsumer won't claim Esc and the key actually reaches
// CommandKeyConsumer where the crash lives.
ApplicationManager.getApplication().invokeAndWait {
injector.commandLine.fullReset()
fixture.editor.vim.mode = Mode.INSERT
}
assertNotNull(KeyHandler.getInstance().keyHandlerState.commandLineCommandBuilder)
// The defensive path logs an error; rethrow it so we can assert no NPE slips through.
try {
LoggedErrorProcessor.executeWith<Throwable>(OnlyThrowLoggedErrorProcessor) {
assertDoesNotThrow { typeText("<Esc>") }
}
} catch (e: Throwable) {
val message = generateSequence(e) { it.cause }.mapNotNull { it.message }.joinToString(" / ")
assertTrue(message.contains("VIM-4115"), "Expected VIM-4115 logger.error, got: $message")
}
assertNull(KeyHandler.getInstance().keyHandlerState.commandLineCommandBuilder)
}
}

View File

@@ -191,20 +191,6 @@ class CmdCommandTest : VimTestCase() {
assertPluginError(true)
}
@Test
fun `test add command with nargs but missing name should not crash`() {
// Regression test: alias[0] on an empty string threw IndexOutOfBoundsException
// when only -nargs was provided without a command name (e.g. "command -nargs=0")
VimPlugin.getCommand().resetAliases()
configureByText("\n")
typeText(commandToKeys("command -nargs=0"))
assertPluginError(true)
typeText(commandToKeys("command! -nargs=1"))
assertPluginError(true)
typeText(commandToKeys("command -nargs=*"))
assertPluginError(true)
}
@Test
fun `test run command with arguments`() {
VimPlugin.getCommand().resetAliases()

View File

@@ -1,431 +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.commands
import com.intellij.openapi.vfs.LocalFileSystem
import org.jetbrains.plugins.ideavim.action.ex.VimExTestCase
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInfo
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import kotlin.io.path.absolutePathString
import kotlin.io.path.createDirectories
import kotlin.io.path.createFile
import kotlin.test.assertEquals
class CommandLineCompletionTest : VimExTestCase() {
@TempDir
lateinit var tempDir: Path
private lateinit var tempPath: String
@BeforeEach
override fun setUp(testInfo: TestInfo) {
super.setUp(testInfo)
createTestFiles()
tempPath = tempDir.absolutePathString()
}
private fun createTestFiles() {
tempDir.resolve("alpha.txt").createFile()
tempDir.resolve("beta.txt").createFile()
tempDir.resolve("bravo.kt").createFile()
tempDir.resolve("subdir").createDirectories()
tempDir.resolve("subdir/nested.txt").createFile()
tempDir.resolve("subdir/notes.md").createFile()
// Make sure VFS knows about these files
LocalFileSystem.getInstance().refreshAndFindFileByNioFile(tempDir)
}
@Test
fun `test tab completes first file match`() {
typeText(":edit $tempPath/a<Tab>")
assertExText("edit $tempPath/alpha.txt")
}
@Test
fun `test tab cycles through matches`() {
typeText(":edit $tempPath/b<Tab>")
assertExText("edit $tempPath/beta.txt")
typeText("<Tab>")
assertExText("edit $tempPath/bravo.kt")
}
@Test
fun `test tab wraps around to first match`() {
typeText(":edit $tempPath/b<Tab>")
assertExText("edit $tempPath/beta.txt")
typeText("<Tab>")
assertExText("edit $tempPath/bravo.kt")
typeText("<Tab>")
assertExText("edit $tempPath/beta.txt")
}
@Test
fun `test shift tab cycles backwards`() {
typeText(":edit $tempPath/b<S-Tab>")
assertExText("edit $tempPath/bravo.kt")
}
@Test
fun `test shift tab then tab`() {
typeText(":edit $tempPath/b<S-Tab>")
assertExText("edit $tempPath/bravo.kt")
typeText("<Tab>")
assertExText("edit $tempPath/beta.txt")
}
@Test
fun `test tab completes directory with trailing slash`() {
typeText(":edit $tempPath/s<Tab>")
assertExText("edit $tempPath/subdir/")
}
@Test
fun `test tab lists all files when prefix is empty`() {
typeText(":edit $tempPath/<Tab>")
// First match alphabetically
assertExText("edit $tempPath/alpha.txt")
}
@Test
fun `test tab with no matches does not change text`() {
typeText(":edit $tempPath/zzz<Tab>")
assertExText("edit $tempPath/zzz")
}
@Test
fun `test tab does nothing in search mode`() {
typeText("/search<Tab>")
// Tab is not handled by the completion action in search mode,
// but it's still consumed by the action framework -- no literal tab inserted
assertExText("search")
}
@Test
fun `test tab works with abbreviated command`() {
typeText(":e $tempPath/a<Tab>")
assertExText("e $tempPath/alpha.txt")
}
@Test
fun `test tab works with write command`() {
typeText(":w $tempPath/a<Tab>")
assertExText("w $tempPath/alpha.txt")
}
@Test
fun `test tab does not complete for commands without file completion`() {
typeText(":set foo<Tab>")
// Tab is consumed by the action but set has no completion type registered
assertExText("set foo")
}
@Test
fun `test typing after completion invalidates session`() {
typeText(":edit $tempPath/b<Tab>")
assertExText("edit $tempPath/beta.txt")
// Type a character -- this changes text, invalidating the completion session
typeText("x")
assertExText("edit $tempPath/beta.txtx")
// Tab starts a fresh completion for prefix "beta.txtx" -- no matches
typeText("<Tab>")
assertExText("edit $tempPath/beta.txtx")
}
@Test
fun `test undo after completion resumes cycling`() {
typeText(":edit $tempPath/b<Tab>")
assertExText("edit $tempPath/beta.txt")
// Type and undo -- text reverts to the expected completion text
typeText("x")
typeText("<BS>")
assertExText("edit $tempPath/beta.txt")
// Tab resumes cycling since text matches the active completion
typeText("<Tab>")
assertExText("edit $tempPath/bravo.kt")
}
@Test
fun `test completion with single match`() {
typeText(":edit $tempPath/al<Tab>")
assertExText("edit $tempPath/alpha.txt")
// Tab again cycles (single match wraps to itself)
typeText("<Tab>")
assertExText("edit $tempPath/alpha.txt")
}
@Test
fun `test completion is case insensitive`() {
typeText(":edit $tempPath/A<Tab>")
assertExText("edit $tempPath/alpha.txt")
}
// --- Arrow key completion cycling tests ---
@Test
fun `test right arrow cycles forward after tab`() {
typeText(":edit $tempPath/b<Tab>")
assertExText("edit $tempPath/beta.txt")
typeText("<Right>")
assertExText("edit $tempPath/bravo.kt")
}
@Test
fun `test left arrow cycles backward after tab`() {
typeText(":edit $tempPath/b<Tab>")
assertExText("edit $tempPath/beta.txt")
typeText("<Left>")
assertExText("edit $tempPath/bravo.kt")
}
@Test
fun `test right arrow wraps around to first match`() {
typeText(":edit $tempPath/b<Tab>")
assertExText("edit $tempPath/beta.txt")
typeText("<Right>")
assertExText("edit $tempPath/bravo.kt")
typeText("<Right>")
assertExText("edit $tempPath/beta.txt")
}
@Test
fun `test left arrow wraps around to last match`() {
typeText(":edit $tempPath/b<Tab>")
assertExText("edit $tempPath/beta.txt")
typeText("<Left>")
assertExText("edit $tempPath/bravo.kt")
typeText("<Left>")
assertExText("edit $tempPath/beta.txt")
}
@Test
fun `test right then left returns to same match`() {
typeText(":edit $tempPath/b<Tab>")
assertExText("edit $tempPath/beta.txt")
typeText("<Right>")
assertExText("edit $tempPath/bravo.kt")
typeText("<Left>")
assertExText("edit $tempPath/beta.txt")
}
@Test
fun `test tab then right continues cycling`() {
typeText(":edit $tempPath/<Tab>")
assertExText("edit $tempPath/alpha.txt")
typeText("<Tab>")
assertExText("edit $tempPath/beta.txt")
typeText("<Right>")
assertExText("edit $tempPath/bravo.kt")
}
@Test
fun `test tab then left goes back`() {
typeText(":edit $tempPath/<Tab>")
assertExText("edit $tempPath/alpha.txt")
typeText("<Tab>")
assertExText("edit $tempPath/beta.txt")
typeText("<Left>")
assertExText("edit $tempPath/alpha.txt")
}
@Test
fun `test shift tab then left continues backward`() {
typeText(":edit $tempPath/b<S-Tab>")
assertExText("edit $tempPath/bravo.kt")
typeText("<Left>")
assertExText("edit $tempPath/beta.txt")
}
@Test
fun `test shift tab then right reverses direction`() {
typeText(":edit $tempPath/b<S-Tab>")
assertExText("edit $tempPath/bravo.kt")
typeText("<Right>")
assertExText("edit $tempPath/beta.txt")
}
@Test
fun `test right arrow with single match stays on same item`() {
typeText(":edit $tempPath/al<Tab>")
assertExText("edit $tempPath/alpha.txt")
typeText("<Right>")
assertExText("edit $tempPath/alpha.txt")
}
@Test
fun `test left arrow with single match stays on same item`() {
typeText(":edit $tempPath/al<Tab>")
assertExText("edit $tempPath/alpha.txt")
typeText("<Left>")
assertExText("edit $tempPath/alpha.txt")
}
@Test
fun `test right arrow without completion moves caret`() {
typeText(":edit foo")
assertExText("edit foo")
val offsetBefore = exEntryPanel.caret.offset
typeText("<Left>")
assertEquals(offsetBefore - 1, exEntryPanel.caret.offset)
typeText("<Right>")
assertEquals(offsetBefore, exEntryPanel.caret.offset)
}
@Test
fun `test typing after arrow completion invalidates session`() {
typeText(":edit $tempPath/b<Tab>")
assertExText("edit $tempPath/beta.txt")
typeText("<Right>")
assertExText("edit $tempPath/bravo.kt")
typeText("x")
assertExText("edit $tempPath/bravo.ktx")
// Arrow key now moves caret instead of cycling
val offsetBefore = exEntryPanel.caret.offset
typeText("<Left>")
assertEquals(offsetBefore - 1, exEntryPanel.caret.offset)
}
@Test
fun `test arrow keys with no matches do not change text`() {
typeText(":edit $tempPath/zzz<Tab>")
assertExText("edit $tempPath/zzz")
// No active completion, so arrows move caret
val offsetBefore = exEntryPanel.caret.offset
typeText("<Left>")
assertEquals(offsetBefore - 1, exEntryPanel.caret.offset)
}
@Test
fun `test mixed tab and arrow key cycling`() {
typeText(":edit $tempPath/<Tab>")
assertExText("edit $tempPath/alpha.txt")
typeText("<Right>")
assertExText("edit $tempPath/beta.txt")
typeText("<Tab>")
assertExText("edit $tempPath/bravo.kt")
typeText("<Left>")
assertExText("edit $tempPath/beta.txt")
typeText("<S-Tab>")
assertExText("edit $tempPath/alpha.txt")
}
@Test
fun `test arrow cycles through all files with empty prefix`() {
typeText(":edit $tempPath/<Tab>")
assertExText("edit $tempPath/alpha.txt")
typeText("<Right>")
assertExText("edit $tempPath/beta.txt")
typeText("<Right>")
assertExText("edit $tempPath/bravo.kt")
typeText("<Right>")
assertExText("edit $tempPath/subdir/")
// Wraps
typeText("<Right>")
assertExText("edit $tempPath/alpha.txt")
}
@Test
fun `test left arrow cycles all files backwards with empty prefix`() {
typeText(":edit $tempPath/<Tab>")
assertExText("edit $tempPath/alpha.txt")
typeText("<Left>")
assertExText("edit $tempPath/subdir/")
typeText("<Left>")
assertExText("edit $tempPath/bravo.kt")
typeText("<Left>")
assertExText("edit $tempPath/beta.txt")
typeText("<Left>")
assertExText("edit $tempPath/alpha.txt")
}
// --- Subdirectory completion tests ---
@Test
fun `test tab completes inside subdirectory`() {
typeText(":edit $tempPath/subdir/ne<Tab>")
assertExText("edit $tempPath/subdir/nested.txt")
}
@Test
fun `test tab cycles through files in subdirectory`() {
typeText(":edit $tempPath/subdir/n<Tab>")
assertExText("edit $tempPath/subdir/nested.txt")
typeText("<Tab>")
assertExText("edit $tempPath/subdir/notes.md")
}
@Test
fun `test right arrow cycles in subdirectory`() {
typeText(":edit $tempPath/subdir/n<Tab>")
assertExText("edit $tempPath/subdir/nested.txt")
typeText("<Right>")
assertExText("edit $tempPath/subdir/notes.md")
typeText("<Right>")
assertExText("edit $tempPath/subdir/nested.txt")
}
@Test
fun `test left arrow cycles backwards in subdirectory`() {
typeText(":edit $tempPath/subdir/n<Tab>")
assertExText("edit $tempPath/subdir/nested.txt")
typeText("<Left>")
assertExText("edit $tempPath/subdir/notes.md")
}
}

View File

@@ -224,6 +224,17 @@ class HistoryCommandTest : VimTestCase() {
)
}
@Test
fun `test history cmd lists empty command history`() {
assertCommandOutput(
"history cmd",
"""
| # cmd history
|> 1 history cmd
""".trimMargin()
)
}
@Test
fun `test history cmd lists current cmd in history`() {
assertCommandOutput(
@@ -488,7 +499,7 @@ class HistoryCommandTest : VimTestCase() {
@Test
fun `test history search with first number lists single entry from search history`() {
fun `test history search with first number lists single entry from saerch history`() {
repeat(10) { i -> enterSearch("foo${i + 1}") }
injector.outputPanel.getCurrentOutputPanel()?.clearText()
assertCommandOutput(

View File

@@ -195,7 +195,6 @@ class SetCommandTest : VimTestCase() {
|nohlsearch nonumber nosneak wrap
| ide=IntelliJ IDEA operatorfunc= startofline wrapscan
| clipboard=ideaput,autoselect
| comments=s1:/*,mb:*,ex:*/,://,b:#,:%,:XCOMM,n:>,fb:-
| fileencoding=utf-8
| guicursor=n-v-c:block-Cursor/lCursor,ve:ver35-Cursor,o:hor50-Cursor,i-ci:ver25-Cursor/lCursor,r-cr:hor20-Cursor/lCursor,sm:block-Cursor-blinkwait175-blinkoff150-blinkon175
|noideacopypreprocess
@@ -259,7 +258,6 @@ class SetCommandTest : VimTestCase() {
| clipboard=ideaput,autoselect
| colorcolumn=
|nocommentary
| comments=s1:/*,mb:*,ex:*/,://,b:#,:%,:XCOMM,n:>,fb:-
|nocursorline
|nodigraph
|noexchange

Some files were not shown because too many files have changed in this diff Show More