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

Compare commits

..

102 Commits

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

Call close() before fullReset() so mode, the key handler state, and
the panel are cleared together. Also replace the `!!` at the crash
site with a null-safe branch that logs VIM-4115 and clears the stale
builder, so any other producer of the same desync surfaces via a
Diogen report instead of a crash.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:07:48 +03:00
Alex Plate
81bf421436 Auto-merge Claude-generated changelog PRs
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:39:34 +03:00
claude[bot]
ebf637a367 Update changelog: S-Tab fix, commentary mode fix in split mode
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 13:51:07 +03:00
1grzyb1
4dec9a95a4 VIM-186 Support nested comments 2026-04-23 13:57:28 +02:00
1grzyb1
cd5c8e9e17 VIM-186 Use vim license for code wrapper 2026-04-23 13:57:28 +02:00
1grzyb1
64e110f27a VIM-186 Add comment-aware code wrapping for gq/gw 2026-04-23 13:57:28 +02:00
1grzyb1
eaf39234b2 VIM-1158 Add gw to reformat code with preserving the cursor position 2026-04-23 13:57:28 +02:00
dependabot[bot]
ac029ee889 Bump io.ktor:ktor-client-auth from 3.4.2 to 3.4.3
Bumps [io.ktor:ktor-client-auth](https://github.com/ktorio/ktor) from 3.4.2 to 3.4.3.
- [Release notes](https://github.com/ktorio/ktor/releases)
- [Changelog](https://github.com/ktorio/ktor/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ktorio/ktor/commits)

---
updated-dependencies:
- dependency-name: io.ktor:ktor-client-auth
  dependency-version: 3.4.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-22 15:36:05 +00:00
1grzyb1
607920d262 VIM-4207 Add support for update command 2026-04-22 09:07:58 +02:00
1grzyb1
ab7ca0e32e VIM-4202 Don't intercept all <S-Tab>
When <S-Tab> was in VIM_ONLY_EDITOR_KEYS users couldn't override it for other intelij actions
2026-04-21 11:05:19 +02:00
1grzyb1
7ec1e4c58d VIM-4202 Fix state after commentary action
in split mode/clion/ rider, after the comment action runs on rpc, it happens after removing selection. To fix that, we execute handler directly in synchronous way
2026-04-21 10:21:29 +02:00
claude[bot]
edce848ebd Update changelog: autocmd support, decompiled Kotlin Vim fix, block-visual undo carets
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 10:39:08 +03:00
claude[bot]
8cda987b6e Update changelog: ReplaceWithRegister mapping fix, command fixes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 10:38:51 +03:00
1grzyb1
558d670f4e VIM-4139 Compute nesting depth for fold regions 2026-04-21 08:21:47 +02:00
1grzyb1
0df627ab19 VIM-1693 Normalize BufRead and BufWrite aliases at registration 2026-04-20 13:40:32 +02:00
1grzyb1
7546b0737d VIM-1693 Fix clearAuGroup on CopyOnWriteArrayList 2026-04-20 13:40:32 +02:00
1grzyb1
d58a68df78 VIM-1693 Bound BufNewFile tracker and clear on disable 2026-04-20 13:40:32 +02:00
1grzyb1
aedf114576 VIM-1693 Run write events against main editor 2026-04-20 13:40:32 +02:00
1grzyb1
9decc7095b VIM-1693 Document autocmd 2026-04-20 13:40:32 +02:00
1grzyb1
4edc23006e VIM-1693 BufWrite events support 2026-04-20 13:40:32 +02:00
1grzyb1
6e21fbd61a VIM-1693 BufNewFile event support 2026-04-20 13:40:32 +02:00
1grzyb1
1be8183399 VIM-1693 BufRead event support 2026-04-20 13:40:32 +02:00
1grzyb1
f7718b6dd8 VIM-1693 FileType event support 2026-04-20 13:40:32 +02:00
1grzyb1
5cfd1d1fe6 VIM-1693 Focus Gained Lost support 2026-04-20 13:40:32 +02:00
1grzyb1
66ed07e6f5 VIM-1693 WinLeave WinEnter support 2026-04-20 13:40:32 +02:00
1grzyb1
720d8fab40 VIM-1693 fix buf enter leave handling 2026-04-20 13:40:32 +02:00
1grzyb1
6c803e3154 VIM-1693 File pattern matching in autocmd 2026-04-20 13:40:32 +02:00
1grzyb1
38c74d6b9d VIM-1693 Add support for multiple autocmd events 2026-04-20 13:40:32 +02:00
1grzyb1
55a451ac2f VIM-1693 Refactor Insert Leave/Enter to work on listeners 2026-04-20 13:40:32 +02:00
1grzyb1
29a02a102b VIM-1693 Use thread-safe collections for autocmd event handling 2026-04-20 13:40:32 +02:00
1grzyb1
bebce05950 VIM-1693 Support augroup
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-20 13:40:32 +02:00
1grzyb1
f86ba678e5 VIM-1693 Fix autocmd grammar
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-20 13:40:32 +02:00
1grzyb1
64f2d5b628 VIM-1693 Support for Buffer events
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-20 13:40:32 +02:00
1grzyb1
98abfedb98 VIM-1693 Fix autocmd in test injector
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-20 13:40:32 +02:00
1grzyb1
1dcb386b92 VIM-1693 Basic autocmd implementation
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-20 13:40:32 +02:00
1grzyb1
d085b3ffce VIM-4112 collapse restored carets after undo of block-visual edit
After undoing a block-visual edit (<C-V>…x, <C-V>…c, <C-V>…I), IntelliJ restored the pre-edit multi-caret CaretState even though Vim is in single-cursor normal mode, leaving stray native carets. UndoRedoHelper now detects the 1 -> N caret jump across undo - a signal unique to block-visual since it's the only flow that collapses multi-carets on exit - and collapses back to a single caret at the block's top-left, matching Vim's convention
2026-04-20 11:41:24 +02:00
Matt Ellis
319b5164dc Enable Vim in Java files decompiled from Kotlin
Fixes VIM-4197
2026-04-20 08:45:23 +02:00
Matt Ellis
c49ac943fc Remove explicit gradleJvm option, use project SDK 2026-04-20 08:45:23 +02:00
Alex Plate
9c4f6d0989 Remove .beads/ directory
This project uses YouTrack for issue tracking, not Beads.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 17:22:52 +03:00
claude[bot]
f9b4059224 Update changelog: fix settings persistence in remote dev mode
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 17:00:32 +03:00
claude[bot]
ef42ce6aa5 Update changelog: Python Console fixes, NERDTree split mode performance
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 16:59:58 +03:00
claude[bot]
270dc391b5 Fix IndexOutOfBoundsException in CmdCommand when no name is provided with -nargs
Accessing alias[0] without an isEmpty() guard crashed with
IndexOutOfBoundsException when the user typed a :command with only
-nargs specified but no command name (e.g. ":command -nargs=0").

After -nargs processing strips the flag, the remaining argument is
empty, so alias becomes "" and alias[0] throws. Adding alias.isEmpty()
guard treats the missing name as an invalid command name (E183).

Adds a regression test to ensure this case no longer crashes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 16:58:35 +03:00
claude[bot]
7a31c774d0 Fix CommandBuilder.isEmpty, clone, equals, and hashCode ignoring isRegisterPending
The `isRegisterPending` field was not considered in several methods of
`CommandBuilder`, causing subtle bugs:

- `isEmpty` returned `true` while waiting for a register character
  (after typing `"`), which caused `EditorResetConsumer` to treat the
  partially-built command as if no command was in progress. This could
  trigger an incorrect error indicator (beep) when pressing `<Esc>` to
  cancel register selection in Normal mode, instead of silently resetting.

- `clone()` did not copy `isRegisterPending`, meaning a cloned builder
  would lose pending register state. This is a latent bug affecting the
  unused `AsyncKeyProcessBuilder`.

- `equals()` and `hashCode()` did not include `isRegisterPending`, so
  two builders differing only in pending-register state were considered
  equal, which is incorrect.

Add a regression test that verifies `isEmpty` returns `false` while a
register selection is pending, and `true` after cancelling with Escape.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 16:56:50 +03:00
Alex Plate
9dd2b7c743 Fix(VIM-4180): ReplaceWithRegister no longer overrides user remaps
Delayed extension init runs after .ideavimrc, so the plugin's default
`nmap gr`/`nmap grr`/`vmap gr` used to clobber user mappings. Switch to
`nmapPluginAction`/`vmapPluginAction` which guard on `hasmapto`, matching
the original Vim plugin's behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 15:34:31 +03:00
Alex Plate
ddfcf07735 Add multi-caret regression tests for argtextobj and textobj-indent
Guard against VIM-4193 regressing again: both plugins are back on the
pre-migration TextObjectActionHandler API, so multi-caret daa/dia/dii
works today. If either plugin is re-migrated to the new TextObjectScope
API before it grows a per-caret read primitive (withCurrentCaret or
equivalent), these tests will fail loudly instead of the bug sneaking
through silently.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 14:46:35 +03:00
Alex Plate
9ea5116bf4 Revert VimIndentObject plugin migration to new VimApi
Same per-caret gap as the argtextobj revert (VIM-4193): the new
TextObjectScope rangeProvider lambda has no way to read state for the
caret currently being iterated, so the post-migration version falls
back to withPrimaryCaret and breaks multi-caret ai/aI/ii.

Restore VimIndentObject.kt to its state just before a6db9acd7
("Refactor: Migrate VimIndentObject extension to new VimApi"), keeping
it on the old TextObjectActionHandler-based API until the new API
exposes a per-caret read primitive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 14:46:35 +03:00
Alex Plate
ac53a63adb Revert argtextobj plugin migration to new VimApi
Pending a fix for the current-caret gap in the new TextObjectScope API
(VIM-4193): the rangeProvider lambda has no way to know which caret the
engine is currently iterating over, so the post-migration extension
falls back to withPrimaryCaret and breaks multi-caret daa/dia.

Restore VimArgTextObjExtension.kt to its state just before 86bf54d84
("Migrate argtextobj extension to new textObjects API"), keeping it on
the old TextObjectActionHandler-based API until the new API exposes a
per-caret read primitive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 14:46:35 +03:00
1grzyb1
499069177c VIM-4195 Remove legacy $APP_CONFIG$ macro from @Storage annotations
The $APP_CONFIG$ path macro in @Storage annotations is rejected by
SecurityHelper.validateSettingsFile in remote dev / split mode. The
platform resolves app-level storage to the config directory automatically,
so the bare filename is sufficient.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 11:45:17 +02:00
1grzyb1
f5afa628d9 Make nerdtree work without calling backend actions
Nerdtree was using actions that went through RPC whihch resulted in poor performance in split mode
2026-04-15 12:53:42 +02:00
1grzyb1
70066dffc1 Update changelog: gw command, search panel fixes, range errors, visual mode off-EDT
Combines changelog entries from PRs #1680, #1670, and #1661:
- VIM-1158: Added gw command
- VIM-4176: Fixed search output panel race condition
- VIM-4175: Fixed search "not found" display
- Fixed \/, \?, \& range errors (E35/E33)
- VIM-4113: Fixed visual mode commands off-EDT

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 10:42:41 +02:00
1grzyb1
75b4d3fce3 VIM-4172 Disable ideavim in Python Console 2026-04-15 10:17:46 +02:00
1grzyb1
08aea2b202 Split mode test for simple undo insert 2026-04-15 10:14:13 +02:00
1grzyb1
f5fdc217be Restore old VimPLugin method signatures
During splitting plugin arhcitecture, we changed method signature, making them not backward compatible.
2026-04-15 10:04:58 +02:00
claude[bot]
75a05a8f12 Update changelog: Add VIM-268 file name completion in ex commands
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 09:58:43 +02:00
1grzyb1
5416d1505f VIM-4172 Add test for visual selection commands 2026-04-15 09:52:06 +02:00
1grzyb1
01789c6a7b VIM-3727 Fix Python console Enter and arrow keys in split mode
In split mode, VimShortcutKeyAction and ToolWindowNavDispatcher claimed Enter/arrow key shortcuts on the Python console editor component, preventing the thin client from creating backend delegating actions for Console.Execute and history navigation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 09:39:33 +02:00
1grzyb1
ebc09ddab1 VIM-3727 Add debug pycharm in splitmode run configuration 2026-04-15 09:39:33 +02:00
1grzyb1
e7722bef61 VIM-268 scroll through results using arrow keys 2026-04-14 11:33:32 +02:00
1grzyb1
d28889fc7b VIM-268 edit command autocomplete 2026-04-14 11:33:32 +02:00
1grzyb1
9251a83031 VIM-1158 Refactor gq tests: use multiline strings and class-level annotation 2026-04-13 10:29:20 +02:00
1grzyb1
3793458d64 VIM-1158 Add gw to reformat code with preserving the cursor position 2026-04-13 10:29:20 +02:00
1grzyb1
34196bc0dd Disable split mode tests
Agent doesn't have Xvfb will enable when it will have
2026-04-10 13:24:04 +02:00
1grzyb1
2a7d23586a Install Xvfb in TC 2026-04-10 11:16:16 +02:00
1grzyb1
d47a22e96f Disable Xvfb access control 2026-04-10 10:56:05 +02:00
1grzyb1
f4c6e04558 Wait for display to be ready in split mode tests 2026-04-10 10:27:52 +02:00
1grzyb1
c82539e379 Remove manual DriverRunner registration 2026-04-10 09:59:25 +02:00
1grzyb1
11f1745c63 VIM-4175 Don't insert repeat when not needed
In split mode calling insertRepeatTExt each time over rpc was adding unessesery overhead
2026-04-10 09:09:36 +02:00
1grzyb1
581001f199 VIM-4175 Don't refocus editor after close output panel 2026-04-10 08:47:31 +02:00
123 changed files with 8254 additions and 1038 deletions

View File

@@ -0,0 +1,20 @@
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}}

View File

@@ -13,7 +13,6 @@ import _Self.IdeaVimBuildType
import jetbrains.buildServer.configs.kotlin.v2019_2.CheckoutMode
import jetbrains.buildServer.configs.kotlin.v2019_2.DslContext
import jetbrains.buildServer.configs.kotlin.v2019_2.buildSteps.script
import jetbrains.buildServer.configs.kotlin.v2019_2.triggers.vcs
object SplitModeTests : IdeaVimBuildType({
name = "Split mode tests"
@@ -42,18 +41,41 @@ object SplitModeTests : IdeaVimBuildType({
script {
name = "Start Xvfb and run split mode tests"
scriptContent = """
Xvfb :99 -screen 0 1920x1080x24 &
sleep 2
# Kill any leftover Xvfb from previous runs
pkill -f 'Xvfb :99' || true
Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp &
XVFB_PID=${'$'}!
# Wait until the display is ready
for i in $(seq 1 30); do
if xdpyinfo -display :99 >/dev/null 2>&1; then
echo "Xvfb is ready on :99"
break
fi
sleep 1
done
if ! xdpyinfo -display :99 >/dev/null 2>&1; then
echo "ERROR: Xvfb failed to start on :99"
exit 1
fi
./gradlew :tests:split-mode-tests:testSplitMode --console=plain --build-cache --configuration-cache --stacktrace
TEST_EXIT=${'$'}?
kill ${'$'}XVFB_PID 2>/dev/null || true
exit ${'$'}TEST_EXIT
""".trimIndent()
}
}
triggers {
vcs {
branchFilter = "+:<default>"
}
}
// 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

View File

@@ -26,6 +26,8 @@ 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"`)
@@ -34,8 +36,13 @@ 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
@@ -45,6 +52,7 @@ usual beta standards.
* [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
@@ -60,8 +68,31 @@ usual beta standards.
* 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
@@ -69,6 +100,7 @@ usual beta standards.
* [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,6 +241,24 @@ 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,6 +2,8 @@ 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

@@ -9,7 +9,6 @@
package com.intellij.vim.api.scopes
import com.intellij.vim.api.VimApi
import com.intellij.vim.api.models.CaretId
/**
* Represents the range of a text object selection.
@@ -110,19 +109,10 @@ interface TextObjectScope {
* or null if no valid range is found at the current position.
* The function receives the count (e.g., `2iw` passes count=2).
*/
fun register(
keys: String,
registerDefaultMapping: Boolean = true,
preserveSelectionAnchor: Boolean = true,
rangeProvider: suspend VimApi.(caret: CaretId, count: Int) -> TextObjectRange?,
)
fun register(
keys: String,
registerDefaultMapping: Boolean = true,
preserveSelectionAnchor: Boolean = true,
rangeProvider: suspend VimApi.(count: Int) -> TextObjectRange?,
) {
register(keys, registerDefaultMapping, preserveSelectionAnchor) { _, count -> rangeProvider(count) }
}
)
}

View File

@@ -27,11 +27,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.2")
classpath("io.ktor:ktor-client-cio:3.4.2")
classpath("io.ktor:ktor-client-auth:3.4.2")
classpath("io.ktor:ktor-client-content-negotiation:3.4.2")
classpath("io.ktor:ktor-serialization-kotlinx-json:3.4.2")
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")
// This comes from the changelog plugin
// classpath("org.jetbrains:markdown:0.3.1")
@@ -228,7 +228,7 @@ tasks {
val runPycharm by intellijPlatformTesting.runIde.registering {
type = IntelliJPlatformType.PyCharmProfessional
version = "2025.3.2"
version = "2026.1"
task {
systemProperty("octopus.handler", System.getProperty("octopus.handler") ?: true)
}
@@ -244,13 +244,14 @@ tasks {
val runClion by intellijPlatformTesting.runIde.registering {
type = IntelliJPlatformType.CLion
version = "2025.3.2"
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(
@@ -313,7 +314,7 @@ tasks {
}
val runPycharmSplitMode by intellijPlatformTesting.runIde.registering {
type = IntelliJPlatformType.PyCharmProfessional
version = "2025.3.2"
version = "2026.1"
splitMode = true
splitModeTarget = SplitModeAware.SplitModeTarget.BOTH
@@ -362,6 +363,45 @@ 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
@@ -434,6 +474,8 @@ 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>
@@ -442,8 +484,13 @@ 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>
@@ -453,6 +500,7 @@ intellijPlatform {
* <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>
@@ -468,8 +516,31 @@ intellijPlatform {
* 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>
@@ -477,6 +548,7 @@ intellijPlatform {
* <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()

137
doc/autocmd.md Normal file
View File

@@ -0,0 +1,137 @@
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=chylex-57
version=9999.58-chylex
javaVersion=21
remoteRobotVersion=0.11.23
antlrVersion=4.10.1

View File

@@ -8,23 +8,24 @@
package com.maddyhome.idea.vim.group.comment
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.actionSystem.IdeActions
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.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
/**
* 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.
* 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.
*/
internal class CommentaryRemoteApiImpl : CommentaryRemoteApi {
@@ -35,40 +36,47 @@ internal class CommentaryRemoteApiImpl : CommentaryRemoteApi {
val startOffset = document.getLineStartOffset(startLine)
val endOffset = document.getLineEndOffset(endLine)
executeCommentAction(editor, startOffset, endOffset, caretOffset, IdeActions.ACTION_COMMENT_LINE)
runCommenter(editor, startOffset, endOffset, caretOffset, lineWise = true)
}
override suspend fun toggleBlockComment(editorId: EditorId, startOffset: Int, endOffset: Int, caretOffset: Int) =
onEdt {
val editor = editorId.findEditorOrNull() ?: return@onEdt
// 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)
}
runCommenter(editor, startOffset, endOffset, caretOffset, lineWise = false)
}
private fun executeCommentAction(
private fun runCommenter(
editor: Editor,
startOffset: Int,
endOffset: Int,
caretOffset: Int,
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)
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)
}
}
}
}, "Commentary", null)
return result
}
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()
}
}

View File

@@ -0,0 +1,87 @@
/*
* 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,6 +162,11 @@ 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? {

View File

@@ -44,6 +44,7 @@ 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,6 +40,8 @@
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>
@@ -169,6 +171,9 @@
<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

@@ -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.2")
implementation("io.ktor:ktor-client-cio:3.4.2")
implementation("io.ktor:ktor-client-content-negotiation:3.4.2")
implementation("io.ktor:ktor-serialization-kotlinx-json:3.4.2")
implementation("io.ktor:ktor-client-auth:3.4.2")
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("org.eclipse.jgit:org.eclipse.jgit:6.6.0.202305301015-r")
// This is needed for jgit to connect to ssh

View File

@@ -106,9 +106,13 @@ internal class IjVimPluginActivator : VimPluginActivator {
}
// Use getServiceIfCreated to avoid creating the service during the dispose (this is prohibited by the platform)
ApplicationManager.getApplication()
val commandLineService = ApplicationManager.getApplication()
.getServiceIfCreated(com.maddyhome.idea.vim.api.VimCommandLineService::class.java)
?.fullReset()
// 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()
// Unregister vim actions in command mode
RegisterActions.unregisterActions()

View File

@@ -22,13 +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.ChangeGroup;
import com.maddyhome.idea.vim.group.KeyGroup;
import com.maddyhome.idea.vim.group.VimNotifications;
import com.maddyhome.idea.vim.group.VimWindowGroup;
import com.maddyhome.idea.vim.group.*;
import com.maddyhome.idea.vim.group.visual.VisualMotionGroup;
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;
@@ -48,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("$APP_CONFIG$/vim_settings.xml")})
@State(name = "VimSettings", storages = {@Storage("vim_settings.xml")})
public class VimPlugin implements PersistentStateComponent<Element>, Disposable {
public static final int STATE_VERSION = 7;
@@ -87,49 +85,48 @@ public class VimPlugin implements PersistentStateComponent<Element>, Disposable
}
public static @NotNull VimMotionGroup getMotion() {
return VimInjectorKt.getInjector().getMotion();
public static @NotNull MotionGroup getMotion() {
return ((MotionGroup)VimInjectorKt.getInjector().getMotion());
}
public static @NotNull ChangeGroup getChange() {
return ((ChangeGroup)VimInjectorKt.getInjector().getChangeGroup());
}
public static @NotNull VimCommandGroup getCommand() {
return VimInjectorKt.getInjector().getCommandGroup();
public static @NotNull CommandGroup getCommand() {
return ((CommandGroup)VimInjectorKt.getInjector().getCommandGroup());
}
public static @NotNull VimRegisterGroup getRegister() {
return VimInjectorKt.getInjector().getRegisterGroup();
public static @NotNull RegisterGroup getRegister() {
return ((RegisterGroup)VimInjectorKt.getInjector().getRegisterGroup());
}
public static @NotNull VimFile getFile() {
return VimInjectorKt.getInjector().getFile();
}
public static @NotNull VimSearchGroup getSearch() {
return VimInjectorKt.getInjector().getSearchGroup();
public static @NotNull IjVimSearchGroup getSearch() {
return ((IjVimSearchGroup)VimInjectorKt.getInjector().getSearchGroup());
}
public static @Nullable VimSearchGroup getSearchIfCreated() {
VimSearchGroup searchGroup = ApplicationManager.getApplication().getServiceIfCreated(VimSearchGroup.class);
return searchGroup;
public static @Nullable IjVimSearchGroup getSearchIfCreated() {
return ApplicationManager.getApplication().getServiceIfCreated(IjVimSearchGroup.class);
}
public static @NotNull VimProcessGroup getProcess() {
return VimInjectorKt.getInjector().getProcessGroup();
}
public static @NotNull VimMacro getMacro() {
return VimInjectorKt.getInjector().getMacro();
public static @NotNull MacroGroup getMacro() {
return ((MacroGroup)VimInjectorKt.getInjector().getMacro());
}
public static @NotNull VimDigraphGroup getDigraph() {
return VimInjectorKt.getInjector().getDigraphGroup();
}
public static @NotNull VimHistory getHistory() {
return VimInjectorKt.getInjector().getHistoryGroup();
public static @NotNull HistoryGroup getHistory() {
return ((HistoryGroup)VimInjectorKt.getInjector().getHistoryGroup());
}
public static @NotNull KeyGroup getKey() {
@@ -140,20 +137,20 @@ public class VimPlugin implements PersistentStateComponent<Element>, Disposable
return ApplicationManager.getApplication().getServiceIfCreated(KeyGroup.class);
}
public static @NotNull VimWindowGroup getWindow() {
return VimInjectorKt.getInjector().getWindow();
public static @NotNull WindowGroup getWindow() {
return ((WindowGroup)VimInjectorKt.getInjector().getWindow());
}
public static @NotNull VimEditorGroup getEditor() {
return VimInjectorKt.getInjector().getEditorGroup();
public static @NotNull EditorGroup getEditor() {
return ((EditorGroup)VimInjectorKt.getInjector().getEditorGroup());
}
public static @Nullable VimEditorGroup getEditorIfCreated() {
return ApplicationManager.getApplication().getServiceIfCreated(VimEditorGroup.class);
public static @Nullable EditorGroup getEditorIfCreated() {
return ApplicationManager.getApplication().getServiceIfCreated(EditorGroup.class);
}
public static @NotNull VimVisualMotionGroup getVisualMotion() {
return VimInjectorKt.getInjector().getVisualMotionGroup();
public static @NotNull VisualMotionGroup getVisualMotion() {
return ((VisualMotionGroup)VimInjectorKt.getInjector().getVisualMotionGroup());
}
public static @NotNull YankGroupBase getYank() {

View File

@@ -0,0 +1,11 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package com.maddyhome.idea.vim.autocmd
data class AuCommand(val command: String, val group: String?, val pattern: AutoCmdPattern = AutoCmdPattern("*"))

View File

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

@@ -0,0 +1,89 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package com.maddyhome.idea.vim.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

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

@@ -1,5 +1,5 @@
/*
* Copyright 2003-2025 The IdeaVim authors
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
@@ -54,10 +54,13 @@ 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]
register("k", NerdTreeAction.ij("Tree-selectPrevious"))
register("j", NerdTreeAction.ij("Tree-selectNext"))
register("G", NerdTreeAction.ij("Tree-selectLast"))
register("gg", NerdTreeAction.ij("Tree-selectFirst"))
// 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"))
// FIXME lazy loaded tree nodes are not expanded
register("NERDTreeMapOpenRecursively", "O", NerdTreeAction.ij("FullyExpandTreeNode"))
@@ -102,7 +105,7 @@ val navigationMappings: Map<List<KeyStroke>, NerdTreeAction> = mutableMapOf<List
tree.selectionPath = path
tree.scrollPathToVisible(path)
})
register("NERDTreeMapJumpParent", "p", NerdTreeAction.ij("Tree-selectParentNoCollapse"))
register("NERDTreeMapJumpParent", "p", NerdTreeAction.swing("selectParentNoCollapse"))
register(
"NERDTreeMapJumpFirstChild",
"K",
@@ -129,8 +132,8 @@ val navigationMappings: Map<List<KeyStroke>, NerdTreeAction> = mutableMapOf<List
tree.scrollPathToVisible(path)
},
)
register("NERDTreeMapJumpNextSibling", "<C-J>", NerdTreeAction.ij("Tree-selectNextSibling"))
register("NERDTreeMapJumpPrevSibling", "<C-K>", NerdTreeAction.ij("Tree-selectPreviousSibling"))
register("NERDTreeMapJumpNextSibling", "<C-J>", NerdTreeAction.swing("selectNextSibling"))
register("NERDTreeMapJumpPrevSibling", "<C-K>", NerdTreeAction.swing("selectPreviousSibling"))
register("/", NerdTreeAction.ij("SpeedSearch"))
register("<ESC>", NerdTreeAction { _, _ -> })

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2003-2025 The IdeaVim authors
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
@@ -11,6 +11,7 @@ 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
@@ -47,5 +48,11 @@ 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,6 +9,8 @@
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 {
@@ -17,21 +19,15 @@ internal class ReplaceWithRegister : VimExtension {
override fun init(initApi: VimInitApi) {
initApi.mappings {
// Step 1: Non-recursive <Plug> → action mappings
nnoremap(RWR_OPERATOR) {
nmapPluginAction("gr", RWR_OPERATOR, keepDefaultMapping = true) {
rewriteMotion()
}
nnoremap(RWR_LINE) {
nmapPluginAction("grr", RWR_LINE, keepDefaultMapping = true) {
rewriteLine()
}
vnoremap(RWR_VISUAL) {
vmapPluginAction("gr", RWR_VISUAL, keepDefaultMapping = true) {
rewriteVisual()
}
// Step 2: Recursive key → <Plug> mappings
nmap("gr", RWR_OPERATOR)
nmap("grr", RWR_LINE)
vmap("gr", RWR_VISUAL)
}
initApi.commands {

View File

@@ -16,27 +16,23 @@ 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 {
// Step 1: Non-recursive <Plug> → action mappings
nnoremap(RWR_OPERATOR) {
nmapPluginAction("gr", RWR_OPERATOR, keepDefaultMapping = true) {
rewriteMotion()
}
nnoremap(RWR_LINE) {
nmapPluginAction("grr", RWR_LINE, keepDefaultMapping = true) {
rewriteLine()
}
vnoremap(RWR_VISUAL) {
vmapPluginAction("gr", RWR_VISUAL, keepDefaultMapping = true) {
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-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
@@ -7,15 +7,36 @@
*/
package com.maddyhome.idea.vim.extension.textobjindent
import com.intellij.vim.api.VimApi
import com.intellij.vim.api.VimInitApi
import com.intellij.vim.api.scopes.TextObjectRange
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.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.
@@ -23,165 +44,236 @@ import com.maddyhome.idea.vim.extension.VimExtension
* * `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 = "textobj-indent"
override fun getName(): String {
return "textobj-indent"
}
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) }
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)
}
}
}
}
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,6 +13,7 @@ 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
@@ -31,7 +32,7 @@ internal class ToolWindowNavEverywhere : VimExtension {
val oldFocusOwner = evt.oldValue as? JComponent
val dispatcher = service<ToolWindowNavDispatcher>()
if (newFocusOwner != null && isInsideToolWindow(newFocusOwner)) {
if (newFocusOwner != null && isInsideToolWindow(newFocusOwner) && !isPythonConsoleComponent(newFocusOwner)) {
dispatcher.register(newFocusOwner)
}
@@ -51,6 +52,18 @@ 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,6 +23,7 @@ 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
@@ -31,11 +32,15 @@ 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
@@ -129,6 +134,8 @@ 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()
@@ -152,6 +159,39 @@ 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

@@ -0,0 +1,63 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package com.maddyhome.idea.vim.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 = "$APP_CONFIG$/vim_settings.xml")})
@State(name = "VimEditorSettings", storages = {@Storage(value = "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,6 +321,18 @@ 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,6 +164,10 @@ 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

@@ -28,6 +28,8 @@ 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;
@@ -51,7 +53,7 @@ import static java.util.stream.Collectors.toList;
/**
* @author vlan
*/
@State(name = "VimKeySettings", storages = {@Storage(value = "$APP_CONFIG$/vim_settings.xml")})
@State(name = "VimKeySettings", storages = {@Storage(value = "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";
@@ -180,9 +182,15 @@ 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()),
((IjVimEditor)editor).getEditor().getContentComponent());
ijEditor.getContentComponent());
}
@Override

View File

@@ -146,6 +146,22 @@ 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,7 +21,6 @@ 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
@@ -119,11 +118,7 @@ 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
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
if (EditorHelper.isPythonConsole(editor)) return false
return (editor.caretModel as? CaretModelImpl)?.isIteratingOverCarets ?: true
}

View File

@@ -13,6 +13,7 @@ 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;
@@ -45,6 +46,9 @@ 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) {
@@ -679,6 +683,48 @@ 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,6 +17,7 @@ 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
@@ -53,7 +54,7 @@ internal val Editor.isIdeaVimDisabledHere: Boolean
!ClientId.isCurrentlyUnderLocalId || // CWM-927
(ideaVimDisabledForSingleLine(ideaVimSupportValue) && isSingleLine()) ||
IdeaVimDisablerExtensionPoint.isDisabledForEditor(this) ||
isNotFileEditorExceptAllowed()
!isAllowedFileEditor()
}
/**
@@ -65,18 +66,21 @@ 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
*
* 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.
* 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.
*/
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 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 ideaVimDisabledInDialog(ideaVimSupportValue: StringListOptionValue): Boolean {

View File

@@ -29,6 +29,7 @@ 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
@@ -54,6 +55,7 @@ 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()
@@ -61,6 +63,8 @@ class UndoRedoHelper : VimTimestampBasedUndoService {
scrollingModel.flushViewportChanges()
collapseRestoredBlockVisualCarets(editor, caretCountBeforeUndo)
return true
}
return false
@@ -191,6 +195,23 @@ 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

@@ -0,0 +1,33 @@
/*
* 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,13 +9,17 @@
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
@@ -34,6 +38,8 @@ 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
@@ -51,6 +57,10 @@ 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
@@ -65,7 +75,12 @@ 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
@@ -112,6 +127,7 @@ 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
/**
@@ -168,6 +184,9 @@ 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)
@@ -221,6 +240,8 @@ 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() {
@@ -237,6 +258,8 @@ object VimListenerManager {
optionGroup.removeGlobalOptionChangeListener(Options.showmode, modeWidgetOptionListener)
optionGroup.removeGlobalOptionChangeListener(Options.showmode, macroWidgetOptionListener)
optionGroup.removeEffectiveOptionValueChangeListener(Options.guicursor, GuicursorChangeListener)
BufNewFileTracker.clear()
}
}
@@ -325,6 +348,7 @@ object VimListenerManager {
injector.editorGroup.editorCreated(IjVimEditor(editor))
(VimPlugin.getChange() as ChangeGroup).editorCreated(IjVimEditor(editor), perEditorDisposable)
CommentsOptionInitializer.initializeForEditor(editor)
(editor as EditorEx).addFocusListener(VimFocusListener, perEditorDisposable)
@@ -335,15 +359,18 @@ 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")
}
}
}
@@ -423,8 +450,17 @@ object VimListenerManager {
// Breaks relativenumber for some reason
// injector.scroll.scrollCaretIntoView(editor.vim)
}
injector.outputPanel.getCurrentOutputPanel()?.close()
// 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)
}
MotionGroup.fileEditorManagerSelectionChangedCallback(event)
FileGroupHelper.fileEditorManagerSelectionChangedCallback(event)
// (VimPlugin.getSearch() as IjVimSearchGroup).fileEditorManagerSelectionChangedCallback(event)
@@ -508,6 +544,7 @@ 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
@@ -567,6 +604,8 @@ object VimListenerManager {
}
EditorListeners.add(editor, openingEditor?.editor?.vim ?: injector.fallbackWindow, scenario)
firstEditorInitialised = true
fireBufferLoadedEvents(editor)
}
}
}
@@ -925,3 +964,122 @@ 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

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

@@ -514,12 +514,11 @@ class IjVimEditor(editor: Editor) : MutableLinearEditor, VimEditorBase() {
val allFolds = editor.foldingModel.allFoldRegions
if (allFolds.isEmpty()) return
val depths = FoldDepthCalculator.computeDepths(allFolds)
editor.foldingModel.runBatchFoldingOperation {
// 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
for (i in allFolds.indices) {
allFolds[i].isExpanded = depths[i] < foldLevel
}
}
}
@@ -528,9 +527,10 @@ class IjVimEditor(editor: Editor) : MutableLinearEditor, VimEditorBase() {
val allFolds = editor.foldingModel.allFoldRegions
if (allFolds.isEmpty()) return 0
return allFolds.maxOfOrNull { fold ->
calculateFoldDepth(fold, allFolds)
} ?: 0
val depths = FoldDepthCalculator.computeDepths(allFolds)
var max = 0
for (d in depths) if (d > max) max = d
return max
}
override fun createFoldRegion(startOffset: Int, endOffset: Int, collapse: Boolean): VimFoldRegion? {
@@ -589,27 +589,6 @@ 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)
}

View File

@@ -12,6 +12,7 @@ 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
@@ -62,6 +63,7 @@ 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
@@ -217,6 +219,8 @@ internal class IjVimInjector : VimInjectorBase() {
get() = service()
override val pluginActivator: VimPluginActivator
get() = service()
override val autoCmd: AutoCmdService get() = service<AutoCmdService>()
}
/**

View File

@@ -254,17 +254,11 @@ class OutputPanel private constructor(
return textPane.getBackground()
}
/**
* Turns off the output panel and optionally puts the focus back to the original component.
*/
fun deactivate(refocusOwningEditor: Boolean) {
fun deactivate() {
if (!active) return
active = false
clearText()
textPane.text = ""
if (refocusOwningEditor) {
requestFocus(editor.contentComponent)
}
if (glassPane != null) {
glassPane!!.removeComponentListener(resizeAdapter)
toolWindowListenerConnection?.disconnect()
@@ -321,7 +315,7 @@ class OutputPanel private constructor(
fun close(key: KeyStroke?) {
val passKeyBack = isSingleLine
ApplicationManager.getApplication().invokeLater {
deactivate(true)
deactivate()
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

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

@@ -24,6 +24,7 @@ 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
@@ -81,6 +82,46 @@ 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
@@ -171,6 +212,8 @@ 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
@@ -253,6 +296,7 @@ class ExEntryPanel private constructor() : JPanel(), VimCommandLine {
}
}
private val incSearchDocumentListener: DocumentListener = object : DocumentAdapter() {
override fun textChanged(e: DocumentEvent) {
try {
@@ -488,6 +532,25 @@ 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
@@ -508,6 +571,8 @@ 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
@@ -556,10 +621,13 @@ 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) {
@@ -568,6 +636,7 @@ class ExEntryPanel private constructor() : JPanel(), VimCommandLine {
entry.deleteText(offset, string.length)
}
entry.insertText(offset, string)
dismissCompletionIfTextChanged()
}
override fun clearCurrentAction() {

View File

@@ -19,6 +19,7 @@ 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
@@ -151,6 +152,27 @@ class CopyActionTest : VimTestCase() {
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

@@ -0,0 +1,37 @@
/*
* 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-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
@@ -65,6 +65,34 @@ 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

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

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

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

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

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

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

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

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

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

@@ -361,7 +361,7 @@ class CommandParserTest : VimTestCase() {
\ endif
""".trimIndent(),
)
assertEquals(0, script.units.size)
assertEquals(1, 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(0, script.units.size)
assertEquals(1, script.units.size)
assertTrue(IdeavimErrorListener.testLogger.isEmpty())
}

View File

@@ -9,13 +9,20 @@
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() {
@@ -210,4 +217,56 @@ 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,6 +191,20 @@ 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

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

@@ -195,6 +195,7 @@ 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
@@ -258,6 +259,7 @@ class SetCommandTest : VimTestCase() {
| clipboard=ideaput,autoselect
| colorcolumn=
|nocommentary
| comments=s1:/*,mb:*,ex:*/,://,b:#,:%,:XCOMM,n:>,fb:-
|nocursorline
|nodigraph
|noexchange

View File

@@ -449,6 +449,7 @@ class SetglobalCommandTest : VimTestCase() {
|nohlsearch operatorfunc= nosurround
| ide=IntelliJ IDEA norelativenumber notextobj-entire
| clipboard=ideaput,autoselect
| comments=s1:/*,mb:*,ex:*/,://,b:#,:%,:XCOMM,n:>,fb:-
| 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
| idearefactormode=select
@@ -512,6 +513,7 @@ class SetglobalCommandTest : VimTestCase() {
| clipboard=ideaput,autoselect
| colorcolumn=
|nocommentary
| comments=s1:/*,mb:*,ex:*/,://,b:#,:%,:XCOMM,n:>,fb:-
|nocursorline
|nodigraph
|noexchange

View File

@@ -500,6 +500,7 @@ class SetlocalCommandTest : VimTestCase() {
|nohlsearch nrformats=hex nosmartcase wrap
| ide=IntelliJ IDEA nonumber nosneak 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
|--ideacopypreprocess
@@ -563,6 +564,7 @@ class SetlocalCommandTest : VimTestCase() {
| clipboard=ideaput,autoselect
| colorcolumn=
|nocommentary
| comments=s1:/*,mb:*,ex:*/,://,b:#,:%,:XCOMM,n:>,fb:-
|nocursorline
|nodigraph
|noexchange

View File

@@ -0,0 +1,80 @@
/*
* 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.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.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
@TestWithoutNeovim(reason = SkipNeovimReason.NOT_VIM_TESTING)
class UpdateCommandTest : VimTestCase() {
@Test
fun `update saves modified buffer`() {
val editor = openFile("hello.txt")
modifyDocument(editor)
val fdm = FileDocumentManager.getInstance()
assertTrue(fdm.isDocumentUnsaved(editor.document))
enterCommand("update")
assertPluginError(false)
assertFalse(fdm.isDocumentUnsaved(editor.document))
}
@Test
fun `update is noop when buffer is not modified`() {
val editor = openFile("hello.txt")
val fdm = FileDocumentManager.getInstance()
assertFalse(fdm.isDocumentUnsaved(editor.document))
enterCommand("update")
assertPluginError(false)
assertFalse(fdm.isDocumentUnsaved(editor.document))
}
@Test
fun `update short form saves modified buffer`() {
val editor = openFile("hello.txt")
modifyDocument(editor)
val fdm = FileDocumentManager.getInstance()
assertTrue(fdm.isDocumentUnsaved(editor.document))
enterCommand("up")
assertPluginError(false)
assertFalse(fdm.isDocumentUnsaved(editor.document))
}
private fun openFile(filename: String, content: String = "initial content"): Editor {
ApplicationManager.getApplication().invokeAndWait {
val file = fixture.createFile(filename, content)
BufNewFileTracker.consumeIfNew(file.path)
fixture.openFileInEditor(file)
}
return fixture.editor
}
private fun modifyDocument(editor: Editor) {
ApplicationManager.getApplication().invokeAndWait {
WriteCommandAction.runWriteCommandAction(fixture.project) {
editor.document.insertString(0, "x")
}
}
}
}

View File

@@ -133,7 +133,7 @@ class CommandTests : VimTestCase() {
augroup END
""".trimIndent(),
)
assertEquals(2, script.units.size)
assertEquals(4, script.units.size)
assertTrue(script.units[0] is PlugCommand)
assertTrue(script.units[1] is SetCommand)
}
@@ -150,8 +150,9 @@ class CommandTests : VimTestCase() {
set nu rnu
""".trimIndent(),
)
assertEquals(2, script.units.size)
assertTrue(script.units[0] is PlugCommand)
assertTrue(script.units[1] is SetCommand)
// `augroup myCmds` and `augroup END` are two units; `au smthing` is malformed and dropped by the parser.
assertEquals(4, script.units.size)
assertTrue(script.units[2] is PlugCommand)
assertTrue(script.units[3] is SetCommand)
}
}

View File

@@ -47,6 +47,48 @@ class VimArgTextObjExtensionTest : VimTestCase() {
)
}
// VIM-4193: daa must operate per-caret when multiple carets are active.
@Test
fun testDeleteAnArgumentWithMultipleCarets() {
doTest(
Lists.newArrayList("daa"),
"""
fun test() {
println(<caret>"abc", 1)
println(<caret>"def", 2)
}
""".trimIndent(),
"""
fun test() {
println(<caret>1)
println(<caret>2)
}
""".trimIndent(),
Mode.NORMAL(),
)
}
// VIM-4193: dia must operate per-caret when multiple carets are active.
@Test
fun testDeleteInnerArgumentWithMultipleCarets() {
doTest(
Lists.newArrayList("dia"),
"""
fun test() {
println(<caret>"abc", 1)
println(<caret>"def", 2)
}
""".trimIndent(),
"""
fun test() {
println(<caret>, 1)
println(<caret>, 2)
}
""".trimIndent(),
Mode.NORMAL(),
)
}
@Test
fun testChangeInnerArgument() {
doTest(

View File

@@ -0,0 +1,57 @@
/*
* 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.extension.replacewithregister
import com.maddyhome.idea.vim.VimPlugin
import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.command.MappingMode
import com.maddyhome.idea.vim.key.ToKeysMappingInfo
import org.jetbrains.plugins.ideavim.VimTestCase
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
// Reproduction for VIM-4180: user's `nmap gr <nop>` in .ideavimrc is overridden by the
// ReplaceWithRegister extension's default mappings when they are applied after the vimrc
// execution as part of the delayed extension-init flow.
class ReplaceWithRegisterMapOverrideTest : VimTestCase() {
@Test
fun `user nmap gr nop is not overridden by plugin default`() {
configureByText("hello")
injector.vimscriptExecutor.executingVimscript = true
injector.vimscriptExecutor.executingIdeaVimRcConfiguration = true
executeVimscript(
"""
set ReplaceWithRegister
xmap s <Plug>ReplaceWithRegisterVisual
nmap s <Plug>ReplaceWithRegisterOperator
nmap ss <Plug>ReplaceWithRegisterLine
nmap gr <nop>
nmap grr <nop>
vmap gr <nop>
""".trimIndent(),
skipHistory = false,
)
injector.vimscriptExecutor.executingIdeaVimRcConfiguration = false
injector.vimscriptExecutor.executingVimscript = false
val nop = injector.parser.parseKeys("<nop>")
val grKeys = injector.parser.parseKeys("gr")
val grrKeys = injector.parser.parseKeys("grr")
val nGr = VimPlugin.getKey().getKeyMapping(MappingMode.NORMAL)[grKeys]
val nGrr = VimPlugin.getKey().getKeyMapping(MappingMode.NORMAL)[grrKeys]
val vGr = VimPlugin.getKey().getKeyMapping(MappingMode.VISUAL)[grKeys]
assertEquals(nop, (nGr as? ToKeysMappingInfo)?.toKeys, "normal gr should map to <nop>")
assertEquals(nop, (nGrr as? ToKeysMappingInfo)?.toKeys, "normal grr should map to <nop>")
assertEquals(nop, (vGr as? ToKeysMappingInfo)?.toKeys, "visual gr should map to <nop>")
}
}

View File

@@ -256,6 +256,30 @@ class VimIndentObjectTest : VimTestCase() {
)
}
// VIM-4193: dii must operate per-caret when multiple carets are active —
// each caret must delete the indent block at its own location, not the
// primary caret's block for every caret.
@Test
fun testDeleteInnerIndentWithMultipleCarets() {
doTest(
"dii",
"""
one
<caret>aa
bb
two
<caret>cc
dd
three
""".trimIndent(),
"""
one
<caret>two
<caret>three
""".trimIndent(),
)
}
@Test
fun testSelectNestedTabs() {
doTest(

View File

@@ -0,0 +1,84 @@
/*
* 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.option
import com.maddyhome.idea.vim.api.Options
import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.newapi.vim
import com.maddyhome.idea.vim.options.OptionAccessScope
import org.jetbrains.plugins.ideavim.SkipNeovimReason
import org.jetbrains.plugins.ideavim.TestWithoutNeovim
import org.jetbrains.plugins.ideavim.VimTestCase
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInfo
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
@TestWithoutNeovim(reason = SkipNeovimReason.OPTION)
class CommentsOptionTest : VimTestCase() {
private val vimDefault = "s1:/*,mb:*,ex:*/,://,b:#,:%,:XCOMM,n:>,fb:-"
@BeforeEach
override fun setUp(testInfo: TestInfo) {
super.setUp(testInfo)
configureByText("\n")
}
private fun commentsValue(): String =
injector.optionGroup.getOptionValue(
Options.comments,
OptionAccessScope.LOCAL(fixture.editor.vim),
).value
@Test
fun `comments option default value matches Vim`() {
assertEquals(vimDefault, commentsValue())
}
@Test
fun `set comments changes the value`() {
enterCommand("set comments=://,b:#")
assertEquals("://,b:#", commentsValue())
}
@Test
fun `set comments& resets to default`() {
val original = commentsValue()
enterCommand("set comments=://")
assertNotEquals(original, commentsValue())
enterCommand("set comments&")
assertEquals(original, commentsValue())
}
@Test
fun `comments abbreviation com is accepted`() {
enterCommand("set com=://")
assertEquals("://", commentsValue())
}
@Test
fun `set comments inspect displays name and value`() {
assertCommandOutput("set comments?", " comments=$vimDefault")
}
@Test
fun `set comments+= appends a list entry`() {
enterCommand("set comments=://")
enterCommand("set comments+=b:#")
assertEquals("://,b:#", commentsValue())
}
@Test
fun `set comments-= removes a list entry`() {
enterCommand("set comments=://,b:#")
enterCommand("set comments-=://")
assertEquals("b:#", commentsValue())
}
}

View File

@@ -0,0 +1,47 @@
/*
* 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.intellij.openapi.fileTypes.PlainTextFileType
import com.maddyhome.idea.vim.api.injector
import org.jetbrains.plugins.ideavim.SkipNeovimReason
import org.jetbrains.plugins.ideavim.TestWithoutNeovim
import org.jetbrains.plugins.ideavim.VimTestCase
import org.junit.jupiter.api.Test
/**
* End-to-end coverage of `gq` honoring the buffer-local `'comments'` value.
*
* Uses a leader not present in the default `'comments'` string so the assertion
* fails if the option value is not read at wrap time.
*/
@TestWithoutNeovim(
reason = SkipNeovimReason.SEE_DESCRIPTION,
description = "IdeaVim wraps via the 'comments' option and its filetype presets.",
)
class CommentsDrivenReformatTest : VimTestCase() {
@Test
fun `custom REM marker from setlocal drives wrap continuation`() {
configureByText(
PlainTextFileType.INSTANCE,
"REM ${c}some long custom-marker comment text that must wrap to respect textwidth",
)
enterCommand("setlocal comments=:REM")
enterCommand("set textwidth=30")
typeText(injector.parser.parseKeys("gqq"))
assertState(
"""
${c}REM some long custom-marker
REM comment text that must
REM wrap to respect textwidth
""".trimIndent(),
)
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2003-2024 The IdeaVim authors
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
@@ -13,91 +13,477 @@ import org.jetbrains.plugins.ideavim.TestWithoutNeovim
import org.jetbrains.plugins.ideavim.VimJavaTestCase
import org.junit.jupiter.api.Test
@Suppress("unused")
@TestWithoutNeovim(
reason = SkipNeovimReason.SEE_DESCRIPTION,
description = "IdeaVim uses IDE code formatter instead of Vim's text formatting based on textwidth",
)
class ReformatCodeTest : VimJavaTestCase() {
@Test
fun testEmpty() {
configureByJavaText("<caret>")
configureByJavaText(c)
typeText(injector.parser.parseKeys("gqq"))
assertState("<caret>")
assertState(c)
}
@TestWithoutNeovim(reason = SkipNeovimReason.DIFFERENT)
@Test
fun testWithCount() {
configureByJavaText("class C {\n\tint a;\n\tint <caret>b;\n\tint c;\n\tint d;\n}\n")
configureByJavaText(
"""
class C {
int a;
int ${c}b;
int c;
int d;
}
""".trimIndent(),
)
typeText(injector.parser.parseKeys("2gqq"))
assertState("class C {\n" + "\tint a;\n" + " <caret>int b;\n" + " int c;\n" + "\tint d;\n" + "}\n")
}
@TestWithoutNeovim(reason = SkipNeovimReason.DIFFERENT)
@Test
fun testWithUpMotion() {
configureByJavaText("class C {\n" + "\tint a;\n" + "\tint b;\n" + "\tint <caret>c;\n" + "\tint d;\n" + "}\n")
typeText(injector.parser.parseKeys("gqk"))
assertState("class C {\n" + "\tint a;\n" + " <caret>int b;\n" + " int c;\n" + "\tint d;\n" + "}\n")
}
@TestWithoutNeovim(reason = SkipNeovimReason.DIFFERENT)
@Test
fun testWithRightMotion() {
configureByJavaText("class C {\n" + "\tint a;\n" + "\tint <caret>b;\n" + "\tint c;\n" + "}\n")
typeText(injector.parser.parseKeys("gql"))
assertState("class C {\n" + "\tint a;\n" + " <caret>int b;\n" + "\tint c;\n" + "}\n")
}
@TestWithoutNeovim(reason = SkipNeovimReason.DIFFERENT)
@Test
fun testWithTextObject() {
configureByJavaText("class C {\n" + "\tint a;\n" + "\tint <caret>b;\n" + "\tint c;\n" + "}\n")
typeText(injector.parser.parseKeys("gqi{"))
assertState(
"""class C {
<caret>int a;
int b;
int c;
}
""",
"""
class C {
int a;
${c}int b;
int c;
int d;
}
""".trimIndent(),
)
}
@Test
fun testWithUpMotion() {
configureByJavaText(
"""
class C {
int a;
int b;
int ${c}c;
int d;
}
""".trimIndent(),
)
typeText(injector.parser.parseKeys("gqk"))
assertState(
"""
class C {
int a;
${c}int b;
int c;
int d;
}
""".trimIndent(),
)
}
@Test
fun testWithRightMotion() {
configureByJavaText(
"""
class C {
int a;
int ${c}b;
int c;
}
""".trimIndent(),
)
typeText(injector.parser.parseKeys("gql"))
assertState(
"""
class C {
int a;
${c}int b;
int c;
}
""".trimIndent(),
)
}
@Test
fun testWithTextObject() {
configureByJavaText(
"""
class C {
int a;
int ${c}b;
int c;
}
""".trimIndent(),
)
typeText(injector.parser.parseKeys("gqi{"))
assertState(
"""
class C {
${c}int a;
int b;
int c;
}
""".trimIndent(),
)
}
@TestWithoutNeovim(reason = SkipNeovimReason.DIFFERENT)
@Test
fun testWithCountsAndDownMotion() {
configureByJavaText("class C {\n" + "\tint <caret>a;\n" + "\tint b;\n" + "\tint c;\n" + "\tint d;\n" + "}\n")
configureByJavaText(
"""
class C {
int ${c}a;
int b;
int c;
int d;
}
""".trimIndent(),
)
typeText(injector.parser.parseKeys("2gqj"))
assertState("class C {\n" + " <caret>int a;\n" + " int b;\n" + " int c;\n" + "\tint d;\n" + "}\n")
assertState(
"""
class C {
${c}int a;
int b;
int c;
int d;
}
""".trimIndent(),
)
}
@TestWithoutNeovim(reason = SkipNeovimReason.DIFFERENT)
@Test
fun testVisual() {
configureByJavaText("class C {\n" + "\tint a;\n" + "\tint <caret>b;\n" + "\tint c;\n" + "}\n")
typeText(injector.parser.parseKeys("v" + "l" + "gq"))
assertState("class C {\n" + "\tint a;\n" + " <caret>int b;\n" + "\tint c;\n" + "}\n")
configureByJavaText(
"""
class C {
int a;
int ${c}b;
int c;
}
""".trimIndent(),
)
typeText(injector.parser.parseKeys("vlgq"))
assertState(
"""
class C {
int a;
${c}int b;
int c;
}
""".trimIndent(),
)
}
@TestWithoutNeovim(reason = SkipNeovimReason.DIFFERENT)
@Test
fun testLinewiseVisual() {
configureByJavaText("class C {\n" + "\tint a;\n" + "\tint <caret>b;\n" + "\tint c;\n" + "}\n")
typeText(injector.parser.parseKeys("V" + "l" + "gq"))
assertState("class C {\n" + "\tint a;\n" + " <caret>int b;\n" + "\tint c;\n" + "}\n")
configureByJavaText(
"""
class C {
int a;
int ${c}b;
int c;
}
""".trimIndent(),
)
typeText(injector.parser.parseKeys("Vlgq"))
assertState(
"""
class C {
int a;
${c}int b;
int c;
}
""".trimIndent(),
)
}
@TestWithoutNeovim(reason = SkipNeovimReason.DIFFERENT)
@Test
fun testVisualMultiline() {
configureByJavaText("class C {\n" + "\tint a;\n" + "\tint <caret>b;\n" + "\tint c;\n" + "\tint d;\n" + "}\n")
typeText(injector.parser.parseKeys("v" + "j" + "gq"))
assertState("class C {\n" + "\tint a;\n" + " <caret>int b;\n" + " int c;\n" + "\tint d;\n" + "}\n")
configureByJavaText(
"""
class C {
int a;
int ${c}b;
int c;
int d;
}
""".trimIndent(),
)
typeText(injector.parser.parseKeys("vjgq"))
assertState(
"""
class C {
int a;
${c}int b;
int c;
int d;
}
""".trimIndent(),
)
}
@TestWithoutNeovim(reason = SkipNeovimReason.DIFFERENT)
@Test
fun testVisualBlock() {
configureByJavaText("class C {\n" + "\tint a;\n" + "\tint <caret>b;\n" + "\tint c;\n" + "\tint d;\n" + "}\n")
typeText(injector.parser.parseKeys("<C-V>" + "j" + "gq"))
assertState("class C {\n" + "\tint a;\n" + " <caret>int b;\n" + " int c;\n" + "\tint d;\n" + "}\n")
configureByJavaText(
"""
class C {
int a;
int ${c}b;
int c;
int d;
}
""".trimIndent(),
)
typeText(injector.parser.parseKeys("<C-V>jgq"))
assertState(
"""
class C {
int a;
${c}int b;
int c;
int d;
}
""".trimIndent(),
)
configureByJavaText(
"""
class C {
int a;
int ${c}b;
int c;
int d;
}
""".trimIndent(),
)
typeText(injector.parser.parseKeys("<C-V>jgq"))
assertState(
"""
class C {
int a;
${c}int b;
int c;
int d;
}
""".trimIndent(),
)
}
@Test
fun testGwEmpty() {
configureByJavaText(c)
typeText(injector.parser.parseKeys("gww"))
assertState(c)
}
@Test
fun testGwWithCount() {
configureByJavaText(
"""
class C {
int a;
int ${c}b;
int c;
int d;
}
""".trimIndent(),
)
typeText(injector.parser.parseKeys("2gww"))
assertState(
"""
class C {
int a;
int ${c}b;
int c;
int d;
}
""".trimIndent(),
)
}
@Test
fun testGwWithUpMotion() {
configureByJavaText(
"""
class C {
int a;
int b;
int ${c}c;
int d;
}
""".trimIndent(),
)
typeText(injector.parser.parseKeys("gwk"))
assertState(
"""
class C {
int a;
int b;
int ${c}c;
int d;
}
""".trimIndent(),
)
}
@Test
fun testGwWithRightMotion() {
configureByJavaText(
"""
class C {
int a;
int ${c}b;
int c;
}
""".trimIndent(),
)
typeText(injector.parser.parseKeys("gwl"))
assertState(
"""
class C {
int a;
int ${c}b;
int c;
}
""".trimIndent(),
)
}
@Test
fun testGwWithTextObject() {
configureByJavaText(
"""
class C {
int a;
int ${c}b;
int c;
}
""".trimIndent(),
)
typeText(injector.parser.parseKeys("gwi{"))
assertState(
"""
class C {
int a;
int ${c}b;
int c;
}
""".trimIndent(),
)
}
@Test
fun testGwWithCountsAndDownMotion() {
configureByJavaText(
"""
class C {
int ${c}a;
int b;
int c;
int d;
}
""".trimIndent(),
)
typeText(injector.parser.parseKeys("2gwj"))
assertState(
"""
class C {
int ${c}a;
int b;
int c;
int d;
}
""".trimIndent(),
)
}
@Test
fun testGwVisual() {
configureByJavaText(
"""
class C {
int a;
int ${c}b;
int c;
}
""".trimIndent(),
)
typeText(injector.parser.parseKeys("vlgw"))
assertState(
"""
class C {
int a;
int ${c}b;
int c;
}
""".trimIndent(),
)
}
@Test
fun testGwLinewiseVisual() {
configureByJavaText(
"""
class C {
int a;
int ${c}b;
int c;
}
""".trimIndent(),
)
typeText(injector.parser.parseKeys("Vgw"))
assertState(
"""
class C {
int a;
int ${c}b;
int c;
}
""".trimIndent(),
)
}
@Test
fun testGwVisualMultiline() {
configureByJavaText(
"""
class C {
int a;
int ${c}b;
int c;
int d;
}
""".trimIndent(),
)
typeText(injector.parser.parseKeys("vjgw"))
assertState(
"""
class C {
int a;
int ${c}b;
int c;
int d;
}
""".trimIndent(),
)
}
@Test
fun testGwVisualBlock() {
configureByJavaText(
"""
class C {
int a;
int ${c}b;
int c;
int d;
}
""".trimIndent(),
)
typeText(injector.parser.parseKeys("<C-V>jgw"))
assertState(
"""
class C {
int a;
int ${c}b;
int c;
int d;
}
""".trimIndent(),
)
}
}

View File

@@ -0,0 +1,223 @@
/*
* 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.intellij.ide.highlighter.JavaFileType
import com.intellij.openapi.fileTypes.PlainTextFileType
import com.maddyhome.idea.vim.api.injector
import org.jetbrains.plugins.ideavim.SkipNeovimReason
import org.jetbrains.plugins.ideavim.TestWithoutNeovim
import org.jetbrains.plugins.ideavim.VimTestCase
import org.junit.jupiter.api.Test
@TestWithoutNeovim(
reason = SkipNeovimReason.SEE_DESCRIPTION,
description = "IdeaVim applies IDE code formatting before textwidth-based wrapping. Comment leaders are driven by the 'comments' option (per-filetype preset, Commenter fallback, or Vim default).",
)
class ReformatCommentsTest : VimTestCase() {
@Test
fun testGqWrapsLongJavaLineComment() {
configureByText(
JavaFileType.INSTANCE,
"""
class Test {
// ${c}This is a very long comment that should be wrapped
void method() {}
}
""".trimIndent(),
)
enterCommand("set textwidth=35")
typeText(injector.parser.parseKeys("gqq"))
assertState(
"""
class Test {
${c}// This is a very long comment
// that should be wrapped
void method() {}
}
""".trimIndent(),
)
}
@Test
fun testGqWrapsLongJavaBlockComment() {
configureByText(
JavaFileType.INSTANCE,
"""
class Test {
/* ${c}This is a very long block comment that should be wrapped by the formatter */
void method() {}
}
""".trimIndent(),
)
enterCommand("set textwidth=50")
typeText(injector.parser.parseKeys("gqq"))
assertState(
"""
class Test {
${c}/* This is a very long block comment that
* should be wrapped by the formatter */
void method() {}
}
""".trimIndent(),
)
}
@Test
fun testGqWrapsJavaDocComment() {
configureByText(
JavaFileType.INSTANCE,
"""
class Test {
/**
* ${c}This is a very long JavaDoc comment that should be wrapped when it exceeds previously defined textwidth
*/
void method() {}
}
""".trimIndent(),
)
enterCommand("set textwidth=50")
typeText(injector.parser.parseKeys("gqj"))
assertState(
"""
class Test {
/**
${c}* This is a very long JavaDoc comment that
* should be wrapped when it exceeds
* previously defined textwidth
*/
void method() {}
}
""".trimIndent(),
)
}
@Test
fun testGqWrapsLongPlainTextLine() {
configureByText(
PlainTextFileType.INSTANCE,
"${c}This is a very long line of plain text that should be wrapped when it exceeds the textwidth setting",
)
enterCommand("set textwidth=40")
typeText(injector.parser.parseKeys("gqq"))
assertState(
"""
${c}This is a very long line of plain text
that should be wrapped when it exceeds
the textwidth setting
""".trimIndent(),
)
}
@Test
fun testGwWrapsLongPlainTextLinePreservingCursor() {
configureByText(
PlainTextFileType.INSTANCE,
"This is a very long line of ${c}plain text that should be wrapped when it exceeds the textwidth setting",
)
enterCommand("set textwidth=40")
typeText(injector.parser.parseKeys("gww"))
assertState(
"""
This is a very long line of ${c}plain text
that should be wrapped when it exceeds
the textwidth setting
""".trimIndent(),
)
}
@Test
fun testGqWrapsMultipleLongLines() {
configureByText(
PlainTextFileType.INSTANCE,
"""
${c}This is the first very long line that needs wrapping to fit within textwidth.
This is the second very long line that also needs wrapping to fit within textwidth.
""".trimIndent(),
)
enterCommand("set textwidth=40")
typeText(injector.parser.parseKeys("gqj"))
assertState(
"""
${c}This is the first very long line that
needs wrapping to fit within textwidth.
This is the second very long line that
also needs wrapping to fit within
textwidth.
""".trimIndent(),
)
}
@Test
fun testGqVisualWrapsSelectedText() {
configureByText(
PlainTextFileType.INSTANCE,
"""
${c}This is a very long line that should be wrapped when formatted with gq in visual mode.
This line should not be affected.
""".trimIndent(),
)
enterCommand("set textwidth=40")
typeText(injector.parser.parseKeys("Vgq"))
assertState(
"""
${c}This is a very long line that should be
wrapped when formatted with gq in visual
mode.
This line should not be affected.
""".trimIndent(),
)
}
@Test
fun testGqWrapsLongMarkdownParagraph() {
configureByTextX(
"test.md",
"""
# Header
${c}This is a very long paragraph in a markdown file that should be wrapped when it exceeds the textwidth setting.
Another paragraph.
""".trimIndent(),
)
enterCommand("set textwidth=50")
typeText(injector.parser.parseKeys("gqq"))
assertState(
"""
# Header
${c}This is a very long paragraph in a markdown file
that should be wrapped when it exceeds the
textwidth setting.
Another paragraph.
""".trimIndent(),
)
}
@Test
fun testGwWrapsMarkdownPreservingCursor() {
configureByTextX(
"test.md",
"""
This is a very long ${c}markdown line that should be wrapped but cursor position should be preserved.
""".trimIndent(),
)
enterCommand("set textwidth=40")
typeText(injector.parser.parseKeys("gww"))
assertState(
"""
This is a very long ${c}markdown line that
should be wrapped but cursor position
should be preserved.
""".trimIndent(),
)
}
}

View File

@@ -34,8 +34,6 @@ dependencies {
testRuntimeOnly("org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
// Kodein DI is required at runtime by IDE Starter
testImplementation("org.kodein.di:kodein-di-jvm:7.31.0")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.10.2")
intellijPlatform {

View File

@@ -79,4 +79,37 @@ class CommentarySplitTest : IdeaVimStarterTestBase() {
val line2 = editorText().lines().getOrNull(1) ?: ""
assertTrue(!line2.contains("//")) { "Line should be uncommented. Line: $line2" }
}
@Test
fun `gcc returns to Normal mode after commenting`() {
openFile(javaFile("Comment4"))
setUpCommentary()
goToLine(2)
typeVim("gcc")
assertEditorContains("//", "Line should be commented")
typeVimAndEscape("Ohello")
assertEditorContains("hello", "Ohello<Esc> should insert 'hello' — proves mode is Normal")
}
@Test
fun `gcc then undo returns to Normal mode`() {
openFile(javaFile("Comment5"))
setUpCommentary()
goToLine(2)
typeVim("gcc")
assertEditorContains("//", "Line should be commented")
typeVim("u")
assertEditorNotContains("//", "Undo should remove comment completely")
typeVimAndEscape("Ohello")
assertEditorContains("hello", "Ohello<Esc> should insert 'hello' after undo — proves mode is Normal")
assertEditorNotContains("//", "Undo must not have left a lingering comment")
}
}

View File

@@ -16,10 +16,7 @@ import com.intellij.driver.sdk.ui.components.common.ideFrame
import com.intellij.driver.sdk.waitForIndicators
import com.intellij.ide.starter.config.ConfigurationStorage
import com.intellij.ide.starter.config.splitMode
import com.intellij.ide.starter.di.di
import com.intellij.ide.starter.driver.driver.remoteDev.RemDevDriverRunner
import com.intellij.ide.starter.driver.engine.BackgroundRun
import com.intellij.ide.starter.driver.engine.DriverRunner
import com.intellij.ide.starter.driver.engine.runIdeWithDriver
import com.intellij.ide.starter.ide.IDERemDevTestContext
import com.intellij.ide.starter.ide.IDETestContext
@@ -33,10 +30,6 @@ import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.TestInstance
import org.kodein.di.DI
import org.kodein.di.bindProvider
import org.kodein.di.direct
import org.kodein.di.instanceOrNull
import java.nio.file.Path
import kotlin.io.path.Path
import kotlin.io.path.createDirectories
@@ -85,14 +78,6 @@ abstract class IdeaVimStarterTestBase {
PluginConfigurator(context.frontendIDEContext).installPluginFromPath(pluginPath)
}
context.patchForMacOsSplitMode()
val hasDriverRunner = di.direct.instanceOrNull<DriverRunner>() != null
if (!hasDriverRunner) {
di = DI {
extend(di)
bindProvider<DriverRunner> { RemDevDriverRunner() }
}
}
}
configureContext(context)

View File

@@ -13,6 +13,20 @@ import org.junit.jupiter.api.Test
class RepeatUndoSplitTest : IdeaVimStarterTestBase() {
@Test
fun `should undo after insert text`() {
openFile(createFile("src/Repeat.txt", "test"))
typeVimAndEscape("0ddiHi ")
assertEditorContains("Hi ", "Should have inserted 'Hi '")
typeVim("u")
var text = ""
val found = waitUntil { text = editorText(); text.isEmpty() }
assertTrue(found) {
"Undo should revert insert. Actual: $text"
}
}
@Test
fun `substitute repeat with dot then undo`() {
openFile(createFile("src/Repeat1.txt", "abcdef\nghijkl\n"))

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2003-2024 The IdeaVim authors
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
@@ -78,6 +78,7 @@ class UiTests {
}
}
waitFor(Duration.ofMinutes(1)) { editor.findAllText("One").isNotEmpty() }
testVisualSort(editor)
testSelectTextWithDelay(editor)
testExtendSelection(editor)
testLargerDragSelection(editor)
@@ -394,6 +395,42 @@ class UiTests {
vimExit()
}
// VIM-4172
private fun IdeaFrame.testVisualSort(editor: Editor) {
println("Run testVisualSort...")
editor.injectText(
"""
|cherry
|banana
|apple
|date
""".trimMargin(),
)
keyboard {
escape()
enterText("gg")
enterText("Vjj")
enterText(":")
enterText("sort")
enter()
}
assertEquals(
"""
apple
banana
cherry
date
""".trimIndent(),
editor.text,
)
editor.injectText(testTextForEditor)
vimExit()
}
private fun IdeaFrame.toggleIdeaVim() {
this.remoteRobot.invokeActionJs("VimPluginToggle")
}

View File

@@ -6,7 +6,7 @@ grammar Vimscript;
//
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
script:
augroup? blockMember* EOF;
blockMember* EOF;
forLoop:
(WS | COLON)* FOR WS+ ((variableScope COLON)? variableName | (L_BRACKET argumentsDeclaration R_BRACKET)) WS+ IN WS* expr WS* ((inline_comment NEW_LINE) | (NEW_LINE | BAR)+)
@@ -72,9 +72,26 @@ functionFlag: RANGE | ABORT | DICT | CLOSURE;
argumentsDeclaration: (ETC | defaultValue (WS* COMMA WS* defaultValue)* (WS* COMMA WS* ETC WS*)? | (variableName (WS* COMMA WS* variableName)* (WS* COMMA WS* defaultValue)* (WS* COMMA WS* ETC WS*)?))?;
defaultValue: variableName WS* ASSIGN WS* expr;
autoCmd: (WS | COLON)* AUTOCMD commandArgument = ~(NEW_LINE)*? NEW_LINE;
autoCmd
: (WS | COLON)* AUTOCMD bang=BANG? WS+ auEvents WS+ auPattern WS+ auCommand NEW_LINE
| (WS | COLON)* AUTOCMD bang=BANG? WS* NEW_LINE
;
augroup: (WS | COLON)* AUGROUP .*? AUGROUP WS+ END WS* NEW_LINE;
auEvents
: auEventName (WS* COMMA WS* auEventName)*
;
auEventName
: anyCaseNameWithDigitsAndUnderscores
;
auPattern
: ~(WS | NEW_LINE)+
;
auCommand
: ~(NEW_LINE)+
;
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
@@ -113,7 +130,7 @@ command:
(WS | COLON)* range? (WS | COLON)*
name = (
ACTIONLIST | ACTION | ASCII | AT
ACTIONLIST | ACTION | ASCII | AT | AUGROUP
| ASSIGN // `:=` print last line number
| B_LOWERCASE | BUFFER| BUFFER_CLOSE | BUFFER_LIST
| CLASS | CLEARJUMPS | CMD_CLEAR | COPY
@@ -469,7 +486,6 @@ lowercaseAlphabeticChar:
;
keyword: ABORT
| AUGROUP
| AUTOCMD
| BREAK
| CATCH
@@ -894,7 +910,6 @@ INLINE_SEPARATOR: '\n' (' ' | '\t')* BACKSLASH -> skip;
LUA_CODE: 'lua' WS* '<<' WS* 'EOF' .*? 'EOF' -> skip;
LUA_CODE2: 'lua' WS* '<<' WS* 'END' .*? 'END' -> skip;
IDEAVIM_IGNORE: ('ideavim' | 'ideaVim' | 'IdeaVim') WS 'ignore' .*? ('ideavim' | 'ideaVim' | 'IdeaVim') WS 'ignore end' NEW_LINE -> skip;
AUGROUP_SKIP: NEW_LINE (WS|COLON)* AUGROUP .*? AUGROUP WS+ END -> skip;
// All the other symbols
UNICODE_CHAR: '\u0000'..'\uFFFE';

View File

@@ -0,0 +1,40 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package com.maddyhome.idea.vim.action.change.change
import com.intellij.vim.annotations.CommandOrMotion
import com.intellij.vim.annotations.Mode
import com.maddyhome.idea.vim.api.ExecutionContext
import com.maddyhome.idea.vim.api.VimCaret
import com.maddyhome.idea.vim.api.VimEditor
import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.command.Argument
import com.maddyhome.idea.vim.command.Command
import com.maddyhome.idea.vim.command.DuplicableOperatorAction
import com.maddyhome.idea.vim.command.OperatorArguments
import com.maddyhome.idea.vim.handler.ChangeEditorActionHandler
@CommandOrMotion(keys = ["gw"], modes = [Mode.NORMAL])
class ReformatCodeMotionPreserveCursorAction : ChangeEditorActionHandler.ForEachCaret(), DuplicableOperatorAction {
override val type: Command.Type = Command.Type.CHANGE
override val argumentType: Argument.Type = Argument.Type.MOTION
override val duplicateWith: Char = 'w'
override fun execute(
editor: VimEditor,
caret: VimCaret,
context: ExecutionContext,
argument: Argument?,
operatorArguments: OperatorArguments,
): Boolean {
return argument != null &&
injector.changeGroup.reformatCodeMotionPreserveCursor(editor, caret, context, argument, operatorArguments)
}
}

View File

@@ -0,0 +1,46 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package com.maddyhome.idea.vim.action.change.change
import com.intellij.vim.annotations.CommandOrMotion
import com.intellij.vim.annotations.Mode
import com.maddyhome.idea.vim.api.ExecutionContext
import com.maddyhome.idea.vim.api.VimCaret
import com.maddyhome.idea.vim.api.VimEditor
import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.command.Command
import com.maddyhome.idea.vim.command.CommandFlags
import com.maddyhome.idea.vim.command.OperatorArguments
import com.maddyhome.idea.vim.group.visual.VimSelection
import com.maddyhome.idea.vim.handler.VisualOperatorActionHandler
import com.maddyhome.idea.vim.helper.enumSetOf
import java.util.*
@CommandOrMotion(keys = ["gw"], modes = [Mode.VISUAL])
class ReformatCodeVisualPreserveCursorAction : VisualOperatorActionHandler.ForEachCaret() {
override val type: Command.Type = Command.Type.CHANGE
override val flags: EnumSet<CommandFlags> = enumSetOf(CommandFlags.FLAG_MOT_LINEWISE)
override fun executeAction(
editor: VimEditor,
caret: VimCaret,
context: ExecutionContext,
cmd: Command,
range: VimSelection,
operatorArguments: OperatorArguments,
): Boolean {
val startPosition = editor.offsetToBufferPosition(range.vimStart)
injector.changeGroup.reformatCodeSelectionPreserveCursor(editor, caret, range)
val newOffset = editor.bufferPositionToOffset(startPosition)
caret.moveToOffset(newOffset)
return true
}
}

View File

@@ -68,7 +68,8 @@ sealed class PutVisualTextBaseAction(
private fun getPutDataForCaret(textData: PutData.TextData?,
caret: VimCaret,
selection: VimSelection?,
count: Int,): PutData {
count: Int
): PutData {
val visualSelection = selection?.let { PutData.VisualSelection(mapOf(caret to it), it.type) }
return PutData(textData, visualSelection, count, insertTextBeforeCaret, indent, caretAfterInsertedText)
}

View File

@@ -0,0 +1,141 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package com.maddyhome.idea.vim.action.ex
import com.intellij.vim.annotations.CommandOrMotion
import com.intellij.vim.annotations.Mode
import com.maddyhome.idea.vim.api.CommandCompletionTypes
import com.maddyhome.idea.vim.api.CommandLineCompletion
import com.maddyhome.idea.vim.api.CommandLineCompletionType
import com.maddyhome.idea.vim.api.ExecutionContext
import com.maddyhome.idea.vim.api.VimCommandLine
import com.maddyhome.idea.vim.api.VimEditor
import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.command.Argument
@CommandOrMotion(keys = ["<Tab>"], modes = [Mode.CMD_LINE])
class CommandLineCompletionAction : CommandLineActionHandler() {
override fun execute(
commandLine: VimCommandLine,
editor: VimEditor,
context: ExecutionContext,
argument: Argument?,
): Boolean {
return performCompletion(commandLine, context, forward = true)
}
override fun execute(commandLine: VimCommandLine): Boolean {
return false
}
}
@CommandOrMotion(keys = ["<S-Tab>"], modes = [Mode.CMD_LINE])
class CommandLineCompletionBackwardAction : CommandLineActionHandler() {
override fun execute(
commandLine: VimCommandLine,
editor: VimEditor,
context: ExecutionContext,
argument: Argument?,
): Boolean {
return performCompletion(commandLine, context, forward = false)
}
override fun execute(commandLine: VimCommandLine): Boolean {
return false
}
}
private fun performCompletion(
commandLine: VimCommandLine,
context: ExecutionContext,
forward: Boolean,
): Boolean {
if (!commandLine.isExCommand()) return false
val existing = commandLine.activeCompletion
if (existing != null && existing.expectedText == commandLine.text) {
cycleExistingCompletion(commandLine, existing, forward)
return true
}
return startNewCompletion(commandLine, context, forward)
}
internal fun cycleExistingCompletion(
commandLine: VimCommandLine,
completion: CommandLineCompletion,
forward: Boolean,
) {
val match = selectMatch(completion, forward)
if (match == null) {
injector.messages.indicateError()
return
}
applyMatch(commandLine, completion, match)
}
private fun startNewCompletion(
commandLine: VimCommandLine,
context: ExecutionContext,
forward: Boolean,
): Boolean {
commandLine.activeCompletion = null
commandLine.hideCompletionBar()
val text = commandLine.text
val parsed = parseCommandLineForCompletion(text) ?: return false
val matches = findMatches(parsed, context) ?: return false
if (matches.isEmpty()) {
injector.messages.indicateError()
return true
}
if (matches.size == 1) {
applySingleMatch(commandLine, text, parsed.completionStart, matches[0])
return true
}
val completion = CommandLineCompletion(text, parsed.completionStart, matches)
commandLine.activeCompletion = completion
commandLine.showCompletionBar(completion)
val match = selectMatch(completion, forward) ?: return true
applyMatch(commandLine, completion, match)
return true
}
private fun findMatches(parsed: ParsedCommandLine, context: ExecutionContext): List<String>? {
val fullCommandName = injector.vimscriptParser.exCommands.getFullCommandName(parsed.commandName) ?: return null
val completionType = CommandCompletionTypes.getCompletionType(fullCommandName)
if (completionType == CommandLineCompletionType.NONE) return null
return injector.file.listFilesForCompletion(parsed.argumentPrefix, context)
}
internal fun selectMatch(completion: CommandLineCompletion, forward: Boolean): String? {
return if (forward) completion.nextMatch() else completion.previousMatch()
}
private fun applySingleMatch(commandLine: VimCommandLine, originalText: String, completionStart: Int, match: String) {
val prefix = originalText.substring(0, completionStart)
val newText = prefix + match
commandLine.setText(newText)
commandLine.caret.offset = newText.length
}
internal fun applyMatch(commandLine: VimCommandLine, completion: CommandLineCompletion, match: String) {
val prefix = completion.originalText.substring(0, completion.completionStart)
val newText = prefix + match
completion.updateExpectedText(newText)
commandLine.setText(newText)
commandLine.caret.offset = newText.length
commandLine.selectCompletionItem(completion.currentIndex)
}

View File

@@ -0,0 +1,54 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package com.maddyhome.idea.vim.action.ex
/**
* Lightweight parser for extracting the command name and argument prefix
* from a partially-typed ex command line. Used for Tab completion context detection.
*/
internal data class ParsedCommandLine(
val commandName: String,
val argumentPrefix: String,
val completionStart: Int,
)
internal fun parseCommandLineForCompletion(text: String): ParsedCommandLine? {
val trimmed = text.trimStart()
if (trimmed.isEmpty()) return null
val commandName = extractCommandName(trimmed) ?: return null
val commandEnd = commandName.length
if (!hasArgumentSeparator(trimmed, commandEnd)) return null
val argStart = skipSpaces(trimmed, commandEnd)
val argPrefix = trimmed.substring(argStart)
val leadingSpaces = text.length - trimmed.length
return ParsedCommandLine(commandName, argPrefix, leadingSpaces + argStart)
}
private fun extractCommandName(text: String): String? {
var end = 0
while (end < text.length && (text[end].isLetter() || text[end] == '!')) {
end++
}
if (end == 0) return null
return text.substring(0, end)
}
private fun hasArgumentSeparator(text: String, offset: Int): Boolean {
return offset < text.length && text[offset] == ' '
}
private fun skipSpaces(text: String, from: Int): Int {
var i = from
while (i < text.length && text[i] == ' ') i++
return i
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2003-2024 The IdeaVim authors
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
@@ -16,6 +16,12 @@ import com.maddyhome.idea.vim.common.Graphemes
@CommandOrMotion(keys = ["<Left>"], modes = [Mode.CMD_LINE])
class MoveCaretLeftAction : CommandLineActionHandler() {
override fun execute(commandLine: VimCommandLine): Boolean {
val completion = commandLine.activeCompletion
if (completion != null && completion.expectedText == commandLine.text) {
cycleExistingCompletion(commandLine, completion, forward = false)
return true
}
val caret = commandLine.caret
val prevOffset = Graphemes.prev(commandLine.text, caret.offset) ?: return true
caret.offset = prevOffset

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2003-2024 The IdeaVim authors
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
@@ -16,6 +16,12 @@ import com.maddyhome.idea.vim.common.Graphemes
@CommandOrMotion(keys = ["<Right>"], modes = [Mode.CMD_LINE])
class MoveCaretRightAction : CommandLineActionHandler() {
override fun execute(commandLine: VimCommandLine): Boolean {
val completion = commandLine.activeCompletion
if (completion != null && completion.expectedText == commandLine.text) {
cycleExistingCompletion(commandLine, completion, forward = true)
return true
}
val caret = commandLine.caret
val nextOffset = Graphemes.next(commandLine.text, caret.offset) ?: return true
caret.offset = nextOffset

View File

@@ -0,0 +1,47 @@
/*
* 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.api
import com.maddyhome.idea.vim.autocmd.AutoCmdEvent
/**
* Registry for Vim-style `:autocmd` event handlers.
*
* Handlers are registered per [AutoCmdEvent] with a file pattern and a Vimscript command to run. When an event is
* fired via [handleEvent], every registered command whose pattern matches the file path is executed against the
* current editor.
*
* Handlers can be grouped via [startAugroup]/[endAugroup]; [clearAugroup] removes all handlers in a group and
* [clearEvents] clears every handler (or just the active group, when one is open).
*/
interface AutoCmdService {
/**
* Fires [event] and runs any registered handlers whose pattern matches [filePath].
*
* [filePath] is a VFS path as returned by `VirtualFile.path` — not a native filesystem path — which is why it is a
* [String] rather than `java.nio.file.Path`. Vim pattern matching is textual and treats the value opaquely.
*/
fun handleEvent(event: AutoCmdEvent, filePath: String? = null, editor: VimEditor? = null)
/** Registers [command] to run for [event] when the file path matches [pattern] (default: all files). */
fun registerEventCommand(command: String, event: AutoCmdEvent, pattern: String = "*")
/** Removes every registered handler. If an augroup is active ([startAugroup]), only that group is cleared. */
fun clearEvents()
/** Opens an augroup named [name]; subsequent [registerEventCommand] calls tag handlers with this group. */
fun startAugroup(name: String)
/** Closes the currently open augroup. Has no effect if no group is open. */
fun endAugroup()
/** Removes every handler registered under the augroup [name]. */
fun clearAugroup(name: String)
}

View File

@@ -0,0 +1,58 @@
/*
* 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.api
/**
* Manages the state of an active command-line completion session.
*
* Created when Tab is first pressed, and cycles through matches on subsequent Tab/Shift-Tab presses.
* Invalidated when the command-line text is modified by anything other than the completion action.
*
* @param originalText The full command-line text before completion was triggered
* @param completionStart The offset in the command-line text where the argument being completed starts
* @param matches Sorted list of completion candidates
*/
class CommandLineCompletion(
val originalText: String,
val completionStart: Int,
val matches: List<String>,
) {
/** File names without the directory prefix, for display in the completion bar */
val displayNames: List<String> = run {
val argument = originalText.substring(completionStart)
val lastSlash = argument.lastIndexOf('/')
if (lastSlash < 0) matches
else matches.map { it.substring(minOf(lastSlash + 1, it.length)) }
}
var currentIndex: Int? = null
private set
/** The full command-line text that was set after applying the current match */
var expectedText: String = originalText
private set
fun nextMatch(): String? {
if (matches.isEmpty()) return null
val current = currentIndex
currentIndex = if (current == null) 0 else (current + 1) % matches.size
return matches[currentIndex!!]
}
fun previousMatch(): String? {
if (matches.isEmpty()) return null
val current = currentIndex
currentIndex = if (current == null || current <= 0) matches.size - 1 else current - 1
return matches[currentIndex!!]
}
fun updateExpectedText(text: String) {
expectedText = text
}
}

View File

@@ -0,0 +1,31 @@
/*
* 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.api
enum class CommandLineCompletionType {
NONE,
FILE,
}
object CommandCompletionTypes {
private val commandToCompletionType = mapOf(
"edit" to CommandLineCompletionType.FILE,
"browse" to CommandLineCompletionType.FILE,
"find" to CommandLineCompletionType.FILE,
"source" to CommandLineCompletionType.FILE,
"write" to CommandLineCompletionType.FILE,
"read" to CommandLineCompletionType.FILE,
"split" to CommandLineCompletionType.FILE,
"vsplit" to CommandLineCompletionType.FILE,
)
fun getCompletionType(fullCommandName: String): CommandLineCompletionType {
return commandToCompletionType[fullCommandName] ?: CommandLineCompletionType.NONE
}
}

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
@@ -102,6 +102,14 @@ object Options {
*/
// Simple options, sorted by name
val comments: StringListOption = addOption(
StringListOption(
"comments",
LOCAL_TO_BUFFER,
"com",
"s1:/*,mb:*,ex:*/,://,b:#,:%,:XCOMM,n:>,fb:-",
)
)
val digraph: ToggleOption = addOption(ToggleOption("digraph", GLOBAL, "dg", false))
// Default differs from Vim (which uses 0). In Vim, foldlevel=0 means all folds are closed on window open.

View File

@@ -212,6 +212,16 @@ interface VimChangeGroup {
fun reformatCodeSelection(editor: VimEditor, caret: VimCaret, range: VimSelection)
fun reformatCodeMotionPreserveCursor(
editor: VimEditor,
caret: VimCaret,
context: ExecutionContext,
argument: Argument,
operatorArguments: OperatorArguments,
): Boolean
fun reformatCodeSelectionPreserveCursor(editor: VimEditor, caret: VimCaret, range: VimSelection)
fun autoIndentMotion(
editor: VimEditor,
caret: VimCaret,

View File

@@ -1385,15 +1385,45 @@ abstract class VimChangeGroupBase : VimChangeGroup {
val starts = range.startOffsets
val ends = range.endOffsets
val firstLine = editor.offsetToBufferPosition(range.startOffset).line
reformatCodeRange(ends, editor, starts)
val newOffset = injector.motion.moveCaretToLineStartSkipLeading(editor, firstLine)
caret.moveToOffset(newOffset)
return true
}
override fun reformatCodeMotionPreserveCursor(
editor: VimEditor,
caret: VimCaret,
context: ExecutionContext,
argument: Argument,
operatorArguments: OperatorArguments,
): Boolean {
val range = injector.motion.getMotionRange(
editor, caret, context, argument,
operatorArguments
)
return range != null && reformatCodeRangePreserveCursor(editor, range)
}
override fun reformatCodeSelectionPreserveCursor(editor: VimEditor, caret: VimCaret, range: VimSelection) {
val textRange = range.toVimTextRange(true)
reformatCodeRangePreserveCursor(editor, textRange)
}
private fun reformatCodeRangePreserveCursor(editor: VimEditor, range: TextRange): Boolean {
val starts = range.startOffsets
val ends = range.endOffsets
reformatCodeRange(ends, editor, starts)
return true
}
private fun reformatCodeRange(ends: IntArray, editor: VimEditor, starts: IntArray) {
for (i in ends.indices.reversed()) {
val startOffset = editor.getLineStartForOffset(starts[i])
val offset = ends[i] - if (startOffset == ends[i]) 0 else 1
val endOffset = editor.getLineEndForOffset(offset)
reformatCode(editor, startOffset, endOffset)
}
val newOffset = injector.motion.moveCaretToLineStartSkipLeading(editor, firstLine)
caret.moveToOffset(newOffset)
return true
}
override fun autoIndentMotion(

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2003-2024 The IdeaVim authors
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
@@ -38,6 +38,20 @@ interface VimCommandLine {
fun toggleReplaceMode()
/** Active completion session, or null if no completion is in progress */
var activeCompletion: CommandLineCompletion?
/** Called when a new completion session starts and candidates should be displayed */
fun showCompletionBar(completion: CommandLineCompletion) {}
/** Called when the selected completion item changes */
fun selectCompletionItem(selectedIndex: Int?) {}
/** Called when the completion bar should be hidden */
fun hideCompletionBar() {}
fun isExCommand(): Boolean
/**
* The entered text. It does not include any rendered text such as `<80>` or prompts such as `^` or `?`
*/

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2003-2024 The IdeaVim authors
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
@@ -22,10 +22,10 @@ abstract class VimEditorBase : VimEditor {
if (oldValue == Mode.REPLACE) {
forgetAllReplaceMasks()
}
injector.listenersNotifier.notifyModeWillChange(this, oldValue, value)
updateMode(value)
injector.listenersNotifier.notifyModeChanged(this, oldValue)
}
override var isReplaceCharacter: Boolean
get() = injector.vimState.isReplaceCharacter
set(value) {

View File

@@ -94,4 +94,12 @@ interface VimEditorGroup {
* project initialization. If the null is returned, fallback to `injector.fallbackWindow`
*/
fun getSelectedEditor(projectId: String): VimEditor?
/**
* Get the currently selected editor, checking any open project.
*
* Prefer this over [getFocusedEditor]: focus is a UI-model concept and unreliable when panels take focus, whereas
* the selection is updated by window-switching commands directly. Returns null if no project has a selected editor.
*/
fun getSelectedEditor(): VimEditor?
}

View File

@@ -44,6 +44,15 @@ interface VimFile {
*/
fun findFile(filename: String, context: ExecutionContext): String? = null
/**
* Lists files and directories matching a partial path prefix for command-line completion.
*
* @param pathPrefix The partial path typed by the user (may be empty, relative, absolute, or start with ~/)
* @param context Execution context for project resolution
* @return List of matching file/directory paths (directories end with '/'), sorted alphabetically
*/
fun listFilesForCompletion(pathPrefix: String, context: ExecutionContext): List<String> = emptyList()
fun getProjectId(project: Any): String
/**

View File

@@ -147,6 +147,8 @@ interface VimInjector {
val listenersNotifier: VimListenersNotifier
val autoCmd: AutoCmdService
val redrawService: VimRedrawService
val pluginService: VimPluginService

View File

@@ -0,0 +1,35 @@
/*
* 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
enum class AutoCmdEvent {
InsertEnter,
InsertLeave,
BufEnter,
BufLeave,
BufRead,
BufReadPost,
BufNewFile,
BufWrite,
BufWritePre,
BufWritePost,
WinEnter,
WinLeave,
FocusGained,
FocusLost,
FileType,
;
val canonical: AutoCmdEvent
get() = when (this) {
BufRead -> BufReadPost
BufWrite -> BufWritePre
else -> this
}
}

View File

@@ -55,6 +55,7 @@ class CommandBuilder private constructor(
/** Returns true if the command builder is clean and ready to start building */
val isEmpty
get() = commandState == CurrentCommandState.NEW_COMMAND
&& !isRegisterPending
&& selectedRegister == null
&& counts.size == 1 && counts[0] == 0
&& action == null
@@ -398,6 +399,7 @@ class CommandBuilder private constructor(
if (keyStrokeTrie != other.keyStrokeTrie) return false
if (counts != other.counts) return false
if (isRegisterPending != other.isRegisterPending) return false
if (selectedRegister != other.selectedRegister) return false
if (action != other.action) return false
if (argument != other.argument) return false
@@ -412,6 +414,7 @@ class CommandBuilder private constructor(
override fun hashCode(): Int {
var result = keyStrokeTrie.hashCode()
result = 31 * result + counts.hashCode()
result = 31 * result + isRegisterPending.hashCode()
result = 31 * result + selectedRegister.hashCode()
result = 31 * result + action.hashCode()
result = 31 * result + argument.hashCode()
@@ -430,6 +433,7 @@ class CommandBuilder private constructor(
commandKeyStrokes.toMutableList()
)
result.selectedRegister = selectedRegister
result.isRegisterPending = isRegisterPending
result.action = action
result.argument = argument
result.commandState = commandState

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