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

Compare commits

..

129 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
1grzyb1
9f33320e66 Move split mode tests to team city 2026-04-09 14:21:18 +02:00
1grzyb1
5dc5aaa0d8 VIM-4176 Don't focus single line output
Scroll was causing clearing output panel which resulted in race conditions
2026-04-09 14:06:05 +02:00
1grzyb1
0d8d215946 Clear gradle caches in random order tests
They were keeping old class descriptors
2026-04-09 12:26:32 +02:00
1grzyb1
c9234b82f5 VIM-4175 Clear output panel before showing error
Split showing error message into two separate methods. Once that appends the error to the current output panel and the second that clears the output panel before showing the error. So when no search results are found we don;t show hit enter message,
2026-04-09 11:45:56 +02:00
1grzyb1
b9bd523648 Fix visual selection commands failing off-EDT due to nested write-in-read action
Commands entered from Visual mode (e.g. :'<,'>sort) fail because Command.execute wraps selection cleanup in runReadAction, but exitVisualMode nests a runWriteAction inside it, which deadlocks off-EDT. Remove the unnecessary runWriteAction from exitVisualMode since removeSelection only requires EDT, not a write lock.
2026-04-08 13:48:33 +02:00
1grzyb1
4f5b793642 Bump ideaVersion to 2026.1 2026-04-08 13:48:33 +02:00
1grzyb1
2fdf52d305 Split double undo into two singles once 2026-04-08 09:24:14 +02:00
1grzyb1
56103c990b Increase compatibility pipeline timeout to 180 minutes 2026-04-08 08:50:47 +02:00
1grzyb1
1e489e2c14 Wait for ideavim to attatch to editor 2026-04-08 08:44:16 +02:00
1grzyb1
06d877415d Make go back shortcut platform-specific 2026-04-07 12:52:07 +02:00
claude[bot]
c946ecde86 Fix NPE when using \/, \?, or \& range without previous search pattern
When using \/, \?, or \& in an Ex command range (e.g., :\/ d) without a
previous search or substitute pattern, the code stored null in the
patterns list and then threw NullPointerException via the !! assertion
in calculateLine1.

Instead, throw the appropriate Vim error eagerly when building the
SearchAddress: E35 for \/ and \? (no previous search), E33 for \& (no
previous substitute). The patterns list is now non-nullable, eliminating
the !! assertion.

Add regression tests that would have caught this NPE.
2026-04-07 12:51:40 +02:00
claude[bot]
00e1d8173e Remove duplicate test and fix typo in HistoryCommandTest
- Remove `test history cmd lists empty command history` which was an
  exact duplicate of `test history cmd lists current cmd in history`
- Fix typo "saerch" -> "search" in test name

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:50:20 +02:00
IdeaVim Bot
cff4afa050 Add digitalby to contributors list 2026-04-07 09:20:20 +00:00
1grzyb1
706ac76b5e Update changelog: aggregate all pending changelog entries
Combines entries from 10 individual changelog PRs (#1593, #1604, #1607,
#1612, #1614, #1618, #1625, #1637, #1644, #1648) into a single update.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 11:13:16 +02:00
1grzyb1
98934ff7bf Clean Gradle before running tests
It should ensure no stale classes survive between builds
2026-04-07 11:09:05 +02:00
1grzyb1
c26658d27d Ensure editor is ready before proceeding with split tests 2026-04-07 10:52:41 +02:00
1grzyb1
f6fb0fbea6 Disable gradle cache for testing build type
It was preserving old class descriptors
2026-04-07 10:36:31 +02:00
digitalby
74cf6fbee8 refactor: Extract isGotoAction and saveJumpBeforeGoto helpers, add split-mode test for IDE Back jump 2026-04-07 09:47:42 +02:00
digitalby
b22089f50f fix: Fix `` and \'\' jump commands not working after the IJ Meta+B shortcut 2026-04-07 09:47:42 +02:00
digitalby
dcb15c826c Save jump location before IDE Back/Forward navigation 2026-04-07 09:47:42 +02:00
digitalby
4ddc30055c refactor: Extract ToolWindowPositioningListener to DRY up duplicated subscriptions in ExEntryPanel and OutputPanel 2026-04-07 09:37:19 +02:00
digitalby
20b46279ad Reposition command and output panels on tool window state change 2026-04-07 09:37:19 +02:00
claude[bot]
3653e7f193 Update changelog: Fix pumvisible, Rider/CLion ESC, extension init
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 09:23:15 +02:00
claude[bot]
fd3222dd76 Replace java.lang.Long.toHexString with Kotlin's Int.toString(16) in VimFileBase 2026-04-07 09:12:01 +02:00
claude[bot]
11ca10d10a Replace java.lang.Integer.min with kotlin.math.min in GoToLineCommand 2026-04-07 09:12:01 +02:00
claude[bot]
fa33b264ba Use ExException.code to identify E130 in DelfunctionCommand
Instead of checking e.message.startsWith("E130"), use the dedicated code
field on ExException, which is set by exExceptionMessage() when the exception
is created. This is more robust since it doesn't depend on message formatting.
2026-04-07 09:12:01 +02:00
1grzyb1
46a48b03a1 Return ChangeGroup from VimPLugin to make it compatibile 2026-04-07 08:55:25 +02:00
167 changed files with 9204 additions and 1518 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

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

1
.idea/gradle.xml generated
View File

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

View File

@@ -1,3 +1,11 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package _Self package _Self
import _Self.buildTypes.Compatibility import _Self.buildTypes.Compatibility
@@ -6,6 +14,7 @@ import _Self.buildTypes.Nvim
import _Self.buildTypes.PluginVerifier import _Self.buildTypes.PluginVerifier
import _Self.buildTypes.PropertyBased import _Self.buildTypes.PropertyBased
import _Self.buildTypes.RandomOrderTests import _Self.buildTypes.RandomOrderTests
import _Self.buildTypes.SplitModeTests
import _Self.buildTypes.TestingBuildType import _Self.buildTypes.TestingBuildType
import _Self.buildTypes.TypeScriptTest import _Self.buildTypes.TypeScriptTest
@@ -30,6 +39,7 @@ object Project : Project({
buildType(PropertyBased) buildType(PropertyBased)
buildType(LongRunning) buildType(LongRunning)
buildType(RandomOrderTests) buildType(RandomOrderTests)
buildType(SplitModeTests)
buildType(Nvim) buildType(Nvim)
buildType(PluginVerifier) buildType(PluginVerifier)

View File

@@ -1,3 +1,11 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package _Self.buildTypes package _Self.buildTypes
import _Self.AgentSize import _Self.AgentSize
@@ -11,6 +19,10 @@ object Compatibility : IdeaVimBuildType({
id("IdeaVimCompatibility") id("IdeaVimCompatibility")
name = "IdeaVim compatibility with external plugins" name = "IdeaVim compatibility with external plugins"
failureConditions {
executionTimeoutMin = 180
}
vcs { vcs {
root(DslContext.settingsRoot) root(DslContext.settingsRoot)
branchFilter = "+:<default>" branchFilter = "+:<default>"

View File

@@ -1,3 +1,11 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package _Self.buildTypes package _Self.buildTypes
import _Self.AgentSize import _Self.AgentSize
@@ -26,7 +34,7 @@ object RandomOrderTests : IdeaVimBuildType({
gradle { gradle {
clearConditions() clearConditions()
tasks = """ tasks = """
test clean test
-x :tests:property-tests:test -x :tests:property-tests:test
-x :tests:long-running-tests:test -x :tests:long-running-tests:test
-Djunit.jupiter.execution.order.random.seed=default -Djunit.jupiter.execution.order.random.seed=default
@@ -34,7 +42,7 @@ object RandomOrderTests : IdeaVimBuildType({
""".trimIndent().replace("\n", " ") """.trimIndent().replace("\n", " ")
buildFile = "" buildFile = ""
enableStacktrace = true enableStacktrace = true
gradleParams = "--build-cache --configuration-cache" gradleParams = "--no-build-cache --configuration-cache"
jdkHome = "/usr/lib/jvm/java-21-amazon-corretto" jdkHome = "/usr/lib/jvm/java-21-amazon-corretto"
} }
} }

View File

@@ -0,0 +1,85 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package _Self.buildTypes
import _Self.AgentSize
import _Self.IdeaVimBuildType
import jetbrains.buildServer.configs.kotlin.v2019_2.CheckoutMode
import jetbrains.buildServer.configs.kotlin.v2019_2.DslContext
import jetbrains.buildServer.configs.kotlin.v2019_2.buildSteps.script
object SplitModeTests : IdeaVimBuildType({
name = "Split mode tests"
description = "Tests for IdeaVim in Remote Development split mode (backend + frontend)"
artifactRules = """
+:tests/split-mode-tests/build/reports => split-mode-tests/build/reports
+:out/ide-tests/tests/**/log => out/ide-tests/log
+:out/ide-tests/tests/**/frontend/log => out/ide-tests/frontend-log
""".trimIndent()
params {
param("env.ORG_GRADLE_PROJECT_downloadIdeaSources", "false")
param("env.ORG_GRADLE_PROJECT_instrumentPluginCode", "false")
param("env.DISPLAY", ":99")
}
vcs {
root(DslContext.settingsRoot)
branchFilter = "+:<default>"
checkoutMode = CheckoutMode.AUTO
}
steps {
script {
name = "Start Xvfb and run split mode tests"
scriptContent = """
# Kill any leftover Xvfb from previous runs
pkill -f 'Xvfb :99' || true
Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp &
XVFB_PID=${'$'}!
# Wait until the display is ready
for i in $(seq 1 30); do
if xdpyinfo -display :99 >/dev/null 2>&1; then
echo "Xvfb is ready on :99"
break
fi
sleep 1
done
if ! xdpyinfo -display :99 >/dev/null 2>&1; then
echo "ERROR: Xvfb failed to start on :99"
exit 1
fi
./gradlew :tests:split-mode-tests:testSplitMode --console=plain --build-cache --configuration-cache --stacktrace
TEST_EXIT=${'$'}?
kill ${'$'}XVFB_PID 2>/dev/null || true
exit ${'$'}TEST_EXIT
""".trimIndent()
}
}
// VCS trigger disabled until Xvfb is installed on the TeamCity agent
// triggers {
// vcs {
// branchFilter = "+:<default>"
// }
// }
requirements {
// Use a larger agent for split-mode tests — they launch two full IDE instances
equals("teamcity.agent.hardware.cpuCount", AgentSize.XLARGE)
equals("teamcity.agent.os.family", "Linux")
}
})

View File

@@ -1,3 +1,11 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
@file:Suppress("ClassName") @file:Suppress("ClassName")
package _Self.buildTypes package _Self.buildTypes
@@ -41,10 +49,10 @@ open class TestingBuildType(
steps { steps {
gradle { gradle {
clearConditions() clearConditions()
tasks = "test -x :tests:property-tests:test -x :tests:long-running-tests:test" tasks = "clean test -x :tests:property-tests:test -x :tests:long-running-tests:test"
buildFile = "" buildFile = ""
enableStacktrace = true enableStacktrace = true
gradleParams = "--build-cache --configuration-cache" gradleParams = "--no-build-cache --configuration-cache"
jdkHome = "/usr/lib/jvm/java-21-amazon-corretto" jdkHome = "/usr/lib/jvm/java-21-amazon-corretto"
} }
} }

View File

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

View File

@@ -26,6 +26,8 @@ usual beta standards.
## [To Be Released] ## [To Be Released]
### Features: ### 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()` * 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 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"`) * [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,18 +36,71 @@ 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 `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 `zf` command - create fold from selection or motion
* [VIM-566](https://youtrack.jetbrains.com/issue/VIM-566) Added support for `:set foldlevel` option - control fold visibility level * [VIM-566](https://youtrack.jetbrains.com/issue/VIM-566) Added support for `:set foldlevel` option - control fold visibility level
* [VIM-1158](https://youtrack.jetbrains.com/issue/VIM-1158) Added `gw` command - reformat code like `gq` but preserving the cursor position
### Fixes: ### Fixes:
* [VIM-4197](https://youtrack.jetbrains.com/issue/VIM-4197) Fixed Vim features (e.g., `f`, `w`, text objects) not working in Java files decompiled from Kotlin class files
* [VIM-4112](https://youtrack.jetbrains.com/issue/VIM-4112) Fixed undo after block-visual edit (`<C-V>...x`, `<C-V>...c`, `<C-V>...I`) leaving stray carets in normal mode
* [VIM-4176](https://youtrack.jetbrains.com/issue/VIM-4176) Fixed race condition in single-line output panel that could cause `*` search wrapping to behave unreliably
* [VIM-4175](https://youtrack.jetbrains.com/issue/VIM-4175) Fixed search "not found" showing previous "Hit ENTER" text alongside the error - panel is now cleared before displaying errors like "E486: Pattern not found"
* [VIM-4135](https://youtrack.jetbrains.com/issue/VIM-4135) Fixed IdeaVim not loading in Rider
* [VIM-4134](https://youtrack.jetbrains.com/issue/VIM-4134) Fixed undo in commentary - `gcc`/`gc{motion}` changes are now properly grouped as a single undo step
* [VIM-4134](https://youtrack.jetbrains.com/issue/VIM-4134) Fixed `=` (format/auto-indent) action in split mode
* [VIM-4134](https://youtrack.jetbrains.com/issue/VIM-4134) Fixed global marks causing errors when used inside write actions (e.g., during document modifications)
* [VIM-4105](https://youtrack.jetbrains.com/issue/VIM-4105) Fixed `a"` `a'` `a\`` text objects to include surrounding whitespace per Vim spec * [VIM-4105](https://youtrack.jetbrains.com/issue/VIM-4105) Fixed `a"` `a'` `a\`` text objects to include surrounding whitespace per Vim spec
* [VIM-4097](https://youtrack.jetbrains.com/issue/VIM-4097) Fixed `<A-n>` (NextOccurrence) with text containing backslashes - e.g., selecting `\IntegerField` now works correctly * [VIM-4097](https://youtrack.jetbrains.com/issue/VIM-4097) Fixed `<A-n>` (NextOccurrence) with text containing backslashes - e.g., selecting `\IntegerField` now works correctly
* [VIM-4094](https://youtrack.jetbrains.com/issue/VIM-4094) Fixed UninitializedPropertyAccessException when loading history * [VIM-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-3948](https://youtrack.jetbrains.com/issue/VIM-3948) Improved hint generation visibility checks for better UI component detection
* [VIM-4195](https://youtrack.jetbrains.com/issue/VIM-4195) Fixed settings not being saved in remote development (split) mode
* [VIM-3473](https://youtrack.jetbrains.com/issue/VIM-3473) Fixed "Reload .ideavimrc" action in remote development (split) mode - no longer causes File Cache Conflict dialogs
* [VIM-2821](https://youtrack.jetbrains.com/issue/VIM-2821) Fixed undo grouping when repeating text insertion with `.` in remote development (split mode)
* [VIM-1705](https://youtrack.jetbrains.com/issue/VIM-1705) Fixed window-switching commands (e.g., `<C-w>h`) during macro playback
* Fixed `pumvisible()` function returning incorrect result (was inverted)
* Fixed `<Esc>` not properly exiting insert mode in Rider/CLion when canceling a completion lookup
* Fixed `<Esc>` not exiting insert mode after `<C-Space>` completion in Rider
* Fixed `<Esc>` in search bar no longer inserts `^[` literal text when search is not found - panel is now properly closed
* Fixed IdeaVim entering broken state when a VimScript extension plugin fails to initialize
* Fixed compatibility issues with external plugins (e.g., IdeaVim-EasyMotion, multicursor)
* Fixed recursive key mappings (e.g., `map b wbb`) causing an apparent infinite loop - `maxmapdepth` limit now properly terminates the entire mapping chain
* Fixed NERDTree `gs`/`gi` preview split commands to keep focus on the tree
* Fixed visual marks (`<` and `>`) position tracking after text deletion - `gv` now re-selects correctly
* Fixed `IndexOutOfBoundsException` when using text objects like `a)` at end of file
* Fixed high CPU usage while showing command line * Fixed high CPU usage while showing command line
* Fixed comparison of String and Number in VimScript expressions * 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: ### Merged PRs:
* [1704](https://github.com/JetBrains/ideavim/pull/1704) by [1grzyb1](https://github.com/1grzyb1): VIM-4202 Don't intercept all <S-Tab>
* [1703](https://github.com/JetBrains/ideavim/pull/1703) by [1grzyb1](https://github.com/1grzyb1): VIM-4202 Fix state after commentary action
* [1700](https://github.com/JetBrains/ideavim/pull/1700) by [1grzyb1](https://github.com/1grzyb1): VIM-4139 Compute nesting depth for fold regions
* [1699](https://github.com/JetBrains/ideavim/pull/1699) by [1grzyb1](https://github.com/1grzyb1): VIM-4112 collapse restored carets after undo of block-visual edit
* [1696](https://github.com/JetBrains/ideavim/pull/1696) by [citizenmatt](https://github.com/citizenmatt): VIM-4197 Fix missing Vim features in Java files decompiled from Kotlin class files
* [1695](https://github.com/JetBrains/ideavim/pull/1695) by [1grzyb1](https://github.com/1grzyb1): VIM-1693 Implement autocmd
* [1690](https://github.com/JetBrains/ideavim/pull/1690) by [1grzyb1](https://github.com/1grzyb1): Make nerdtree work without calling backend actions
* [1688](https://github.com/JetBrains/ideavim/pull/1688) by [1grzyb1](https://github.com/1grzyb1): VIM-4172 Disable ideavim in Python Console
* [1687](https://github.com/JetBrains/ideavim/pull/1687) by [1grzyb1](https://github.com/1grzyb1): Restore old VimPLugin method signatures
* [1685](https://github.com/JetBrains/ideavim/pull/1685) by [1grzyb1](https://github.com/1grzyb1): VIM-3727 Fix Python console Enter and arrow keys in split mode
* [1548](https://github.com/JetBrains/ideavim/pull/1548) by [1grzyb1](https://github.com/1grzyb1): VIM-1158 Add `gw` to reformat code with preserving the cursor position
* [1682](https://github.com/JetBrains/ideavim/pull/1682) by [1grzyb1](https://github.com/1grzyb1): VIM-268 Complete file names in edit command
* [1632](https://github.com/JetBrains/ideavim/pull/1632) by [chylex](https://github.com/chylex): Fix pumvisible returning opposite result
* [1615](https://github.com/JetBrains/ideavim/pull/1615) by [1grzyb1](https://github.com/1grzyb1): Fix IndexOutOfBoundsException in findBlock when caret is at end of file
* [1613](https://github.com/JetBrains/ideavim/pull/1613) by [1grzyb1](https://github.com/1grzyb1): VIM-3473 Sync ideavim in remdev
* [1608](https://github.com/JetBrains/ideavim/pull/1608) by [1grzyb1](https://github.com/1grzyb1): VIM-4134 format using = action in split mode
* [1585](https://github.com/JetBrains/ideavim/pull/1585) by [1grzyb1](https://github.com/1grzyb1): Break in case of maximum recursion depth
* [1414](https://github.com/JetBrains/ideavim/pull/1414) by [Matt Ellis](https://github.com/citizenmatt): Refactor/functions * [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 * [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 ## 2.28.0, 2025-12-09

View File

@@ -241,6 +241,24 @@ ShowHoverInfo - Quick Documentation and Error Description
QuickImplementations - Quick Definition 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 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 [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 [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 VIM LICENSE

View File

@@ -27,11 +27,11 @@ buildscript {
classpath("org.eclipse.jgit:org.eclipse.jgit.ssh.apache:7.6.0.202603022253-r") classpath("org.eclipse.jgit:org.eclipse.jgit.ssh.apache:7.6.0.202603022253-r")
classpath("org.kohsuke:github-api:1.305") classpath("org.kohsuke:github-api:1.305")
classpath("io.ktor:ktor-client-core:3.4.2") classpath("io.ktor:ktor-client-core:3.4.3")
classpath("io.ktor:ktor-client-cio:3.4.2") classpath("io.ktor:ktor-client-cio:3.4.3")
classpath("io.ktor:ktor-client-auth:3.4.2") classpath("io.ktor:ktor-client-auth:3.4.3")
classpath("io.ktor:ktor-client-content-negotiation:3.4.2") classpath("io.ktor:ktor-client-content-negotiation:3.4.3")
classpath("io.ktor:ktor-serialization-kotlinx-json:3.4.2") classpath("io.ktor:ktor-serialization-kotlinx-json:3.4.3")
// This comes from the changelog plugin // This comes from the changelog plugin
// classpath("org.jetbrains:markdown:0.3.1") // classpath("org.jetbrains:markdown:0.3.1")
@@ -228,7 +228,7 @@ tasks {
val runPycharm by intellijPlatformTesting.runIde.registering { val runPycharm by intellijPlatformTesting.runIde.registering {
type = IntelliJPlatformType.PyCharmProfessional type = IntelliJPlatformType.PyCharmProfessional
version = "2025.3.2" version = "2026.1"
task { task {
systemProperty("octopus.handler", System.getProperty("octopus.handler") ?: true) systemProperty("octopus.handler", System.getProperty("octopus.handler") ?: true)
} }
@@ -244,13 +244,14 @@ tasks {
val runClion by intellijPlatformTesting.runIde.registering { val runClion by intellijPlatformTesting.runIde.registering {
type = IntelliJPlatformType.CLion type = IntelliJPlatformType.CLion
version = "2025.3.2" version = "2026.1"
task { task {
systemProperty("octopus.handler", System.getProperty("octopus.handler") ?: true) systemProperty("octopus.handler", System.getProperty("octopus.handler") ?: true)
} }
} }
val runIdeForUiTests by intellijPlatformTesting.runIde.registering { val runIdeForUiTests by intellijPlatformTesting.runIde.registering {
version = "2026.1"
task { task {
jvmArgumentProviders += CommandLineArgumentProvider { jvmArgumentProviders += CommandLineArgumentProvider {
listOf( listOf(
@@ -313,7 +314,7 @@ tasks {
} }
val runPycharmSplitMode by intellijPlatformTesting.runIde.registering { val runPycharmSplitMode by intellijPlatformTesting.runIde.registering {
type = IntelliJPlatformType.PyCharmProfessional type = IntelliJPlatformType.PyCharmProfessional
version = "2025.3.2" version = "2026.1"
splitMode = true splitMode = true
splitModeTarget = SplitModeAware.SplitModeTarget.BOTH 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 { val testIdeSplitMode by intellijPlatformTesting.testIde.registering {
splitMode = true splitMode = true
splitModeTarget = SplitModeAware.SplitModeTarget.BOTH splitModeTarget = SplitModeAware.SplitModeTarget.BOTH
@@ -434,6 +474,8 @@ intellijPlatform {
changeNotes.set( changeNotes.set(
""" """
<b>Features:</b><br> <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> * 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 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> * <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,18 +484,71 @@ 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>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>zf</code> command - create fold from selection or motion<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-566">VIM-566</a> Added support for <code>:set foldlevel</code> option - control fold visibility level<br> * <a href="https://youtrack.jetbrains.com/issue/VIM-566">VIM-566</a> Added support for <code>:set foldlevel</code> option - control fold visibility level<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-1158">VIM-1158</a> Added <code>gw</code> command - reformat code like <code>gq</code> but preserving the cursor position<br>
<br> <br>
<b>Fixes:</b><br> <b>Fixes:</b><br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4197">VIM-4197</a> Fixed Vim features (e.g., <code>f</code>, <code>w</code>, text objects) not working in Java files decompiled from Kotlin class files<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4112">VIM-4112</a> Fixed undo after block-visual edit (<code>&lt;C-V&gt;...x</code>, <code>&lt;C-V&gt;...c</code>, <code>&lt;C-V&gt;...I</code>) leaving stray carets in normal mode<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4176">VIM-4176</a> Fixed race condition in single-line output panel that could cause <code>*</code> search wrapping to behave unreliably<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4175">VIM-4175</a> Fixed search "not found" showing previous "Hit ENTER" text alongside the error - panel is now cleared before displaying errors like "E486: Pattern not found"<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4135">VIM-4135</a> Fixed IdeaVim not loading in Rider<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4134">VIM-4134</a> Fixed undo in commentary - <code>gcc</code>/<code>gc{motion}</code> changes are now properly grouped as a single undo step<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4134">VIM-4134</a> Fixed <code>=</code> (format/auto-indent) action in split mode<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4134">VIM-4134</a> Fixed global marks causing errors when used inside write actions (e.g., during document modifications)<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4105">VIM-4105</a> Fixed <code>a"</code> <code>a'</code> <code>a`</code> text objects to include surrounding whitespace per Vim spec<br> * <a href="https://youtrack.jetbrains.com/issue/VIM-4105">VIM-4105</a> Fixed <code>a"</code> <code>a'</code> <code>a`</code> text objects to include surrounding whitespace per Vim spec<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4097">VIM-4097</a> Fixed <code>&lt;A-n&gt;</code> (NextOccurrence) with text containing backslashes - e.g., selecting <code>\IntegerField</code> now works correctly<br> * <a href="https://youtrack.jetbrains.com/issue/VIM-4097">VIM-4097</a> Fixed <code>&lt;A-n&gt;</code> (NextOccurrence) with text containing backslashes - e.g., selecting <code>\IntegerField</code> now works correctly<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4094">VIM-4094</a> Fixed UninitializedPropertyAccessException when loading history<br> * <a href="https://youtrack.jetbrains.com/issue/VIM-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-3948">VIM-3948</a> Improved hint generation visibility checks for better UI component detection<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4195">VIM-4195</a> Fixed settings not being saved in remote development (split) mode<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-3473">VIM-3473</a> Fixed "Reload .ideavimrc" action in remote development (split) mode - no longer causes File Cache Conflict dialogs<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-2821">VIM-2821</a> Fixed undo grouping when repeating text insertion with <code>.</code> in remote development (split mode)<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-1705">VIM-1705</a> Fixed window-switching commands (e.g., <code>&lt;C-w&gt;h</code>) during macro playback<br>
* Fixed <code>pumvisible()</code> function returning incorrect result (was inverted)<br>
* Fixed <code>&lt;Esc&gt;</code> not properly exiting insert mode in Rider/CLion when canceling a completion lookup<br>
* Fixed <code>&lt;Esc&gt;</code> not exiting insert mode after <code>&lt;C-Space&gt;</code> completion in Rider<br>
* Fixed <code>&lt;Esc&gt;</code> in search bar no longer inserts <code>^[</code> literal text when search is not found - panel is now properly closed<br>
* Fixed IdeaVim entering broken state when a VimScript extension plugin fails to initialize<br>
* Fixed compatibility issues with external plugins (e.g., IdeaVim-EasyMotion, multicursor)<br>
* Fixed recursive key mappings (e.g., <code>map b wbb</code>) causing an apparent infinite loop - <code>maxmapdepth</code> limit now properly terminates the entire mapping chain<br>
* Fixed NERDTree <code>gs</code>/<code>gi</code> preview split commands to keep focus on the tree<br>
* Fixed visual marks (<code>&lt;</code> and <code>&gt;</code>) position tracking after text deletion - <code>gv</code> now re-selects correctly<br>
* Fixed <code>IndexOutOfBoundsException</code> when using text objects like <code>a)</code> at end of file<br>
* Fixed high CPU usage while showing command line<br> * Fixed high CPU usage while showing command line<br>
* Fixed comparison of String and Number in VimScript expressions<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> <br>
<b>Merged PRs:</b><br> <b>Merged PRs:</b><br>
* <a href="https://github.com/JetBrains/ideavim/pull/1704">1704</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: VIM-4202 Don't intercept all &lt;S-Tab&gt;<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1703">1703</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: VIM-4202 Fix state after commentary action<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1700">1700</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: VIM-4139 Compute nesting depth for fold regions<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1699">1699</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: VIM-4112 collapse restored carets after undo of block-visual edit<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1696">1696</a> by <a href="https://github.com/citizenmatt">citizenmatt</a>: VIM-4197 Fix missing Vim features in Java files decompiled from Kotlin class files<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1695">1695</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: VIM-1693 Implement autocmd<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1690">1690</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: Make nerdtree work without calling backend actions<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1688">1688</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: VIM-4172 Disable ideavim in Python Console<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1687">1687</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: Restore old VimPLugin method signatures<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1685">1685</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: VIM-3727 Fix Python console Enter and arrow keys in split mode<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1548">1548</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: VIM-1158 Add <code>gw</code> to reformat code with preserving the cursor position<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1682">1682</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: VIM-268 Complete file names in edit command<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1632">1632</a> by <a href="https://github.com/chylex">chylex</a>: Fix pumvisible returning opposite result<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1615">1615</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: Fix IndexOutOfBoundsException in findBlock when caret is at end of file<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1613">1613</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: VIM-3473 Sync ideavim in remdev<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1608">1608</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: VIM-4134 format using = action in split mode<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1585">1585</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: Break in case of maximum recursion depth<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1414">1414</a> by <a href="https://github.com/citizenmatt">Matt Ellis</a>: Refactor/functions<br> * <a href="https://github.com/JetBrains/ideavim/pull/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/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> <br>
<a href="https://youtrack.jetbrains.com/issues/VIM?q=State:%20Fixed%20Fix%20versions:%20${version.get()}">Changelog</a> <a href="https://youtrack.jetbrains.com/issues/VIM?q=State:%20Fixed%20Fix%20versions:%20${version.get()}">Changelog</a>
""".trimIndent() """.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 # Values for type: https://plugins.jetbrains.com/docs/intellij/tools-gradle-intellij-plugin.html#intellij-extension-type
ideaType=IU ideaType=IU
instrumentPluginCode=true instrumentPluginCode=true
version=chylex-56 version=9999.58-chylex
javaVersion=21 javaVersion=21
remoteRobotVersion=0.11.23 remoteRobotVersion=0.11.23
antlrVersion=4.10.1 antlrVersion=4.10.1

View File

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

View File

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

View File

@@ -26,11 +26,11 @@ dependencies {
testImplementation("org.junit.jupiter:junit-jupiter:6.0.3") testImplementation("org.junit.jupiter:junit-jupiter:6.0.3")
testRuntimeOnly("org.junit.platform:junit-platform-launcher") testRuntimeOnly("org.junit.platform:junit-platform-launcher")
implementation("io.ktor:ktor-client-core:3.4.2") implementation("io.ktor:ktor-client-core:3.4.3")
implementation("io.ktor:ktor-client-cio:3.4.2") implementation("io.ktor:ktor-client-cio:3.4.3")
implementation("io.ktor:ktor-client-content-negotiation:3.4.2") implementation("io.ktor:ktor-client-content-negotiation:3.4.3")
implementation("io.ktor:ktor-serialization-kotlinx-json:3.4.2") implementation("io.ktor:ktor-serialization-kotlinx-json:3.4.3")
implementation("io.ktor:ktor-client-auth:3.4.2") implementation("io.ktor:ktor-client-auth:3.4.3")
implementation("org.eclipse.jgit:org.eclipse.jgit:6.6.0.202305301015-r") implementation("org.eclipse.jgit:org.eclipse.jgit:6.6.0.202305301015-r")
// This is needed for jgit to connect to ssh // 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) // 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) .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 // Unregister vim actions in command mode
RegisterActions.unregisterActions() RegisterActions.unregisterActions()

View File

@@ -22,12 +22,11 @@ import com.intellij.openapi.util.Disposer;
import com.maddyhome.idea.vim.api.*; import com.maddyhome.idea.vim.api.*;
import com.maddyhome.idea.vim.config.VimState; import com.maddyhome.idea.vim.config.VimState;
import com.maddyhome.idea.vim.config.migration.ApplicationConfigurationMigrator; import com.maddyhome.idea.vim.config.migration.ApplicationConfigurationMigrator;
import com.maddyhome.idea.vim.group.KeyGroup; import com.maddyhome.idea.vim.group.*;
import com.maddyhome.idea.vim.group.VimNotifications; import com.maddyhome.idea.vim.group.visual.VisualMotionGroup;
import com.maddyhome.idea.vim.group.VimWindowGroup;
import com.maddyhome.idea.vim.history.VimHistory; 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.IjVimInjectorKt;
import com.maddyhome.idea.vim.newapi.IjVimSearchGroup;
import com.maddyhome.idea.vim.newapi.VimLegacyStateLoader; import com.maddyhome.idea.vim.newapi.VimLegacyStateLoader;
import com.maddyhome.idea.vim.newapi.VimSearchGroupLegacyLoader; import com.maddyhome.idea.vim.newapi.VimSearchGroupLegacyLoader;
import com.maddyhome.idea.vim.put.VimPut; import com.maddyhome.idea.vim.put.VimPut;
@@ -47,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. * 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. * 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 class VimPlugin implements PersistentStateComponent<Element>, Disposable {
public static final int STATE_VERSION = 7; public static final int STATE_VERSION = 7;
@@ -86,49 +85,48 @@ public class VimPlugin implements PersistentStateComponent<Element>, Disposable
} }
public static @NotNull VimMotionGroup getMotion() { public static @NotNull MotionGroup getMotion() {
return VimInjectorKt.getInjector().getMotion(); return ((MotionGroup)VimInjectorKt.getInjector().getMotion());
} }
public static @NotNull VimChangeGroup getChange() { public static @NotNull ChangeGroup getChange() {
return VimInjectorKt.getInjector().getChangeGroup(); return ((ChangeGroup)VimInjectorKt.getInjector().getChangeGroup());
} }
public static @NotNull VimCommandGroup getCommand() { public static @NotNull CommandGroup getCommand() {
return VimInjectorKt.getInjector().getCommandGroup(); return ((CommandGroup)VimInjectorKt.getInjector().getCommandGroup());
} }
public static @NotNull VimRegisterGroup getRegister() { public static @NotNull RegisterGroup getRegister() {
return VimInjectorKt.getInjector().getRegisterGroup(); return ((RegisterGroup)VimInjectorKt.getInjector().getRegisterGroup());
} }
public static @NotNull VimFile getFile() { public static @NotNull VimFile getFile() {
return VimInjectorKt.getInjector().getFile(); return VimInjectorKt.getInjector().getFile();
} }
public static @NotNull VimSearchGroup getSearch() { public static @NotNull IjVimSearchGroup getSearch() {
return VimInjectorKt.getInjector().getSearchGroup(); return ((IjVimSearchGroup)VimInjectorKt.getInjector().getSearchGroup());
} }
public static @Nullable VimSearchGroup getSearchIfCreated() { public static @Nullable IjVimSearchGroup getSearchIfCreated() {
VimSearchGroup searchGroup = ApplicationManager.getApplication().getServiceIfCreated(VimSearchGroup.class); return ApplicationManager.getApplication().getServiceIfCreated(IjVimSearchGroup.class);
return searchGroup;
} }
public static @NotNull VimProcessGroup getProcess() { public static @NotNull VimProcessGroup getProcess() {
return VimInjectorKt.getInjector().getProcessGroup(); return VimInjectorKt.getInjector().getProcessGroup();
} }
public static @NotNull VimMacro getMacro() { public static @NotNull MacroGroup getMacro() {
return VimInjectorKt.getInjector().getMacro(); return ((MacroGroup)VimInjectorKt.getInjector().getMacro());
} }
public static @NotNull VimDigraphGroup getDigraph() { public static @NotNull VimDigraphGroup getDigraph() {
return VimInjectorKt.getInjector().getDigraphGroup(); return VimInjectorKt.getInjector().getDigraphGroup();
} }
public static @NotNull VimHistory getHistory() { public static @NotNull HistoryGroup getHistory() {
return VimInjectorKt.getInjector().getHistoryGroup(); return ((HistoryGroup)VimInjectorKt.getInjector().getHistoryGroup());
} }
public static @NotNull KeyGroup getKey() { public static @NotNull KeyGroup getKey() {
@@ -139,20 +137,20 @@ public class VimPlugin implements PersistentStateComponent<Element>, Disposable
return ApplicationManager.getApplication().getServiceIfCreated(KeyGroup.class); return ApplicationManager.getApplication().getServiceIfCreated(KeyGroup.class);
} }
public static @NotNull VimWindowGroup getWindow() { public static @NotNull WindowGroup getWindow() {
return VimInjectorKt.getInjector().getWindow(); return ((WindowGroup)VimInjectorKt.getInjector().getWindow());
} }
public static @NotNull VimEditorGroup getEditor() { public static @NotNull EditorGroup getEditor() {
return VimInjectorKt.getInjector().getEditorGroup(); return ((EditorGroup)VimInjectorKt.getInjector().getEditorGroup());
} }
public static @Nullable VimEditorGroup getEditorIfCreated() { public static @Nullable EditorGroup getEditorIfCreated() {
return ApplicationManager.getApplication().getServiceIfCreated(VimEditorGroup.class); return ApplicationManager.getApplication().getServiceIfCreated(EditorGroup.class);
} }
public static @NotNull VimVisualMotionGroup getVisualMotion() { public static @NotNull VisualMotionGroup getVisualMotion() {
return VimInjectorKt.getInjector().getVisualMotionGroup(); return ((VisualMotionGroup)VimInjectorKt.getInjector().getVisualMotionGroup());
} }
public static @NotNull YankGroupBase getYank() { public static @NotNull YankGroupBase getYank() {

View File

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

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-2026 The IdeaVim authors * Copyright 2003-2023 The IdeaVim authors
* *
* Use of this source code is governed by an MIT-style * Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at * license that can be found in the LICENSE.txt file or at
@@ -7,19 +7,74 @@
*/ */
package com.maddyhome.idea.vim.extension.argtextobj package com.maddyhome.idea.vim.extension.argtextobj
import com.intellij.vim.api.VimApi import com.intellij.openapi.editor.Document
import com.intellij.vim.api.VimInitApi import com.maddyhome.idea.vim.KeyHandler.Companion.getInstance
import com.intellij.vim.api.scopes.TextObjectRange
import com.maddyhome.idea.vim.VimPlugin import com.maddyhome.idea.vim.VimPlugin
import com.maddyhome.idea.vim.api.ExecutionContext
import com.maddyhome.idea.vim.api.ImmutableVimCaret
import com.maddyhome.idea.vim.api.VimCaret
import com.maddyhome.idea.vim.api.VimEditor
import com.maddyhome.idea.vim.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.VimExtension
import com.maddyhome.idea.vim.extension.VimExtensionFacade.putExtensionHandlerMapping
import com.maddyhome.idea.vim.extension.VimExtensionFacade.putKeyMappingIfMissing
import com.maddyhome.idea.vim.group.visual.vimSetSelection
import com.maddyhome.idea.vim.handler.TextObjectActionHandler
import com.maddyhome.idea.vim.helper.MessageHelper import com.maddyhome.idea.vim.helper.MessageHelper
import com.maddyhome.idea.vim.helper.VimNlsSafe import com.maddyhome.idea.vim.helper.VimNlsSafe
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 com.maddyhome.idea.vim.vimscript.model.datatypes.VimString import com.maddyhome.idea.vim.vimscript.model.datatypes.VimString
import org.jetbrains.annotations.Nls import org.jetbrains.annotations.Nls
import java.util.* import java.util.*
import java.util.function.Consumer
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
class VimArgTextObjExtension : VimExtension {
override fun getName(): String = "argtextobj"
override fun init() {
putExtensionHandlerMapping(
MappingMode.XO,
injector.parser.parseKeys("<Plug>InnerArgument"),
owner,
ArgumentHandler(true),
false
)
putExtensionHandlerMapping(
MappingMode.XO,
injector.parser.parseKeys("<Plug>OuterArgument"),
owner,
ArgumentHandler(false),
false
)
putKeyMappingIfMissing(
MappingMode.XO,
injector.parser.parseKeys("ia"),
owner,
injector.parser.parseKeys("<Plug>InnerArgument"),
true
)
putKeyMappingIfMissing(
MappingMode.XO,
injector.parser.parseKeys("aa"),
owner,
injector.parser.parseKeys("<Plug>OuterArgument"),
true
)
}
/** /**
* The pairs of brackets that delimit different types of argument lists. * The pairs of brackets that delimit different types of argument lists.
*/ */
@@ -116,17 +171,82 @@ private class BracketPairs(openBrackets: String, closeBrackets: String) {
} }
} }
class VimArgTextObjExtension : VimExtension { /**
override fun getName(): String = "argtextobj" * A text object for an argument to a function definition or a call.
*/
internal class ArgumentHandler(val isInner: Boolean) : ExtensionHandler {
override val isRepeatable: Boolean
get() = false
override fun init(initApi: VimInitApi) { internal class ArgumentTextObjectHandler(private val isInner: Boolean) : TextObjectActionHandler() {
initApi.textObjects { override fun getRange(
register("ia", preserveSelectionAnchor = false) { count -> editor: VimEditor,
findArgumentRange(isInner = true, count) caret: ImmutableVimCaret,
context: ExecutionContext,
count: Int,
rawCount: Int
): TextRange? {
var bracketPairs: BracketPairs = Util.DEFAULT_BRACKET_PAIRS
val bracketPairsVar: String? = Util.bracketPairsVariable()
if (bracketPairsVar != null) {
try {
bracketPairs = BracketPairs.fromBracketPairList(bracketPairsVar)
} catch (parseException: BracketPairs.ParseException) {
@VimNlsSafe val message =
MessageHelper.message("argtextobj.error.invalid.value.of.g.argtextobj.pairs.0", parseException.message!!)
VimPlugin.showMessage(message)
VimPlugin.indicateError()
return null
} }
register("aa", preserveSelectionAnchor = false) { count ->
findArgumentRange(isInner = false, count)
} }
val finder = ArgBoundsFinder((editor as IjVimEditor).editor.document, bracketPairs)
var pos = (caret as IjVimCaret).caret.offset
for (i in 0..<count) {
if (!finder.findBoundsAt(pos)) {
VimPlugin.showMessage(finder.errorMessage())
VimPlugin.indicateError()
return null
}
if (i + 1 < count) {
finder.extendTillNext()
}
pos = finder.rightBound
}
if (isInner) {
finder.adjustForInner()
} else {
finder.adjustForOuter()
}
return TextRange(finder.leftBound, finder.rightBound)
}
override val visualType: TextObjectVisualType
get() = TextObjectVisualType.CHARACTER_WISE
}
override fun execute(editor: VimEditor, context: ExecutionContext, operatorArguments: OperatorArguments) {
val keyHandlerState = getInstance().keyHandlerState
val textObjectHandler = ArgumentTextObjectHandler(isInner)
if (editor.mode !is OP_PENDING) {
val count0 = operatorArguments.count0
editor.nativeCarets().forEach(Consumer { caret: VimCaret ->
val range = textObjectHandler.getRange(editor, caret, context, max(1, count0), count0)
if (range != null) {
SelectionVimListenerSuppressor.lock().use { _ ->
if (editor.mode is VISUAL) {
caret.vimSetSelection(range.startOffset, range.endOffset - 1, true)
} else {
(caret as IjVimCaret).caret.moveToInlayAwareOffset(range.startOffset)
}
}
}
})
} else {
keyHandlerState.commandBuilder.addAction(textObjectHandler)
} }
} }
} }
@@ -135,11 +255,8 @@ class VimArgTextObjExtension : VimExtension {
* Helper class to find argument boundaries starting at the specified * Helper class to find argument boundaries starting at the specified
* position * position
*/ */
private class ArgBoundsFinder( private class ArgBoundsFinder(private val document: Document, private val brackets: BracketPairs) {
private val text: CharSequence, private val text: CharSequence = document.immutableCharSequence
private val api: VimApi,
private val brackets: BracketPairs,
) {
var leftBound: Int = Int.MAX_VALUE var leftBound: Int = Int.MAX_VALUE
private set private set
var rightBound: Int = Int.MIN_VALUE var rightBound: Int = Int.MIN_VALUE
@@ -150,12 +267,6 @@ private class ArgBoundsFinder(
@Nls @Nls
private var error: @Nls String? = null private var error: @Nls String? = null
// Line info methods that call VimApi directly
private suspend fun getLineNumber(offset: Int) = api.editor { read { getLine(offset).number } }
private suspend fun getLineStartOffset(lineNo: Int) = api.editor { read { getLineStartOffset(lineNo) } }
private suspend fun getLineEndOffset(lineNo: Int) = api.editor { read { getLineEndOffset(lineNo, true) } }
private suspend fun getLineCount() = api.editor { read { lineCount } }
/** /**
* Finds left and right boundaries of an argument at the specified * Finds left and right boundaries of an argument at the specified
* position. If successful @ref getLeftBound() will point to the left * position. If successful @ref getLeftBound() will point to the left
@@ -166,14 +277,14 @@ private class ArgBoundsFinder(
* @param position starting position. * @param position starting position.
*/ */
@Throws(IllegalStateException::class) @Throws(IllegalStateException::class)
suspend fun findBoundsAt(position: Int): Boolean { fun findBoundsAt(position: Int): Boolean {
if (text.isEmpty()) { if (text.isEmpty()) {
error = "empty document" error = "empty document"
return false return false
} }
leftBound = min(position, leftBound) leftBound = min(position, leftBound)
rightBound = max(position, rightBound) rightBound = max(position, rightBound)
this.adjustForQuotedText() this.outOfQuotedText
if (rightBound == leftBound) { if (rightBound == leftBound) {
if (brackets.isCloseBracket(getCharAt(rightBound).code)) { if (brackets.isCloseBracket(getCharAt(rightBound).code)) {
--leftBound --leftBound
@@ -264,17 +375,18 @@ private class ArgBoundsFinder(
return (idEnd - i) > 0 && Character.isJavaIdentifierStart(getCharAt(i + 1)) return (idEnd - i) > 0 && Character.isJavaIdentifierStart(getCharAt(i + 1))
} }
val outOfQuotedText: Unit
/** /**
* Detects if current position is inside a quoted string and adjusts * Detects if current position is inside a quoted string and adjusts
* left and right bounds to the boundaries of the string. * left and right bounds to the boundaries of the string.
* *
* NOTE: Does not support line continuations for quoted string ('\' at the end of line). * NOTE: Does not support line continuations for quoted string ('\' at the end of line).
*/ */
suspend fun adjustForQuotedText() { get() {
// TODO this method should use IdeaVim methods to determine if the current position is in the string // TODO this method should use IdeaVim methods to determine if the current position is in the string
val lineNo = getLineNumber(leftBound) val lineNo = document.getLineNumber(leftBound)
val lineStartOffset = getLineStartOffset(lineNo) val lineStartOffset = document.getLineStartOffset(lineNo)
val lineEndOffset = getLineEndOffset(lineNo) val lineEndOffset = document.getLineEndOffset(lineNo)
var i = lineStartOffset var i = lineStartOffset
while (i <= leftBound) { while (i <= leftBound) {
if (isQuote(i)) { if (isQuote(i)) {
@@ -291,7 +403,7 @@ private class ArgBoundsFinder(
} }
} }
private fun findRightBound() { fun findRightBound() {
while (rightBound < rightBracket) { while (rightBound < rightBracket) {
val ch = getCharAt(rightBound) val ch = getCharAt(rightBound)
if (ch == ',') { if (ch == ',') {
@@ -308,7 +420,7 @@ private class ArgBoundsFinder(
} }
} }
private fun findLeftBound() { fun findLeftBound() {
while (leftBound > leftBracket) { while (leftBound > leftBracket) {
val ch = getCharAt(leftBound) val ch = getCharAt(leftBound)
if (ch == ',') { if (ch == ',') {
@@ -325,19 +437,17 @@ private class ArgBoundsFinder(
} }
} }
private fun isQuote(i: Int): Boolean { fun isQuote(i: Int): Boolean {
return QUOTES.indexOf(getCharAt(i)) != -1 return QUOTES.indexOf(getCharAt(i)) != -1
} }
private fun getCharAt(logicalOffset: Int): Char { fun getCharAt(logicalOffset: Int): Char {
require(logicalOffset >= 0 && logicalOffset < text.length) { assert(logicalOffset < text.length)
"Offset $logicalOffset out of bounds [0, ${text.length})"
}
return text[logicalOffset] return text[logicalOffset]
} }
private fun skipQuotedTextForward(start: Int, end: Int): Int { fun skipQuotedTextForward(start: Int, end: Int): Int {
require(start < end) { "start ($start) must be less than end ($end)" } assert(start < end)
val quoteChar = getCharAt(start) val quoteChar = getCharAt(start)
var backSlash = false var backSlash = false
var i = start + 1 var i = start + 1
@@ -355,8 +465,8 @@ private class ArgBoundsFinder(
return i return i
} }
private fun skipQuotedTextBackward(start: Int, end: Int): Int { fun skipQuotedTextBackward(start: Int, end: Int): Int {
require(start > end) { "start ($start) must be greater than end ($end)" } assert(start > end)
val quoteChar = getCharAt(start) val quoteChar = getCharAt(start)
var i = start - 1 var i = start - 1
@@ -374,17 +484,17 @@ private class ArgBoundsFinder(
return i return i
} }
private suspend fun leftLimit(pos: Int): Int { fun leftLimit(pos: Int): Int {
val offsetLimit = max(pos - MAX_SEARCH_OFFSET, 0) val offsetLimit = max(pos - MAX_SEARCH_OFFSET, 0)
val lineNo = getLineNumber(pos) val lineNo = document.getLineNumber(pos)
val lineOffsetLimit = getLineStartOffset(max(0, lineNo - MAX_SEARCH_LINES)) val lineOffsetLimit = document.getLineStartOffset(max(0, lineNo - MAX_SEARCH_LINES))
return max(offsetLimit, lineOffsetLimit) return max(offsetLimit, lineOffsetLimit)
} }
private suspend fun rightLimit(pos: Int): Int { fun rightLimit(pos: Int): Int {
val offsetLimit = min(pos + MAX_SEARCH_OFFSET, text.length) val offsetLimit = min(pos + MAX_SEARCH_OFFSET, text.length)
val lineNo = getLineNumber(pos) val lineNo = document.getLineNumber(pos)
val lineOffsetLimit = getLineEndOffset(min(getLineCount() - 1, lineNo + MAX_SEARCH_LINES)) val lineOffsetLimit = document.getLineEndOffset(min(document.lineCount - 1, lineNo + MAX_SEARCH_LINES))
return min(offsetLimit, lineOffsetLimit) return min(offsetLimit, lineOffsetLimit)
} }
@@ -456,7 +566,7 @@ private class ArgBoundsFinder(
* @return position after the S-expression, or the next to the start position if * @return position after the S-expression, or the next to the start position if
* unbalanced. * unbalanced.
*/ */
private fun skipSexp(start: Int, end: Int, dir: SexpDirection): Int { fun skipSexp(start: Int, end: Int, dir: SexpDirection): Int {
val lastChar = getCharAt(start) val lastChar = getCharAt(start)
assert(dir.isOpenBracket(lastChar)) assert(dir.isOpenBracket(lastChar))
val bracketStack: Deque<Char?> = ArrayDeque<Char?>() val bracketStack: Deque<Char?> = ArrayDeque<Char?>()
@@ -503,10 +613,8 @@ private class ArgBoundsFinder(
* @param end maximum position * @param end maximum position
* @return true if found * @return true if found
*/ */
private fun findOuterBrackets(start: Int, end: Int): Boolean { fun findOuterBrackets(start: Int, end: Int): Boolean {
val foundPrev = findPrevOpenBracket(start) var hasNewBracket = findPrevOpenBracket(start) && findNextCloseBracket(end)
val foundNext = if (foundPrev) findNextCloseBracket(end) else false
var hasNewBracket = foundPrev && foundNext
while (hasNewBracket) { while (hasNewBracket) {
val leftPrio = brackets.getBracketPrio(getCharAt(leftBracket)) val leftPrio = brackets.getBracketPrio(getCharAt(leftBracket))
val rightPrio = brackets.getBracketPrio(getCharAt(rightBracket)) val rightPrio = brackets.getBracketPrio(getCharAt(rightBracket))
@@ -540,7 +648,7 @@ private class ArgBoundsFinder(
* @param start minimum position. * @param start minimum position.
* @return true if found * @return true if found
*/ */
private fun findPrevOpenBracket(start: Int): Boolean { fun findPrevOpenBracket(start: Int): Boolean {
var ch: Char var ch: Char
while (!brackets.isOpenBracket(getCharAt(leftBracket).also { ch = it }.code)) { while (!brackets.isOpenBracket(getCharAt(leftBracket).also { ch = it }.code)) {
if (brackets.isCloseBracket(ch.code)) { if (brackets.isCloseBracket(ch.code)) {
@@ -565,7 +673,7 @@ private class ArgBoundsFinder(
* @param end maximum position. * @param end maximum position.
* @return true if found * @return true if found
*/ */
private fun findNextCloseBracket(end: Int): Boolean { fun findNextCloseBracket(end: Int): Boolean {
var ch: Char var ch: Char
while (!brackets.isCloseBracket(getCharAt(rightBracket).also { ch = it }.code)) { while (!brackets.isCloseBracket(getCharAt(rightBracket).also { ch = it }.code)) {
if (brackets.isOpenBracket(ch.code)) { if (brackets.isOpenBracket(ch.code)) {
@@ -595,7 +703,7 @@ private class ArgBoundsFinder(
} }
} }
private object ArgTextObjUtil { private object Util {
val DEFAULT_BRACKET_PAIRS = BracketPairs("(", ")") val DEFAULT_BRACKET_PAIRS = BracketPairs("(", ")")
fun bracketPairsVariable(): String? { fun bracketPairsVariable(): String? {
@@ -606,46 +714,4 @@ private object ArgTextObjUtil {
return null return null
} }
} }
/**
* Find argument range using the new VimApi.
*/
private suspend fun VimApi.findArgumentRange(isInner: Boolean, count: Int): TextObjectRange? {
var bracketPairs: BracketPairs = ArgTextObjUtil.DEFAULT_BRACKET_PAIRS
val bracketPairsVar: String? = ArgTextObjUtil.bracketPairsVariable()
if (bracketPairsVar != null) {
try {
bracketPairs = BracketPairs.fromBracketPairList(bracketPairsVar)
} catch (parseException: BracketPairs.ParseException) {
@VimNlsSafe val message =
MessageHelper.message("argtextobj.error.invalid.value.of.g.argtextobj.pairs.0", parseException.message ?: "")
VimPlugin.showMessage(message)
VimPlugin.indicateError()
return null
}
}
val (text, caretOffset) = editor { read { text to withPrimaryCaret { offset } } }
val finder = ArgBoundsFinder(text, this, bracketPairs)
var pos = caretOffset
for (i in 0..<count) {
if (!finder.findBoundsAt(pos)) {
VimPlugin.showMessage(finder.errorMessage())
VimPlugin.indicateError()
return null
}
if (i + 1 < count) {
finder.extendTillNext()
}
pos = finder.rightBound
}
if (isInner) {
finder.adjustForInner()
} else {
finder.adjustForOuter()
}
return TextObjectRange.CharacterWise(finder.leftBound, finder.rightBound)
} }

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 * Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at * license that can be found in the LICENSE.txt file or at
@@ -54,10 +54,13 @@ fun MutableMap<List<KeyStroke>, NerdTreeAction>.register(mapping: String, action
*/ */
val navigationMappings: Map<List<KeyStroke>, NerdTreeAction> = mutableMapOf<List<KeyStroke>, NerdTreeAction>().apply { val navigationMappings: Map<List<KeyStroke>, NerdTreeAction> = mutableMapOf<List<KeyStroke>, NerdTreeAction>().apply {
// TODO support going [count] lines upward/downward or to line [count] // TODO support going [count] lines upward/downward or to line [count]
register("k", NerdTreeAction.ij("Tree-selectPrevious")) // Delegate to JTree's Swing ActionMap (same path as native arrow keys via TreeAction/DefaultTreeUI).
register("j", NerdTreeAction.ij("Tree-selectNext")) // This avoids ActionManager.tryToExecute which can RPC to backend in split mode,
register("G", NerdTreeAction.ij("Tree-selectLast")) // while preserving platform features (separator skipping, cycle scrolling, loading node handling).
register("gg", NerdTreeAction.ij("Tree-selectFirst")) 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 // FIXME lazy loaded tree nodes are not expanded
register("NERDTreeMapOpenRecursively", "O", NerdTreeAction.ij("FullyExpandTreeNode")) register("NERDTreeMapOpenRecursively", "O", NerdTreeAction.ij("FullyExpandTreeNode"))
@@ -102,7 +105,7 @@ val navigationMappings: Map<List<KeyStroke>, NerdTreeAction> = mutableMapOf<List
tree.selectionPath = path tree.selectionPath = path
tree.scrollPathToVisible(path) tree.scrollPathToVisible(path)
}) })
register("NERDTreeMapJumpParent", "p", NerdTreeAction.ij("Tree-selectParentNoCollapse")) register("NERDTreeMapJumpParent", "p", NerdTreeAction.swing("selectParentNoCollapse"))
register( register(
"NERDTreeMapJumpFirstChild", "NERDTreeMapJumpFirstChild",
"K", "K",
@@ -129,8 +132,8 @@ val navigationMappings: Map<List<KeyStroke>, NerdTreeAction> = mutableMapOf<List
tree.scrollPathToVisible(path) tree.scrollPathToVisible(path)
}, },
) )
register("NERDTreeMapJumpNextSibling", "<C-J>", NerdTreeAction.ij("Tree-selectNextSibling")) register("NERDTreeMapJumpNextSibling", "<C-J>", NerdTreeAction.swing("selectNextSibling"))
register("NERDTreeMapJumpPrevSibling", "<C-K>", NerdTreeAction.ij("Tree-selectPreviousSibling")) register("NERDTreeMapJumpPrevSibling", "<C-K>", NerdTreeAction.swing("selectPreviousSibling"))
register("/", NerdTreeAction.ij("SpeedSearch")) register("/", NerdTreeAction.ij("SpeedSearch"))
register("<ESC>", NerdTreeAction { _, _ -> }) 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 * Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at * license that can be found in the LICENSE.txt file or at
@@ -11,6 +11,7 @@ package com.maddyhome.idea.vim.extension.nerdtree
import com.intellij.openapi.actionSystem.ActionManager import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.ApplicationManager
import com.intellij.ui.SwingActionDelegate
import com.intellij.ui.treeStructure.Tree import com.intellij.ui.treeStructure.Tree
import com.maddyhome.idea.vim.VimPlugin import com.maddyhome.idea.vim.VimPlugin
import com.maddyhome.idea.vim.api.ExecutionContext 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. * @return An [NerdTreeAction] that runs the specified action when triggered.
*/ */
fun ij(id: String) = NerdTreeAction { event, _ -> callAction(null, id, event.dataContext.vim) } 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 package com.maddyhome.idea.vim.extension.replacewithregister
import com.intellij.vim.api.VimInitApi 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 import com.maddyhome.idea.vim.extension.VimExtension
internal class ReplaceWithRegister : VimExtension { internal class ReplaceWithRegister : VimExtension {
@@ -17,21 +19,15 @@ internal class ReplaceWithRegister : VimExtension {
override fun init(initApi: VimInitApi) { override fun init(initApi: VimInitApi) {
initApi.mappings { initApi.mappings {
// Step 1: Non-recursive <Plug> → action mappings nmapPluginAction("gr", RWR_OPERATOR, keepDefaultMapping = true) {
nnoremap(RWR_OPERATOR) {
rewriteMotion() rewriteMotion()
} }
nnoremap(RWR_LINE) { nmapPluginAction("grr", RWR_LINE, keepDefaultMapping = true) {
rewriteLine() rewriteLine()
} }
vnoremap(RWR_VISUAL) { vmapPluginAction("gr", RWR_VISUAL, keepDefaultMapping = true) {
rewriteVisual() rewriteVisual()
} }
// Step 2: Recursive key → <Plug> mappings
nmap("gr", RWR_OPERATOR)
nmap("grr", RWR_LINE)
vmap("gr", RWR_VISUAL)
} }
initApi.commands { 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.Range
import com.intellij.vim.api.models.TextType import com.intellij.vim.api.models.TextType
import com.intellij.vim.api.scopes.editor.caret.CaretTransaction 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" private const val PLUGIN_NAME: String = "ReplaceWithRegisterNew"
@VimPlugin(name = PLUGIN_NAME) @VimPlugin(name = PLUGIN_NAME)
fun VimInitApi.init() { fun VimInitApi.init() {
mappings { mappings {
// Step 1: Non-recursive <Plug> → action mappings nmapPluginAction("gr", RWR_OPERATOR, keepDefaultMapping = true) {
nnoremap(RWR_OPERATOR) {
rewriteMotion() rewriteMotion()
} }
nnoremap(RWR_LINE) { nmapPluginAction("grr", RWR_LINE, keepDefaultMapping = true) {
rewriteLine() rewriteLine()
} }
vnoremap(RWR_VISUAL) { vmapPluginAction("gr", RWR_VISUAL, keepDefaultMapping = true) {
rewriteVisual() rewriteVisual()
} }
// Step 2: Recursive key → <Plug> mappings
nmap("gr", RWR_OPERATOR)
nmap("grr", RWR_LINE)
vmap("gr", RWR_VISUAL)
} }
commands { 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 * Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at * license that can be found in the LICENSE.txt file or at
@@ -7,15 +7,36 @@
*/ */
package com.maddyhome.idea.vim.extension.textobjindent package com.maddyhome.idea.vim.extension.textobjindent
import com.intellij.vim.api.VimApi import com.intellij.openapi.editor.Caret
import com.intellij.vim.api.VimInitApi import com.maddyhome.idea.vim.KeyHandler.Companion.getInstance
import com.intellij.vim.api.scopes.TextObjectRange 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.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: * Port of vim-indent-object:
* [vim-indent-object](https://github.com/michaeljsmith/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: * vim-indent-object provides these text objects based on the cursor line's indentation:
* *
* * `ai` **A**n **I**ndentation level and line above. * * `ai` **A**n **I**ndentation level and line above.
@@ -23,30 +44,73 @@ import com.maddyhome.idea.vim.extension.VimExtension
* * `aI` **A**n **I**ndentation level and lines above and below. * * `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` * * `iI` **I**nner **I**ndentation level (no lines above and below). Synonym of `ii`
* *
*
* See also the reference manual for more details at: * 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) * [indent-object.txt](https://github.com/michaeljsmith/vim-indent-object/blob/master/doc/indent-object.txt)
*/ */
class VimIndentObject : VimExtension { 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) }
}
}
} }
private suspend fun VimApi.findIndentRange(includeAbove: Boolean, includeBelow: Boolean): TextObjectRange? { override fun init() {
val charSequence = editor { read { text } } putExtensionHandlerMapping(
val caretOffset = editor { read { withPrimaryCaret { offset } } } 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. // Part 1: Find the start of the caret line.
var caretLineStartOffset = caretOffset var caretLineStartOffset = caretOffset
var accumulatedWhitespace = 0 var accumulatedWhitespace = 0
while (--caretLineStartOffset >= 0) { while (--caretLineStartOffset >= 0) {
val ch = charSequence[caretLineStartOffset] val ch = charSequence.get(caretLineStartOffset)
if (ch == ' ' || ch == '\t') { if (ch == ' ' || ch == '\t') {
++accumulatedWhitespace ++accumulatedWhitespace
} else if (ch == '\n') { } else if (ch == '\n') {
@@ -67,7 +131,7 @@ private suspend fun VimApi.findIndentRange(includeAbove: Boolean, includeBelow:
var offset = caretLineStartOffset var offset = caretLineStartOffset
var indentSize = 0 var indentSize = 0
while (offset < charSequence.length) { while (offset < charSequence.length) {
val ch = charSequence[offset] val ch = charSequence.get(offset)
if (ch == ' ' || ch == '\t') { if (ch == ' ' || ch == '\t') {
++indentSize ++indentSize
++offset ++offset
@@ -84,7 +148,7 @@ private suspend fun VimApi.findIndentRange(includeAbove: Boolean, includeBelow:
while (upperBoundaryOffset == null) { while (upperBoundaryOffset == null) {
// 3.1: Going backwards from `caretLineStartOffset`, find the first non-whitespace character. // 3.1: Going backwards from `caretLineStartOffset`, find the first non-whitespace character.
while (--pos1 >= 0) { while (--pos1 >= 0) {
val ch = charSequence[pos1] val ch = charSequence.get(pos1)
if (ch != ' ' && ch != '\t' && ch != '\n') { if (ch != ' ' && ch != '\t' && ch != '\n') {
break break
} }
@@ -92,7 +156,7 @@ private suspend fun VimApi.findIndentRange(includeAbove: Boolean, includeBelow:
// 3.2: Find the indent size of the line with this non-whitespace character and check against `indentSize`. // 3.2: Find the indent size of the line with this non-whitespace character and check against `indentSize`.
accumulatedWhitespace = 0 accumulatedWhitespace = 0
while (--pos1 >= 0) { while (--pos1 >= 0) {
val ch = charSequence[pos1] val ch = charSequence.get(pos1)
if (ch == ' ' || ch == '\t') { if (ch == ' ' || ch == '\t') {
++accumulatedWhitespace ++accumulatedWhitespace
} else if (ch == '\n') { } else if (ch == '\n') {
@@ -115,28 +179,28 @@ private suspend fun VimApi.findIndentRange(includeAbove: Boolean, includeBelow:
// Now `upperBoundaryOffset` marks the beginning of an `ai` text object. // Now `upperBoundaryOffset` marks the beginning of an `ai` text object.
if (isUpperBoundaryFound && !includeAbove) { if (isUpperBoundaryFound && !includeAbove) {
while (++upperBoundaryOffset < charSequence.length) { while (++upperBoundaryOffset < charSequence.length) {
val ch = charSequence[upperBoundaryOffset] val ch = charSequence.get(upperBoundaryOffset)
if (ch == '\n') { if (ch == '\n') {
++upperBoundaryOffset ++upperBoundaryOffset
break break
} }
} }
while (charSequence[upperBoundaryOffset] == '\n') { while (charSequence.get(upperBoundaryOffset) == '\n') {
++upperBoundaryOffset ++upperBoundaryOffset
} }
} }
// Part 4: Find the end of the caret line. // Part 4: Find the start of the caret line.
var caretLineEndOffset = caretOffset var caretLineEndOffset = caretOffset
while (++caretLineEndOffset < charSequence.length) { while (++caretLineEndOffset < charSequence.length) {
val ch = charSequence[caretLineEndOffset] val ch = charSequence.get(caretLineEndOffset)
if (ch == '\n') { if (ch == '\n') {
++caretLineEndOffset ++caretLineEndOffset
break break
} }
} }
// `caretLineEndOffset` points to the first character in the line below caret line. // `caretLineEndOffset` points to the first charater in the line below caret line.
var lowerBoundaryOffset: Int? = null var lowerBoundaryOffset: Int? = null
// Part 5: Find a line below the caret line, that has an indentation lower than `indentSize`. // Part 5: Find a line below the caret line, that has an indentation lower than `indentSize`.
var pos2 = caretLineEndOffset - 1 var pos2 = caretLineEndOffset - 1
@@ -146,7 +210,7 @@ private suspend fun VimApi.findIndentRange(includeAbove: Boolean, includeBelow:
var lastNewlinePos = caretLineEndOffset - 1 var lastNewlinePos = caretLineEndOffset - 1
var isInIndent = true var isInIndent = true
while (++pos2 < charSequence.length) { while (++pos2 < charSequence.length) {
val ch = charSequence[pos2] val ch = charSequence.get(pos2)
if (isIndentChar(ch) && isInIndent) { if (isIndentChar(ch) && isInIndent) {
++accumulatedWhitespace2 ++accumulatedWhitespace2
} else if (ch == '\n') { } else if (ch == '\n') {
@@ -171,17 +235,45 @@ private suspend fun VimApi.findIndentRange(includeAbove: Boolean, includeBelow:
// Now `lowerBoundaryOffset` marks the end of an `ii` text object. // Now `lowerBoundaryOffset` marks the end of an `ii` text object.
if (isLowerBoundaryFound && includeBelow) { if (isLowerBoundaryFound && includeBelow) {
while (++lowerBoundaryOffset < charSequence.length) { while (++lowerBoundaryOffset < charSequence.length) {
val ch = charSequence[lowerBoundaryOffset] val ch = charSequence.get(lowerBoundaryOffset)
if (ch == '\n') { if (ch == '\n') {
break break
} }
} }
} }
// Convert offsets to line numbers for LineWise result return TextRange(upperBoundaryOffset, lowerBoundaryOffset)
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' 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)
}
}
}
}

View File

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

View File

@@ -23,6 +23,7 @@ import com.intellij.psi.codeStyle.CodeStyleManager
import com.intellij.psi.util.PsiUtilBase import com.intellij.psi.util.PsiUtilBase
import com.maddyhome.idea.vim.EventFacade import com.maddyhome.idea.vim.EventFacade
import com.maddyhome.idea.vim.api.ExecutionContext 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.VimCaret
import com.maddyhome.idea.vim.api.VimChangeGroupBase import com.maddyhome.idea.vim.api.VimChangeGroupBase
import com.maddyhome.idea.vim.api.VimEditor 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.change.ChangeRemoteApi
import com.maddyhome.idea.vim.group.format.FormatRemoteApi import com.maddyhome.idea.vim.group.format.FormatRemoteApi
import com.maddyhome.idea.vim.handler.commandContinuation import com.maddyhome.idea.vim.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.helper.inInsertMode
import com.maddyhome.idea.vim.key.KeyHandlerKeeper import com.maddyhome.idea.vim.key.KeyHandlerKeeper
import com.maddyhome.idea.vim.listener.VimInsertListener import com.maddyhome.idea.vim.listener.VimInsertListener
import com.maddyhome.idea.vim.newapi.IjVimEditor import com.maddyhome.idea.vim.newapi.IjVimEditor
import com.maddyhome.idea.vim.newapi.ij 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.state.mode.Mode
import com.maddyhome.idea.vim.undo.VimKeyBasedUndoService import com.maddyhome.idea.vim.undo.VimKeyBasedUndoService
import com.maddyhome.idea.vim.undo.VimTimestampBasedUndoService import com.maddyhome.idea.vim.undo.VimTimestampBasedUndoService
@@ -129,6 +134,8 @@ class ChangeGroup : VimChangeGroupBase() {
} }
override fun repeatInsertText(editor: VimEditor, context: ExecutionContext, count: Int) { override fun repeatInsertText(editor: VimEditor, context: ExecutionContext, count: Int) {
if (count <= 0) return
val ijEditor = (editor as IjVimEditor).editor val ijEditor = (editor as IjVimEditor).editor
val editorId = ijEditor.editorId() val editorId = ijEditor.editorId()
@@ -152,6 +159,39 @@ class ChangeGroup : VimChangeGroupBase() {
injector.application.runWriteAction { injector.application.runWriteAction {
CodeStyleManager.getInstance(project).reformatText(file, listOf(textRange)) 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( 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 * @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 class EditorGroup implements PersistentStateComponent<Element>, VimEditorGroup {
public static final @NonNls String EDITOR_STORE_ELEMENT = "editor"; public static final @NonNls String EDITOR_STORE_ELEMENT = "editor";
private final CaretListener myLineNumbersCaretListener = new CaretListener() { private final CaretListener myLineNumbersCaretListener = new CaretListener() {
@@ -321,6 +321,18 @@ public class EditorGroup implements PersistentStateComponent<Element>, VimEditor
return null; 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 @Override
public @NotNull Collection<VimEditor> getEditorsRaw() { public @NotNull Collection<VimEditor> getEditorsRaw() {
return getLocalEditors().map(IjVimEditor::new).collect(Collectors.toList()); 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 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 { override fun getProjectId(project: Any): String {
require(project is Project) require(project is Project)
return project.projectId().serializeToString() return project.projectId().serializeToString()

View File

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

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.api.*;
import com.maddyhome.idea.vim.command.MappingMode; import com.maddyhome.idea.vim.command.MappingMode;
import com.maddyhome.idea.vim.extension.VimExtensionFacade; 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.helper.ShortcutHelper;
import com.maddyhome.idea.vim.key.*; import com.maddyhome.idea.vim.key.*;
import com.maddyhome.idea.vim.newapi.IjNativeAction; import com.maddyhome.idea.vim.newapi.IjNativeAction;
@@ -51,7 +53,7 @@ import static java.util.stream.Collectors.toList;
/** /**
* @author vlan * @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 class KeyGroup extends VimKeyGroupBase implements PersistentStateComponent<Element> {
public static final @NonNls String SHORTCUT_CONFLICTS_ELEMENT = "shortcut-conflicts"; public static final @NonNls String SHORTCUT_CONFLICTS_ELEMENT = "shortcut-conflicts";
private static final @NonNls String SHORTCUT_CONFLICT_ELEMENT = "shortcut-conflict"; private static final @NonNls String SHORTCUT_CONFLICT_ELEMENT = "shortcut-conflict";
@@ -180,9 +182,15 @@ public class KeyGroup extends VimKeyGroupBase implements PersistentStateComponen
@Override @Override
public void registerRequiredShortcutKeys(@NotNull VimEditor editor) { 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(), EventFacade.getInstance().registerCustomShortcutSet(VimShortcutKeyAction.getInstance(),
ShortcutHelper.toShortcutSet(getRequiredShortcutKeys()), ShortcutHelper.toShortcutSet(getRequiredShortcutKeys()),
((IjVimEditor)editor).getEditor().getContentComponent()); ijEditor.getContentComponent());
} }
@Override @Override

View File

@@ -146,6 +146,22 @@ class OptionGroup : VimOptionGroupBase(), IjVimOptionGroup, InternalOptionValueA
super.setOptionValueInternal(option, scope, value) 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 { companion object {
fun editorReleased(editor: Editor) { 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 // 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.actionSystem.EditorActionHandler
import com.intellij.openapi.editor.actions.SplitLineAction import com.intellij.openapi.editor.actions.SplitLineAction
import com.intellij.openapi.editor.impl.CaretModelImpl import com.intellij.openapi.editor.impl.CaretModelImpl
import com.intellij.openapi.fileEditor.FileDocumentManager
import com.intellij.openapi.util.Key import com.intellij.openapi.util.Key
import com.intellij.openapi.util.UserDataHolder import com.intellij.openapi.util.UserDataHolder
import com.intellij.openapi.util.removeUserData import com.intellij.openapi.util.removeUserData
@@ -119,11 +118,7 @@ internal abstract class OctopusHandler(private val nextHandler: EditorActionHand
private fun executeInInvokeLater(editor: Editor): Boolean { private fun executeInInvokeLater(editor: Editor): Boolean {
// Currently we have a workaround for the PY console VIM-3157 // Currently we have a workaround for the PY console VIM-3157
val fileName = FileDocumentManager.getInstance().getFile(editor.document)?.name if (EditorHelper.isPythonConsole(editor)) return false
if (
fileName == "Python Console.py" || // This is the name in 232+
fileName == "Python Console" // This is the name in 231
) return false
return (editor.caretModel as? CaretModelImpl)?.isIteratingOverCarets ?: true 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.ex.util.EditorUtil;
import com.intellij.openapi.editor.impl.EditorImpl; import com.intellij.openapi.editor.impl.EditorImpl;
import com.intellij.openapi.fileEditor.FileDocumentManager; import com.intellij.openapi.fileEditor.FileDocumentManager;
import com.intellij.openapi.util.Key;
import com.intellij.openapi.util.SystemInfo; import com.intellij.openapi.util.SystemInfo;
import com.intellij.openapi.util.registry.Registry; import com.intellij.openapi.util.registry.Registry;
import com.intellij.openapi.vfs.VirtualFile; 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 // 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 // multiline rendered doc comments, while still providing some visibility of the block inlay (e.g. Rider's single line
// Code Vision) // 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; private static final int BLOCK_INLAY_MAX_LINE_HEIGHT = 3;
public static @NotNull Rectangle getVisibleArea(final @NotNull Editor editor) { public static @NotNull Rectangle getVisibleArea(final @NotNull Editor editor) {
@@ -679,6 +683,48 @@ public class EditorHelper {
return editor.getEditorKind() == EditorKind.DIFF; 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. * 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.EditorKind
import com.intellij.openapi.editor.ex.util.EditorUtil import com.intellij.openapi.editor.ex.util.EditorUtil
import com.intellij.openapi.fileEditor.ex.FileEditorManagerEx import com.intellij.openapi.fileEditor.ex.FileEditorManagerEx
import com.intellij.openapi.ui.popup.util.PopupUtil
import com.intellij.util.ui.table.JBTableRowEditor import com.intellij.util.ui.table.JBTableRowEditor
import com.maddyhome.idea.vim.VimPlugin import com.maddyhome.idea.vim.VimPlugin
import com.maddyhome.idea.vim.api.StringListOptionValue import com.maddyhome.idea.vim.api.StringListOptionValue
@@ -53,7 +54,7 @@ internal val Editor.isIdeaVimDisabledHere: Boolean
!ClientId.isCurrentlyUnderLocalId || // CWM-927 !ClientId.isCurrentlyUnderLocalId || // CWM-927
(ideaVimDisabledForSingleLine(ideaVimSupportValue) && isSingleLine()) || (ideaVimDisabledForSingleLine(ideaVimSupportValue) && isSingleLine()) ||
IdeaVimDisablerExtensionPoint.isDisabledForEditor(this) || IdeaVimDisablerExtensionPoint.isDisabledForEditor(this) ||
isNotFileEditorExceptAllowed() !isAllowedFileEditor()
} }
/** /**
@@ -65,18 +66,21 @@ internal val Editor.isIdeaVimDisabledHere: Boolean
* Here are issues when non-file editors were supported: * Here are issues when non-file editors were supported:
* AI Chat VIM-3786 * AI Chat VIM-3786
* Debug evaluate console VIM-3929 * 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 * We do want to support Vim actions in some windows, such as the commit window, diff windows, and decompiled Java
* be quite a visible change for users. * files. We don't support the Python console.
* We detect the commit window by the name of the editor (Dummy.txt). If this causes issues, let's disable IdeaVim
* in the commit window as well.
*
* Also, we support IdeaVim in diff viewers.
*/ */
private fun Editor.isNotFileEditorExceptAllowed(): Boolean { private fun Editor.isAllowedFileEditor(): Boolean {
if (EditorHelper.getVirtualFile(this)?.name?.contains("Dummy.txt") == true) return false if (EditorHelper.getVirtualFile(this)?.name?.contains("Dummy.txt") == true) {
if (EditorHelper.isDiffEditor(this)) return false return PopupUtil.getPopupContainerFor(component) == null
return !EditorHelper.isFileEditor(this) }
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 { private fun ideaVimDisabledInDialog(ideaVimSupportValue: StringListOptionValue): Boolean {

View File

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

View File

@@ -29,6 +29,7 @@ import com.maddyhome.idea.vim.common.InsertSequence
import com.maddyhome.idea.vim.newapi.IjVimCaret import com.maddyhome.idea.vim.newapi.IjVimCaret
import com.maddyhome.idea.vim.newapi.globalIjOptions import com.maddyhome.idea.vim.newapi.globalIjOptions
import com.maddyhome.idea.vim.newapi.ij 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.SelectionType
import com.maddyhome.idea.vim.state.mode.inVisualMode import com.maddyhome.idea.vim.state.mode.inVisualMode
import com.maddyhome.idea.vim.undo.VimTimestampBasedUndoService import com.maddyhome.idea.vim.undo.VimTimestampBasedUndoService
@@ -54,6 +55,7 @@ class UndoRedoHelper : VimTimestampBasedUndoService {
val textEditor = getTextEditor(editor.ij) val textEditor = getTextEditor(editor.ij)
val undoManager = UndoManager.getInstance(project) val undoManager = UndoManager.getInstance(project)
if (undoManager.isUndoAvailable(textEditor)) { if (undoManager.isUndoAvailable(textEditor)) {
val caretCountBeforeUndo = editor.ij.caretModel.allCarets.size
val scrollingModel = editor.getScrollingModel() val scrollingModel = editor.getScrollingModel()
scrollingModel.accumulateViewportChanges() scrollingModel.accumulateViewportChanges()
@@ -61,6 +63,8 @@ class UndoRedoHelper : VimTimestampBasedUndoService {
scrollingModel.flushViewportChanges() scrollingModel.flushViewportChanges()
collapseRestoredBlockVisualCarets(editor, caretCountBeforeUndo)
return true return true
} }
return false 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) { private fun removeSelections(editor: VimEditor) {
editor.carets().forEach { editor.carets().forEach {
val ijCaret = it.ij val ijCaret = it.ij

View File

@@ -29,12 +29,14 @@ import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.AnActionResult import com.intellij.openapi.actionSystem.AnActionResult
import com.intellij.openapi.actionSystem.AnActionWrapper import com.intellij.openapi.actionSystem.AnActionWrapper
import com.intellij.openapi.actionSystem.CommonDataKeys import com.intellij.openapi.actionSystem.CommonDataKeys
import com.intellij.openapi.actionSystem.IdeActions
import com.intellij.openapi.actionSystem.ex.AnActionListener import com.intellij.openapi.actionSystem.ex.AnActionListener
import com.intellij.openapi.actionSystem.impl.ProxyShortcutSet import com.intellij.openapi.actionSystem.impl.ProxyShortcutSet
import com.intellij.openapi.editor.Editor import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.RangeMarker import com.intellij.openapi.editor.RangeMarker
import com.intellij.openapi.editor.actions.EnterAction import com.intellij.openapi.editor.actions.EnterAction
import com.intellij.openapi.editor.impl.ScrollingModelImpl import com.intellij.openapi.editor.impl.ScrollingModelImpl
import com.intellij.openapi.fileEditor.FileEditorManager
import com.intellij.openapi.keymap.KeymapManager import com.intellij.openapi.keymap.KeymapManager
import com.intellij.openapi.project.DumbAwareToggleAction import com.intellij.openapi.project.DumbAwareToggleAction
import com.intellij.openapi.util.TextRange import com.intellij.openapi.util.TextRange
@@ -87,6 +89,11 @@ internal object IdeaSpecifics {
caretOffset = hostEditor.caretModel.offset caretOffset = hostEditor.caretModel.offset
} }
val actionId = ActionManager.getInstance().getId(action)
if (isGotoAction(actionId)) {
saveJumpBeforeGoto(event, editor)
}
val isVimAction = (action as? AnActionWrapper)?.delegate is VimShortcutKeyAction val isVimAction = (action as? AnActionWrapper)?.delegate is VimShortcutKeyAction
if (!isVimAction && injector.vimState.mode == Mode.INSERT && action !is EnterAction) { if (!isVimAction && injector.vimState.mode == Mode.INSERT && action !is EnterAction) {
val undoService = injector.undo as VimTimestampBasedUndoService val undoService = injector.undo as VimTimestampBasedUndoService
@@ -206,6 +213,20 @@ internal object IdeaSpecifics {
this.completionData = null this.completionData = null
} }
private fun isGotoAction(actionId: String?): Boolean =
actionId == IdeActions.ACTION_GOTO_BACK || actionId == IdeActions.ACTION_GOTO_FORWARD
private fun saveJumpBeforeGoto(event: AnActionEvent, editor: Editor?) {
val project = event.dataContext.getData(CommonDataKeys.PROJECT)
val currentEditor = editor
?: event.dataContext.getData(CommonDataKeys.EDITOR)
?: project?.let { VimListenerManager.VimLastSelectedEditorTracker.getLastSelectedEditor(it) }
?: project?.let { FileEditorManager.getInstance(it).selectedTextEditor }
if (currentEditor != null && !currentEditor.isIdeaVimDisabledHere) {
injector.jumpService.saveJumpLocation(currentEditor.vim)
}
}
private data class CompletionData( private data class CompletionData(
val completionStartMarker: RangeMarker, val completionStartMarker: RangeMarker,
val originalStartOffset: Int, val originalStartOffset: Int,

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 package com.maddyhome.idea.vim.listener
import com.intellij.codeWithMe.ClientId import com.intellij.codeWithMe.ClientId
import com.intellij.codeWithMe.ClientId.Companion.isLocal
import com.intellij.ide.ui.UISettings import com.intellij.ide.ui.UISettings
import com.intellij.openapi.Disposable import com.intellij.openapi.Disposable
import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.diagnostic.trace import com.intellij.openapi.diagnostic.trace
import com.intellij.openapi.editor.Caret 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.Editor
import com.intellij.openapi.editor.EditorFactory
import com.intellij.openapi.editor.EditorKind import com.intellij.openapi.editor.EditorKind
import com.intellij.openapi.editor.actionSystem.TypedAction import com.intellij.openapi.editor.actionSystem.TypedAction
import com.intellij.openapi.editor.event.CaretEvent 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.EditorEx
import com.intellij.openapi.editor.ex.FocusChangeListener import com.intellij.openapi.editor.ex.FocusChangeListener
import com.intellij.openapi.editor.impl.EditorComponentImpl 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.FileEditor
import com.intellij.openapi.fileEditor.FileEditorManager import com.intellij.openapi.fileEditor.FileEditorManager
import com.intellij.openapi.fileEditor.FileEditorManagerEvent 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.Key
import com.intellij.openapi.util.removeUserData import com.intellij.openapi.util.removeUserData
import com.intellij.openapi.vfs.VirtualFile 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.ExceptionUtil
import com.intellij.util.SlowOperations import com.intellij.util.SlowOperations
import com.maddyhome.idea.vim.EventFacade 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.getLineEndForOffset
import com.maddyhome.idea.vim.api.getLineStartForOffset import com.maddyhome.idea.vim.api.getLineStartForOffset
import com.maddyhome.idea.vim.api.injector 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.ChangeGroup
import com.maddyhome.idea.vim.group.CommentsOptionInitializer
import com.maddyhome.idea.vim.group.FileGroupHelper import com.maddyhome.idea.vim.group.FileGroupHelper
import com.maddyhome.idea.vim.group.IjOptions import com.maddyhome.idea.vim.group.IjOptions
import com.maddyhome.idea.vim.group.IjVimRedrawService import com.maddyhome.idea.vim.group.IjVimRedrawService
@@ -112,6 +127,7 @@ import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent import java.awt.event.MouseEvent
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import java.util.* import java.util.*
import java.util.concurrent.ConcurrentHashMap
import javax.swing.SwingUtilities import javax.swing.SwingUtilities
/** /**
@@ -168,6 +184,9 @@ object VimListenerManager {
val insertTimeRecorder = InsertTimeRecorder() val insertTimeRecorder = InsertTimeRecorder()
injector.listenersNotifier.modeChangeListeners.add(insertTimeRecorder) injector.listenersNotifier.modeChangeListeners.add(insertTimeRecorder)
injector.listenersNotifier.modeWillChangeListeners.add(AutoCmdInsertEnterListener())
injector.listenersNotifier.modeChangeListeners.add(AutoCmdInsertLeaveListener())
val modeWidgetListener = ModeWidgetListener() val modeWidgetListener = ModeWidgetListener()
injector.listenersNotifier.modeChangeListeners.add(modeWidgetListener) injector.listenersNotifier.modeChangeListeners.add(modeWidgetListener)
injector.listenersNotifier.myEditorListeners.add(modeWidgetListener) injector.listenersNotifier.myEditorListeners.add(modeWidgetListener)
@@ -221,6 +240,8 @@ object VimListenerManager {
val busConnection = val busConnection =
ApplicationManager.getApplication().messageBus.connect(VimPlugin.getInstance().onOffDisposable) ApplicationManager.getApplication().messageBus.connect(VimPlugin.getInstance().onOffDisposable)
busConnection.subscribe(FileOpenedSyncListener.TOPIC, VimEditorFactoryListener) busConnection.subscribe(FileOpenedSyncListener.TOPIC, VimEditorFactoryListener)
busConnection.subscribe(VirtualFileManager.VFS_CHANGES, BufNewFileTracker)
busConnection.subscribe(FileDocumentManagerListener.TOPIC, BufWriteListener)
} }
fun disable() { fun disable() {
@@ -237,6 +258,8 @@ object VimListenerManager {
optionGroup.removeGlobalOptionChangeListener(Options.showmode, modeWidgetOptionListener) optionGroup.removeGlobalOptionChangeListener(Options.showmode, modeWidgetOptionListener)
optionGroup.removeGlobalOptionChangeListener(Options.showmode, macroWidgetOptionListener) optionGroup.removeGlobalOptionChangeListener(Options.showmode, macroWidgetOptionListener)
optionGroup.removeEffectiveOptionValueChangeListener(Options.guicursor, GuicursorChangeListener) optionGroup.removeEffectiveOptionValueChangeListener(Options.guicursor, GuicursorChangeListener)
BufNewFileTracker.clear()
} }
} }
@@ -325,6 +348,7 @@ object VimListenerManager {
injector.editorGroup.editorCreated(IjVimEditor(editor)) injector.editorGroup.editorCreated(IjVimEditor(editor))
(VimPlugin.getChange() as ChangeGroup).editorCreated(IjVimEditor(editor), perEditorDisposable) (VimPlugin.getChange() as ChangeGroup).editorCreated(IjVimEditor(editor), perEditorDisposable)
CommentsOptionInitializer.initializeForEditor(editor)
(editor as EditorEx).addFocusListener(VimFocusListener, perEditorDisposable) (editor as EditorEx).addFocusListener(VimFocusListener, perEditorDisposable)
@@ -335,15 +359,18 @@ object VimListenerManager {
injector.editorGroup.editorDeinit(editor.vim) injector.editorGroup.editorDeinit(editor.vim)
} }
} }
ApplicationManager.getApplication().invokeLater {
if (vimDisabled(editor)) {
remove(editor)
}
}
} }
fun remove(editor: Editor) { fun remove(editor: Editor) {
val editorDisposable = editor.removeUserData(editorListenersDisposableKey) val editorDisposable = editor.removeUserData(editorListenersDisposableKey)
if (editorDisposable != null) { if (editorDisposable != null) {
Disposer.dispose(editorDisposable) 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 // Breaks relativenumber for some reason
// injector.scroll.scrollCaretIntoView(editor.vim) // injector.scroll.scrollCaretIntoView(editor.vim)
} }
// Vim order: BufLeave → WinLeave → WinEnter → BufEnter
injector.outputPanel.getCurrentOutputPanel()?.close() // 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) MotionGroup.fileEditorManagerSelectionChangedCallback(event)
FileGroupHelper.fileEditorManagerSelectionChangedCallback(event) FileGroupHelper.fileEditorManagerSelectionChangedCallback(event)
// (VimPlugin.getSearch() as IjVimSearchGroup).fileEditorManagerSelectionChangedCallback(event) // (VimPlugin.getSearch() as IjVimSearchGroup).fileEditorManagerSelectionChangedCallback(event)
@@ -508,6 +544,7 @@ object VimListenerManager {
EditorListeners.remove(event.editor) EditorListeners.remove(event.editor)
injector.listenersNotifier.notifyEditorReleased(vimEditor) injector.listenersNotifier.notifyEditorReleased(vimEditor)
injector.markService.editorReleased(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 // 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 // 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) EditorListeners.add(editor, openingEditor?.editor?.vim ?: injector.fallbackWindow, scenario)
firstEditorInitialised = true firstEditorInitialised = true
fireBufferLoadedEvents(editor)
} }
} }
} }
@@ -925,3 +964,122 @@ private object MouseEventsDataHolder {
const val allowedSkippedDragEvents = 3 const val allowedSkippedDragEvents = 3
var dragEventCount = allowedSkippedDragEvents 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 val allFolds = editor.foldingModel.allFoldRegions
if (allFolds.isEmpty()) return if (allFolds.isEmpty()) return
val depths = FoldDepthCalculator.computeDepths(allFolds)
editor.foldingModel.runBatchFoldingOperation { editor.foldingModel.runBatchFoldingOperation {
// I'm aware it's O(n^2) comparison here, for (i in allFolds.indices) {
// but it doesn't affect performance even on a large amount of fold allFolds[i].isExpanded = depths[i] < foldLevel
allFolds.forEach { fold ->
val depth = calculateFoldDepth(fold, allFolds)
fold.isExpanded = depth < foldLevel
} }
} }
} }
@@ -528,9 +527,10 @@ class IjVimEditor(editor: Editor) : MutableLinearEditor, VimEditorBase() {
val allFolds = editor.foldingModel.allFoldRegions val allFolds = editor.foldingModel.allFoldRegions
if (allFolds.isEmpty()) return 0 if (allFolds.isEmpty()) return 0
return allFolds.maxOfOrNull { fold -> val depths = FoldDepthCalculator.computeDepths(allFolds)
calculateFoldDepth(fold, allFolds) var max = 0
} ?: 0 for (d in depths) if (d > max) max = d
return max
} }
override fun createFoldRegion(startOffset: Int, endOffset: Int, collapse: Boolean): VimFoldRegion? { 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 } .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 { private fun toVimFoldRegion(ijFoldRegion: FoldRegion): VimFoldRegion {
return IjVimFoldRegion(ijFoldRegion, editor) 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.components.serviceIfCreated
import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.editor.textarea.TextComponentEditorImpl 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.EngineEditorHelper
import com.maddyhome.idea.vim.api.ExecutionContextManager import com.maddyhome.idea.vim.api.ExecutionContextManager
import com.maddyhome.idea.vim.api.LocalOptionInitialisationScenario 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.VimscriptParser
import com.maddyhome.idea.vim.api.injector import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.api.isInjectorInitialized 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.diagnostic.VimLogger
import com.maddyhome.idea.vim.extension.ExtensionLoader import com.maddyhome.idea.vim.extension.ExtensionLoader
import com.maddyhome.idea.vim.extension.JsonExtensionProvider import com.maddyhome.idea.vim.extension.JsonExtensionProvider
@@ -217,6 +219,8 @@ internal class IjVimInjector : VimInjectorBase() {
get() = service() get() = service()
override val pluginActivator: VimPluginActivator override val pluginActivator: VimPluginActivator
get() = service() get() = service()
override val autoCmd: AutoCmdService get() = service<AutoCmdService>()
} }
/** /**

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 * Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at * license that can be found in the LICENSE.txt file or at
@@ -9,9 +9,7 @@
package com.maddyhome.idea.vim.newapi package com.maddyhome.idea.vim.newapi
import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.project.Project import com.maddyhome.idea.vim.api.MessageType
import com.intellij.openapi.project.ProjectManager
import com.intellij.openapi.wm.WindowManager
import com.maddyhome.idea.vim.api.VimEditor import com.maddyhome.idea.vim.api.VimEditor
import com.maddyhome.idea.vim.api.VimMessagesBase import com.maddyhome.idea.vim.api.VimMessagesBase
import com.maddyhome.idea.vim.api.globalOptions import com.maddyhome.idea.vim.api.globalOptions
@@ -25,56 +23,50 @@ internal class IjVimMessages : VimMessagesBase() {
private var message: String? = null private var message: String? = null
private var error = false private var error = false
private var lastBeepTimeMillis = 0L private var lastBeepTimeMillis = 0L
private var allowClearStatusBarMessage = true
override fun showStatusBarMessage(editor: VimEditor?, message: String?) { override fun showMessage(editor: VimEditor, message: String?) {
fun setStatusBarMessage(project: Project, message: String?) { injector.outputPanel.clear(editor, injector.executionContextManager.getEditorExecutionContext(editor))
WindowManager.getInstance().getStatusBar(project)?.let { showMessageInternal(editor, message, MessageType.STANDARD)
it.info = if (message.isNullOrBlank()) "" else "Vim - $message"
}
} }
override fun showErrorMessage(editor: VimEditor, message: String?) {
injector.outputPanel.clear(editor, injector.executionContextManager.getEditorExecutionContext(editor))
showMessageInternal(editor, message, MessageType.ERROR)
indicateError()
}
override fun appendErrorMessage(editor: VimEditor, message: String?) {
showMessageInternal(editor, message, MessageType.ERROR)
indicateError()
}
private fun showMessageInternal(editor: VimEditor, message: String?, messageType: MessageType) {
this.message = message this.message = message
val project = editor?.ij?.project if (message.isNullOrBlank()) {
if (project != null) { clearStatusBarMessage()
setStatusBarMessage(project, message) return
} else {
// TODO: We really shouldn't set the status bar text for other projects. That's rude.
ProjectManager.getInstance().openProjects.forEach {
setStatusBarMessage(it, message)
}
} }
// Redraw happens automatically based on changes or scrolling. If we've just set the message (e.g., searching for a val context = injector.executionContextManager.getEditorExecutionContext(editor)
// string, hitting the bottom and scrolling to the top), make sure we don't immediately clear it when scrolling. injector.outputPanel.output(editor, context, message, messageType)
allowClearStatusBarMessage = false }
ApplicationManager.getApplication().invokeLater {
allowClearStatusBarMessage = true @Suppress("DEPRECATION")
override fun showStatusBarMessage(editor: VimEditor?, message: String?) {
if (editor != null) {
showMessage(editor, message)
} else {
// Legacy path for when editor is null - just store the message
this.message = message
} }
} }
override fun getStatusBarMessage(): String? = message override fun getStatusBarMessage(): String? = message
// Vim doesn't appear to have a policy about clearing the status bar, other than on "redraw". This can be forced with
// <C-L> or the `:redraw` command, but also happens as the screen changes, e.g., when inserting or deleting lines,
// scrolling, entering Command-line mode and probably lots more. We should manually clear the status bar when these
// things happen.
override fun clearStatusBarMessage() { override fun clearStatusBarMessage() {
val currentMessage = message if (message.isNullOrEmpty()) return
if (currentMessage.isNullOrEmpty()) return injector.outputPanel.getCurrentOutputPanel()?.close()
// Don't clear the status bar message if we've only just set it
if (!allowClearStatusBarMessage) return
ProjectManager.getInstance().openProjects.forEach { project ->
WindowManager.getInstance().getStatusBar(project)?.let { statusBar ->
// Only clear the status bar if it's showing our last message
if (statusBar.info?.contains(currentMessage) == true) {
statusBar.info = ""
}
}
}
message = null message = null
} }

View File

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

View File

@@ -11,12 +11,15 @@ import com.intellij.ide.ui.LafManager
import com.intellij.ide.ui.LafManagerListener import com.intellij.ide.ui.LafManagerListener
import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.editor.Editor import com.intellij.openapi.editor.Editor
import com.intellij.openapi.wm.ex.ToolWindowManagerListener
import com.intellij.openapi.wm.impl.IdeBackgroundUtil import com.intellij.openapi.wm.impl.IdeBackgroundUtil
import com.intellij.openapi.wm.impl.ToolWindowManagerImpl import com.intellij.openapi.wm.impl.ToolWindowManagerImpl
import com.intellij.ui.ClientProperty import com.intellij.ui.ClientProperty
import com.intellij.ui.JBColor
import com.intellij.ui.components.JBPanel import com.intellij.ui.components.JBPanel
import com.intellij.ui.components.JBScrollPane import com.intellij.ui.components.JBScrollPane
import com.intellij.util.IJSwingUtilities import com.intellij.util.IJSwingUtilities
import com.intellij.util.messages.MessageBusConnection
import com.maddyhome.idea.vim.KeyHandler.Companion.getInstance import com.maddyhome.idea.vim.KeyHandler.Companion.getInstance
import com.maddyhome.idea.vim.VimPlugin import com.maddyhome.idea.vim.VimPlugin
import com.maddyhome.idea.vim.api.ExecutionContext import com.maddyhome.idea.vim.api.ExecutionContext
@@ -24,7 +27,6 @@ import com.maddyhome.idea.vim.api.MessageType
import com.maddyhome.idea.vim.api.VimOutputPanel import com.maddyhome.idea.vim.api.VimOutputPanel
import com.maddyhome.idea.vim.api.globalOptions import com.maddyhome.idea.vim.api.globalOptions
import com.maddyhome.idea.vim.api.injector import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.diagnostic.VimLogger
import com.maddyhome.idea.vim.helper.requestFocus import com.maddyhome.idea.vim.helper.requestFocus
import com.maddyhome.idea.vim.helper.selectEditorFont import com.maddyhome.idea.vim.helper.selectEditorFont
import com.maddyhome.idea.vim.helper.vimMorePanel import com.maddyhome.idea.vim.helper.vimMorePanel
@@ -36,121 +38,167 @@ import java.awt.event.ComponentAdapter
import java.awt.event.ComponentEvent import java.awt.event.ComponentEvent
import java.awt.event.KeyAdapter import java.awt.event.KeyAdapter
import java.awt.event.KeyEvent import java.awt.event.KeyEvent
import java.lang.ref.WeakReference
import javax.swing.JComponent import javax.swing.JComponent
import javax.swing.JLabel import javax.swing.JLabel
import javax.swing.JRootPane
import javax.swing.JScrollPane import javax.swing.JScrollPane
import javax.swing.JTextArea import javax.swing.JTextPane
import javax.swing.KeyStroke import javax.swing.KeyStroke
import javax.swing.SwingUtilities import javax.swing.SwingUtilities
import javax.swing.text.DefaultCaret
import javax.swing.text.SimpleAttributeSet
import javax.swing.text.StyleConstants
import javax.swing.text.StyledDocument
import kotlin.math.ceil import kotlin.math.ceil
import kotlin.math.min import kotlin.math.min
/** /**
* This panel displays text in a `more` like window and implements [VimOutputPanel]. * Panel that displays text in a `more` like window overlaid on the editor.
*/ */
class OutputPanel(editorRef: WeakReference<Editor>) : JBPanel<OutputPanel?>(), VimOutputPanel { class OutputPanel private constructor(
private val myEditorRef: WeakReference<Editor> = editorRef private val editor: Editor,
val editor: Editor? get() = myEditorRef.get() ) : JBPanel<OutputPanel>(), VimOutputPanel {
val myLabel: JLabel = JLabel("more") private val textPane = JTextPane()
private val myText = JTextArea() private val resizeAdapter: ComponentAdapter
private val myScrollPane: JScrollPane = private var defaultForeground: Color? = null
JBScrollPane(myText, JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED, JScrollPane.HORIZONTAL_SCROLLBAR_NEVER)
private val myAdapter: ComponentAdapter
private var myLineHeight = 0
private var myOldGlass: JComponent? = null private var glassPane: JComponent? = null
private var myOldLayout: LayoutManager? = null private var originalLayout: LayoutManager? = null
private var myWasOpaque = false private var wasOpaque = false
private var toolWindowListenerConnection: MessageBusConnection? = null
var myActive: Boolean = false var active: Boolean = false
private val segments = mutableListOf<TextLine>()
val isActive: Boolean private val labelComponent: JLabel = JLabel("more")
get() = myActive private val scrollPane: JScrollPane =
JBScrollPane(textPane, JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED, JScrollPane.HORIZONTAL_SCROLLBAR_NEVER)
private var cachedLineHeight = 0
private var isSingleLine = false
init { init {
// Create a text editor for the text and a label for the prompt textPane.isEditable = false
val layout = BorderLayout(0, 0) textPane.caret = object : DefaultCaret() {
setLayout(layout) override fun setVisible(v: Boolean) {
add(myScrollPane, BorderLayout.CENTER) super.setVisible(false)
add(myLabel, BorderLayout.SOUTH) }
}
textPane.highlighter = null
// Set the text area read only, and support wrap resizeAdapter = object : ComponentAdapter() {
myText.isEditable = false
myText.setLineWrap(true)
myAdapter = object : ComponentAdapter() {
override fun componentResized(e: ComponentEvent?) { override fun componentResized(e: ComponentEvent?) {
positionPanel() positionPanel()
} }
} }
// Setup some listeners to handle keystrokes // Suppress the fancy frame background used in the Islands theme
val moreKeyListener = MoreKeyListener() ClientProperty.putRecursive(this, IdeBackgroundUtil.NO_BACKGROUND, true)
addKeyListener(moreKeyListener)
myText.addKeyListener(moreKeyListener)
// Suppress the fancy frame background used in the Islands theme, which comes from a custom Graphics implementation // Initialize panel
// applied to the IdeRoot, and used to paint all children, including this panel. This client property is checked by setLayout(BorderLayout(0, 0))
// JBPanel.getComponentGraphics to give us the original Graphics, opting out of the fancy painting. add(scrollPane, BorderLayout.CENTER)
ClientProperty.putRecursive<Boolean?>(this, IdeBackgroundUtil.NO_BACKGROUND, true) add(labelComponent, BorderLayout.SOUTH)
val keyListener = OutputPanelKeyListener()
addKeyListener(keyListener)
textPane.addKeyListener(keyListener)
editor?.let { putClientProperty(ToolWindowManagerImpl.PARENT_COMPONENT, it.component) } editor?.let { putClientProperty(ToolWindowManagerImpl.PARENT_COMPONENT, it.component) }
updateUI() updateUI()
} }
// Called automatically when the LAF is changed and the component is visible, and manually by the LAF listener handler
override fun updateUI() { override fun updateUI() {
super.updateUI() super.updateUI()
setBorder(ExPanelBorder()) setBorder(ExPanelBorder())
// Swing uses a bad pattern of calling updateUI() from the constructor. At this moment, all these variables are null
@Suppress("SENSELESS_COMPARISON") @Suppress("SENSELESS_COMPARISON")
if (myText != null && myLabel != null && myScrollPane != null) { if (textPane != null && labelComponent != null && scrollPane != null) {
setFontForElements() setFontForElements()
myText.setBorder(null) textPane.setBorder(null)
myScrollPane.setBorder(null) scrollPane.setBorder(null)
myLabel.setForeground(myText.getForeground()) labelComponent.setForeground(textPane.getForeground())
// Make sure the panel is positioned correctly in case we're changing font size
positionPanel() positionPanel()
} }
} }
override var text: String override var text: String
get() = myText.text get() = textPane.getText() ?: ""
set(value) { set(value) {
// ExOutputPanel will strip a trailing newline. We'll do it now so that tests have the same behaviour.
val newValue = value.removeSuffix("\n") val newValue = value.removeSuffix("\n")
myText.text = newValue segments.clear()
val ed = editor if (newValue.isEmpty()) return
if (ed != null) { segments.add(TextLine(newValue, null))
myText.setFont(selectEditorFont(ed, newValue))
} }
myText.setCaretPosition(0)
if (newValue.isNotEmpty()) { override var label: String
get() = labelComponent.text
set(value) {
labelComponent.text = value
}
/**
* Sets styled text with multiple segments, each potentially having a different color.
*/
fun setStyledText(lines: List<TextLine>) {
val doc = textPane.styledDocument
doc.remove(0, doc.length)
if (defaultForeground == null) {
defaultForeground = textPane.foreground
}
if (lines.size > 1) {
setMultiLineText(lines, doc)
} else {
doc.insertString(doc.length, lines[0].text.removeSuffix("\n"), getLineColor(lines[0]))
}
val fullText = doc.getText(0, doc.length)
textPane.setFont(selectEditorFont(editor, fullText))
textPane.setCaretPosition(0)
if (fullText.isNotEmpty()) {
activate() activate()
} }
} }
override var label: String private fun setMultiLineText(
get() = myLabel.text ?: "" lines: List<TextLine>,
set(value) { doc: StyledDocument,
myLabel.text = value ) {
val ed = editor for ((index, line) in lines.withIndex()) {
if (ed != null) { val text = line.text.removeSuffix("\n")
myLabel.setFont(selectEditorFont(ed, value)) val attrs = getLineColor(line)
val separator = if (index < lines.size - 1) "\n" else ""
doc.insertString(doc.length, text + separator, attrs)
} }
} }
private fun getLineColor(segment: TextLine): SimpleAttributeSet {
val attrs = SimpleAttributeSet()
val color = segment.color ?: defaultForeground
if (color != null) {
StyleConstants.setForeground(attrs, color)
}
return attrs
}
override fun addText(text: String, isNewLine: Boolean, messageType: MessageType) { override fun addText(text: String, isNewLine: Boolean, messageType: MessageType) {
if (this.text.isNotEmpty() && isNewLine) { val color = when (messageType) {
this.text += "\n$text" MessageType.ERROR -> JBColor.RED
} else { MessageType.STANDARD -> null
this.text += text }
segments.add(TextLine(text, color))
}
override fun show() {
val currentPanel = injector.outputPanel.getCurrentOutputPanel()
if (currentPanel != null && currentPanel != this) currentPanel.close()
setStyledText(segments)
if (!active) {
activate()
} }
} }
@@ -159,20 +207,15 @@ class OutputPanel(editorRef: WeakReference<Editor>) : JBPanel<OutputPanel?>(), V
} }
override fun clearText() { override fun clearText() {
segments.clear()
}
fun clear() {
text = "" text = ""
} }
override fun show() {
editor ?: return
val currentPanel = injector.outputPanel.getCurrentOutputPanel()
if (currentPanel != null && currentPanel != this) currentPanel.close()
if (!myActive) {
activate()
}
}
override fun handleKey(key: KeyStroke) { override fun handleKey(key: KeyStroke) {
if (isAtEnd) { if (isAtEnd) {
close(key) close(key)
return return
@@ -197,183 +240,71 @@ class OutputPanel(editorRef: WeakReference<Editor>) : JBPanel<OutputPanel?>(), V
override fun getForeground(): Color? { override fun getForeground(): Color? {
@Suppress("SENSELESS_COMPARISON") @Suppress("SENSELESS_COMPARISON")
if (myText == null) { if (textPane == null) {
// Swing uses a bad pattern of calling getForeground() from the constructor. At this moment, `myText` is null.
return super.getForeground() return super.getForeground()
} }
return myText.getForeground() return textPane.getForeground()
} }
override fun getBackground(): Color? { override fun getBackground(): Color? {
@Suppress("SENSELESS_COMPARISON") @Suppress("SENSELESS_COMPARISON")
if (myText == null) { if (textPane == null) {
// Swing uses a bad pattern of calling getBackground() from the constructor. At this moment, `myText` is null.
return super.getBackground() return super.getBackground()
} }
return myText.getBackground() return textPane.getBackground()
} }
/** fun deactivate() {
* Turns off the ex entry field and optionally puts the focus back to the original component if (!active) return
*/ active = false
fun deactivate(refocusOwningEditor: Boolean) { clearText()
if (!myActive) return textPane.text = ""
myActive = false if (glassPane != null) {
myText.text = "" glassPane!!.removeComponentListener(resizeAdapter)
val ed = editor toolWindowListenerConnection?.disconnect()
if (refocusOwningEditor && ed != null) { toolWindowListenerConnection = null
requestFocus(ed.contentComponent) glassPane!!.isVisible = false
} glassPane!!.remove(this)
if (myOldGlass != null) { glassPane!!.setOpaque(wasOpaque)
myOldGlass!!.removeComponentListener(myAdapter) glassPane!!.setLayout(originalLayout)
myOldGlass!!.isVisible = false
myOldGlass!!.remove(this)
myOldGlass!!.setOpaque(myWasOpaque)
myOldGlass!!.setLayout(myOldLayout)
} }
} }
/** /**
* Turns on the more window for the given editor * Turns on the output panel for the given editor.
*/ */
fun activate() { fun activate() {
val ed = editor ?: return disableOldGlass()
val root = SwingUtilities.getRootPane(ed.contentComponent)
deactivateOldGlass(root)
setFontForElements() setFontForElements()
positionPanel() positionPanel()
if (myOldGlass != null) { if (glassPane != null) {
myOldGlass!!.isVisible = true glassPane!!.isVisible = true
} }
myActive = true active = true
requestFocus(myText) if (isSingleLine) return
requestFocus(textPane)
} }
private fun deactivateOldGlass(root: JRootPane?) { private fun disableOldGlass() {
if (root == null) return val root = SwingUtilities.getRootPane(editor.contentComponent) ?: return
myOldGlass = root.getGlassPane() as JComponent? glassPane = root.getGlassPane() as JComponent?
if (myOldGlass != null) { if (glassPane == null) {
myOldLayout = myOldGlass!!.layout
myWasOpaque = myOldGlass!!.isOpaque
myOldGlass!!.setLayout(null)
myOldGlass!!.setOpaque(false)
myOldGlass!!.add(this)
myOldGlass!!.addComponentListener(myAdapter)
}
}
private fun setFontForElements() {
val ed = editor ?: return
myText.setFont(selectEditorFont(ed, myText.getText()))
myLabel.setFont(selectEditorFont(ed, myLabel.text))
}
override fun scrollLine() {
scrollOffset(myLineHeight)
}
override fun scrollPage() {
scrollOffset(myScrollPane.getVerticalScrollBar().visibleAmount)
}
override fun scrollHalfPage() {
val sa = myScrollPane.getVerticalScrollBar().visibleAmount / 2.0
val offset = ceil(sa / myLineHeight) * myLineHeight
scrollOffset(offset.toInt())
}
fun onBadKey() {
val ed = editor ?: return
myLabel.setText(injector.messages.message("message.ex.output.more.prompt.full"))
myLabel.setFont(selectEditorFont(ed, myLabel.text))
}
private fun scrollOffset(more: Int) {
val ed = editor ?: return
val `val` = myScrollPane.getVerticalScrollBar().value
myScrollPane.getVerticalScrollBar().setValue(`val` + more)
myScrollPane.getHorizontalScrollBar().setValue(0)
if (isAtEnd) {
myLabel.setText(injector.messages.message("message.ex.output.end.prompt"))
} else {
myLabel.setText(injector.messages.message("message.ex.output.more.prompt"))
}
myLabel.setFont(selectEditorFont(ed, myLabel.text))
}
val isAtEnd: Boolean
get() {
val isSingleLine = myText.getLineCount() == 1
if (isSingleLine) return true
val scrollBar = myScrollPane.getVerticalScrollBar()
val value = scrollBar.value
if (!scrollBar.isVisible) {
return true
}
return value >= scrollBar.maximum - scrollBar.visibleAmount ||
scrollBar.maximum <= scrollBar.visibleAmount
}
private fun positionPanel() {
val ed = editor ?: return
val contentComponent = ed.contentComponent
val scroll = SwingUtilities.getAncestorOfClass(JScrollPane::class.java, contentComponent)
val rootPane = SwingUtilities.getRootPane(contentComponent)
if (scroll == null || rootPane == null) {
// These might be null if we're invoked during component initialisation and before it's been added to the tree
return return
} }
originalLayout = glassPane!!.layout
size = scroll.size wasOpaque = glassPane!!.isOpaque
glassPane!!.setLayout(null)
myLineHeight = myText.getFontMetrics(myText.getFont()).height glassPane!!.setOpaque(false)
val count: Int = countLines(myText.getText()) glassPane!!.add(this)
val visLines = size.height / myLineHeight - 1 glassPane!!.addComponentListener(resizeAdapter)
val lines = min(count, visLines) val project = editor.project
setSize( if (project != null) {
size.width, toolWindowListenerConnection = project.messageBus.connect()
lines * myLineHeight + myLabel.getPreferredSize().height + border.getBorderInsets(this).top * 2 toolWindowListenerConnection!!.subscribe(ToolWindowManagerListener.TOPIC, ToolWindowPositioningListener { positionPanel() })
)
val height = size.height
val bounds = scroll.bounds
bounds.translate(0, scroll.getHeight() - height)
bounds.height = height
val pos = SwingUtilities.convertPoint(scroll.getParent(), bounds.location, rootPane.getGlassPane())
bounds.location = pos
setBounds(bounds)
myScrollPane.getVerticalScrollBar().setValue(0)
if (!injector.globalOptions().more) {
// FIX
scrollOffset(100000)
} else {
scrollOffset(0)
}
}
fun close(key: KeyStroke? = null) {
val ed = editor ?: return
ApplicationManager.getApplication().invokeLater {
deactivate(true)
val project = ed.project
if (project != null && key != null && key.keyChar != '\n') {
val keys: MutableList<KeyStroke> = ArrayList(1)
keys.add(key)
if (LOG.isTrace()) {
LOG.trace(
"Adding new keys to keyStack as part of playback. State before adding keys: " +
getInstance().keyStack.dump()
)
}
getInstance().keyStack.addKeys(keys)
val context: ExecutionContext =
injector.executionContextManager.getEditorExecutionContext(IjVimEditor(ed))
VimPlugin.getMacro().playbackKeys(IjVimEditor(ed), context, 1)
}
} }
} }
@@ -381,61 +312,88 @@ class OutputPanel(editorRef: WeakReference<Editor>) : JBPanel<OutputPanel?>(), V
close(null) close(null)
} }
private class MoreKeyListener : KeyAdapter() { fun close(key: KeyStroke?) {
/** val passKeyBack = isSingleLine
* Invoked when a key has been pressed. ApplicationManager.getApplication().invokeLater {
*/ deactivate()
override fun keyTyped(e: KeyEvent) { val project = editor.project
val currentPanel: VimOutputPanel = injector.outputPanel.getCurrentOutputPanel() ?: return // 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)
val keyCode = e.getKeyCode() if (project != null && key != null && (passKeyBack || key.keyChar != '\n')) {
val keyChar = e.getKeyChar() val keys: MutableList<KeyStroke> = ArrayList(1)
val modifiers = e.modifiersEx keys.add(key)
val keyStroke = if (keyChar == KeyEvent.CHAR_UNDEFINED) getInstance().keyStack.addKeys(keys)
KeyStroke.getKeyStroke(keyCode, modifiers) val context: ExecutionContext =
else injector.executionContextManager.getEditorExecutionContext(IjVimEditor(editor))
KeyStroke.getKeyStroke(keyChar, modifiers) VimPlugin.getMacro().playbackKeys(IjVimEditor(editor), context, 1)
currentPanel.handleKey(keyStroke)
}
}
class LafListener : LafManagerListener {
override fun lookAndFeelChanged(source: LafManager) {
if (VimPlugin.isNotEnabled()) return
// This listener is only invoked for local scenarios, and we only need to update local editor UI. This will invoke
// updateUI on the output pane and it's child components
for (vimEditor in injector.editorGroup.getEditors()) {
val editor = (vimEditor as IjVimEditor).editor
if (!isPanelActive(editor)) continue
IJSwingUtilities.updateComponentTreeUI(getInstance(editor))
} }
} }
} }
companion object { private fun setFontForElements() {
private val LOG: VimLogger = injector.getLogger<OutputPanel>(OutputPanel::class.java) textPane.setFont(selectEditorFont(editor, textPane.getText()))
labelComponent.setFont(selectEditorFont(editor, labelComponent.text))
fun getNullablePanel(editor: Editor): OutputPanel? {
return editor.vimMorePanel as? OutputPanel
} }
fun isPanelActive(editor: Editor): Boolean { private fun positionPanel() {
return getNullablePanel(editor)?.myActive ?: false val scroll = positionPanelStart() ?: return
val lineHeight = textPane.getFontMetrics(textPane.getFont()).height
val count = countLines(textPane.getText())
val visLines = size.height / lineHeight - 1
val lines = min(count, visLines)
// Simple output: single line that fits entirely - no label needed
isSingleLine = count == 1 && count <= visLines
labelComponent.isVisible = !isSingleLine
val extraHeight = if (isSingleLine) 0 else labelComponent.getPreferredSize().height
setSize(
size.width,
lines * lineHeight + extraHeight + border.getBorderInsets(this).top * 2
)
finishPositioning(scroll)
// Force layout so that viewport sizes are valid before checking scroll state
validate()
// onPositioned
cachedLineHeight = lineHeight
scrollPane.getVerticalScrollBar().setValue(0)
if (!isSingleLine) {
if (!injector.globalOptions().more) {
scrollOffset(100000)
} else {
scrollOffset(0)
}
}
} }
fun getInstance(editor: Editor): OutputPanel { private fun positionPanelStart(): JScrollPane? {
var panel: OutputPanel? = getNullablePanel(editor) val contentComponent = editor.contentComponent
if (panel == null) { val scroll = SwingUtilities.getAncestorOfClass(JScrollPane::class.java, contentComponent) as? JScrollPane
panel = OutputPanel(WeakReference(editor)) val rootPane = SwingUtilities.getRootPane(contentComponent)
editor.vimMorePanel = panel if (scroll == null || rootPane == null) {
return null
} }
return panel
size = scroll.size
return scroll
}
private fun finishPositioning(scroll: JScrollPane) {
val rootPane = SwingUtilities.getRootPane(editor.contentComponent)
val bounds = scroll.bounds
bounds.translate(0, scroll.getHeight() - size.height)
bounds.height = size.height
val pos = SwingUtilities.convertPoint(scroll.getParent(), bounds.location, rootPane.getGlassPane())
bounds.location = pos
setBounds(bounds)
} }
private fun countLines(text: String): Int { private fun countLines(text: String): Int {
if (text.isEmpty()) { if (text.isEmpty()) {
return 0 return 1
} }
var count = 0 var count = 0
@@ -450,5 +408,124 @@ class OutputPanel(editorRef: WeakReference<Editor>) : JBPanel<OutputPanel?>(), V
return count return count
} }
override fun scrollLine() {
scrollOffset(cachedLineHeight)
}
override fun scrollPage() {
scrollOffset(scrollPane.getVerticalScrollBar().visibleAmount)
}
override fun scrollHalfPage() {
val sa = scrollPane.getVerticalScrollBar().visibleAmount / 2.0
val offset = ceil(sa / cachedLineHeight) * cachedLineHeight
scrollOffset(offset.toInt())
}
fun onBadKey() {
labelComponent.setText(injector.messages.message("message.ex.output.more.prompt.full"))
labelComponent.setFont(selectEditorFont(editor, labelComponent.text))
}
private fun scrollOffset(more: Int) {
scrollPane.validate()
val scrollBar = scrollPane.getVerticalScrollBar()
val value = scrollBar.value
scrollBar.setValue(value + more)
scrollPane.getHorizontalScrollBar().setValue(0)
// Check if we're at the end or if content fits entirely (nothing to scroll)
if (isAtEnd) {
labelComponent.setText(injector.messages.message("message.ex.output.end.prompt"))
} else {
labelComponent.setText(injector.messages.message("message.ex.output.more.prompt"))
}
labelComponent.setFont(selectEditorFont(editor, labelComponent.text))
}
val isAtEnd: Boolean
get() {
if (isSingleLine) return true
val contentHeight = textPane.preferredSize.height
val viewportHeight = scrollPane.viewport.height
if (contentHeight <= viewportHeight) return true
val scrollBar = scrollPane.getVerticalScrollBar()
return scrollBar.value >= scrollBar.maximum - scrollBar.visibleAmount
}
private inner class OutputPanelKeyListener : KeyAdapter() {
override fun keyTyped(e: KeyEvent) {
val currentPanel: VimOutputPanel = injector.outputPanel.getCurrentOutputPanel() ?: return
val keyChar = e.keyChar
val modifiers = e.modifiersEx
val keyStroke = KeyStroke.getKeyStroke(keyChar, modifiers)
currentPanel.handleKey(keyStroke)
}
override fun keyPressed(e: KeyEvent) {
if (!e.isActionKey && e.keyCode != KeyEvent.VK_ENTER) return
val currentPanel = injector.outputPanel.getCurrentOutputPanel() as? OutputPanel ?: return
val keyCode = e.keyCode
val modifiers = e.modifiersEx
val keyStroke = KeyStroke.getKeyStroke(keyCode, modifiers)
if (isSingleLine) {
currentPanel.close(keyStroke)
e.consume()
return
}
// Multi-line mode: arrow keys scroll, down/right at end closes
when (keyCode) {
KeyEvent.VK_ENTER -> {
if (currentPanel.isAtEnd) currentPanel.close() else currentPanel.scrollLine()
e.consume()
}
KeyEvent.VK_DOWN -> if (currentPanel.isAtEnd) currentPanel.close(keyStroke) else currentPanel.scrollLine()
KeyEvent.VK_RIGHT -> if (currentPanel.isAtEnd) currentPanel.close(keyStroke) else currentPanel.scrollLine()
KeyEvent.VK_UP -> currentPanel.scrollOffset(-cachedLineHeight)
KeyEvent.VK_LEFT -> currentPanel.scrollOffset(-cachedLineHeight)
KeyEvent.VK_PAGE_DOWN -> if (currentPanel.isAtEnd) currentPanel.close(keyStroke) else currentPanel.scrollPage()
KeyEvent.VK_PAGE_UP -> currentPanel.scrollOffset(-scrollPane.verticalScrollBar.visibleAmount)
} }
} }
}
class LafListener : LafManagerListener {
override fun lookAndFeelChanged(source: LafManager) {
if (VimPlugin.isNotEnabled()) return
for (vimEditor in injector.editorGroup.getEditors()) {
val editor = (vimEditor as IjVimEditor).editor
if (!isPanelActive(editor)) continue
IJSwingUtilities.updateComponentTreeUI(getInstance(editor))
}
}
}
companion object {
fun getNullablePanel(editor: Editor): OutputPanel? {
return editor.vimMorePanel as OutputPanel?
}
fun isPanelActive(editor: Editor): Boolean {
return getNullablePanel(editor) != null
}
fun getInstance(editor: Editor): OutputPanel {
var panel: OutputPanel? = getNullablePanel(editor)
if (panel == null) {
panel = OutputPanel(editor)
editor.vimMorePanel = panel
}
return panel
}
}
}
data class TextLine(val text: String, val color: Color?)

View File

@@ -0,0 +1,23 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package com.maddyhome.idea.vim.ui
import com.intellij.openapi.wm.ToolWindowManager
import com.intellij.openapi.wm.ex.ToolWindowManagerListener
import javax.swing.SwingUtilities
/**
* Repositions a panel whenever a tool window visibility state changes.
* Shared between [com.maddyhome.idea.vim.ui.ex.ExEntryPanel] and [OutputPanel].
*/
internal class ToolWindowPositioningListener(private val reposition: () -> Unit) : ToolWindowManagerListener {
override fun stateChanged(toolWindowManager: ToolWindowManager) {
SwingUtilities.invokeLater(reposition)
}
}

View File

@@ -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

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

View File

@@ -22,11 +22,11 @@ class IjOutputPanelService : VimOutputPanelServiceBase() {
private var activeOutputPanel: WeakReference<VimOutputPanel>? = null private var activeOutputPanel: WeakReference<VimOutputPanel>? = null
override fun getCurrentOutputPanel(): VimOutputPanel? { override fun getCurrentOutputPanel(): VimOutputPanel? {
return activeOutputPanel?.get()?.takeIf { (it as OutputPanel).isActive } return activeOutputPanel?.get()?.takeIf { (it as OutputPanel).active }
} }
override fun create(editor: VimEditor, context: ExecutionContext): VimOutputPanel { override fun create(editor: VimEditor, context: ExecutionContext): VimOutputPanel {
val panel = OutputPanel(WeakReference(editor.ij)) val panel = OutputPanel.getInstance(editor.ij)
activeOutputPanel = WeakReference(panel) activeOutputPanel = WeakReference(panel)
return panel return panel
} }

View File

@@ -19,6 +19,7 @@ import org.jetbrains.plugins.ideavim.TestWithoutNeovim
import org.jetbrains.plugins.ideavim.VimTestCase import org.jetbrains.plugins.ideavim.VimTestCase
import org.jetbrains.plugins.ideavim.waitAndAssert import org.jetbrains.plugins.ideavim.waitAndAssert
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import kotlin.test.assertFalse
import kotlin.test.assertNotNull import kotlin.test.assertNotNull
import kotlin.test.assertTrue import kotlin.test.assertTrue
@@ -151,6 +152,27 @@ class CopyActionTest : VimTestCase() {
assertTrue(KeyHandler.getInstance().keyHandlerState.commandBuilder.isEmpty) 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 @Test
fun testWrongYankQuoteYankLine() { fun testWrongYankQuoteYankLine() {
assertPluginError(false) 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 * Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at * license that can be found in the LICENSE.txt file or at
@@ -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 @Test
fun `test undo with count`() { fun `test undo with count`() {
val keys = listOf("dwdwdw", "2u") val keys = listOf("dwdwdw", "2u")

View File

@@ -8,12 +8,14 @@
package org.jetbrains.plugins.ideavim.action.motion.search package org.jetbrains.plugins.ideavim.action.motion.search
import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.state.mode.Mode import com.maddyhome.idea.vim.state.mode.Mode
import com.maddyhome.idea.vim.state.mode.SelectionType import com.maddyhome.idea.vim.state.mode.SelectionType
import com.maddyhome.idea.vim.ui.ex.ExEntryPanel import com.maddyhome.idea.vim.ui.ex.ExEntryPanel
import org.jetbrains.plugins.ideavim.VimTestCase import org.jetbrains.plugins.ideavim.VimTestCase
import org.junit.jupiter.api.Disabled import org.junit.jupiter.api.Disabled
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse import kotlin.test.assertFalse
import kotlin.test.assertTrue import kotlin.test.assertTrue
@@ -27,6 +29,14 @@ class SearchEntryFwdActionTest : VimTestCase() {
assertStatusLineCleared() assertStatusLineCleared()
} }
@Test
fun `test search not found shows only error message on output panel`() {
configureByText("lorem ipsum dolor sit amet")
enterSearch("nonexistent")
val panelText = injector.outputPanel.getCurrentOutputPanel()?.text ?: ""
assertEquals("E486: Pattern not found: nonexistent", panelText)
}
@Test @Test
fun `search in visual mode`() { fun `search in visual mode`() {
doTest( doTest(

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

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

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

View File

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

View File

@@ -9,13 +9,20 @@
package org.jetbrains.plugins.ideavim.ex package org.jetbrains.plugins.ideavim.ex
import com.intellij.openapi.application.ApplicationManager 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.VimPlugin
import com.maddyhome.idea.vim.api.injector import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.newapi.vim import com.maddyhome.idea.vim.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.jetbrains.plugins.ideavim.action.ex.VimExTestCase
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertDoesNotThrow
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFalse import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue import kotlin.test.assertTrue
class ExEntryTest : VimExTestCase() { class ExEntryTest : VimExTestCase() {
@@ -210,4 +217,56 @@ class ExEntryTest : VimExTestCase() {
typeText(":echo <C-V>x80") typeText(":echo <C-V>x80")
assertRenderedExText("echo <80>") 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

@@ -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 * Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at * license that can be found in the LICENSE.txt file or at
@@ -134,7 +134,7 @@ class CmdCommandTest : VimTestCase() {
VimPlugin.getCommand().resetAliases() VimPlugin.getCommand().resetAliases()
configureByText("\n") configureByText("\n")
typeText(commandToKeys("command! -range Error echo <args>")) typeText(commandToKeys("command! -range Error echo <args>"))
assertPluginError(false) assertPluginError(true)
kotlin.test.assertEquals("'-range' is not supported by `command`", injector.messages.getStatusBarMessage()) kotlin.test.assertEquals("'-range' is not supported by `command`", injector.messages.getStatusBarMessage())
} }
@@ -143,7 +143,7 @@ class CmdCommandTest : VimTestCase() {
VimPlugin.getCommand().resetAliases() VimPlugin.getCommand().resetAliases()
configureByText("\n") configureByText("\n")
typeText(commandToKeys("command! -complete=color Error echo <args>")) typeText(commandToKeys("command! -complete=color Error echo <args>"))
assertPluginError(false) assertPluginError(true)
kotlin.test.assertEquals("'-complete' is not supported by `command`", injector.messages.getStatusBarMessage()) kotlin.test.assertEquals("'-complete' is not supported by `command`", injector.messages.getStatusBarMessage())
} }
@@ -191,6 +191,20 @@ class CmdCommandTest : VimTestCase() {
assertPluginError(true) 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 @Test
fun `test run command with arguments`() { fun `test run command with arguments`() {
VimPlugin.getCommand().resetAliases() 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

@@ -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 * Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at * license that can be found in the LICENSE.txt file or at
@@ -24,7 +24,6 @@ class ExecuteCommandTest : VimTestCase() {
fun `test execute with range`() { fun `test execute with range`() {
configureByText("\n") configureByText("\n")
typeText(commandToKeys("1,2execute 'echo 42'")) typeText(commandToKeys("1,2execute 'echo 42'"))
assertNoExOutput()
assertPluginError(true) assertPluginError(true)
} }

View File

@@ -72,7 +72,6 @@ class HistoryCommandTest : VimTestCase() {
fun `test history with 'history' option set to 0 shows nothing`() { fun `test history with 'history' option set to 0 shows nothing`() {
enterCommand("set history=0") enterCommand("set history=0")
enterCommand("history") enterCommand("history")
assertNoExOutput()
assertPluginError(false) assertPluginError(false)
assertPluginErrorMessage("'history' option is zero") assertPluginErrorMessage("'history' option is zero")
} }
@@ -225,17 +224,6 @@ class HistoryCommandTest : VimTestCase() {
) )
} }
@Test
fun `test history cmd lists empty command history`() {
assertCommandOutput(
"history cmd",
"""
| # cmd history
|> 1 history cmd
""".trimMargin()
)
}
@Test @Test
fun `test history cmd lists current cmd in history`() { fun `test history cmd lists current cmd in history`() {
assertCommandOutput( assertCommandOutput(
@@ -500,7 +488,7 @@ class HistoryCommandTest : VimTestCase() {
@Test @Test
fun `test history search with first number lists single entry from saerch history`() { fun `test history search with first number lists single entry from search history`() {
repeat(10) { i -> enterSearch("foo${i + 1}") } repeat(10) { i -> enterSearch("foo${i + 1}") }
injector.outputPanel.getCurrentOutputPanel()?.clearText() injector.outputPanel.getCurrentOutputPanel()?.clearText()
assertCommandOutput( assertCommandOutput(

View File

@@ -195,6 +195,7 @@ class SetCommandTest : VimTestCase() {
|nohlsearch nonumber nosneak wrap |nohlsearch nonumber nosneak wrap
| ide=IntelliJ IDEA operatorfunc= startofline wrapscan | ide=IntelliJ IDEA operatorfunc= startofline wrapscan
| clipboard=ideaput,autoselect | clipboard=ideaput,autoselect
| comments=s1:/*,mb:*,ex:*/,://,b:#,:%,:XCOMM,n:>,fb:-
| fileencoding=utf-8 | 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 | 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 |noideacopypreprocess
@@ -258,6 +259,7 @@ class SetCommandTest : VimTestCase() {
| clipboard=ideaput,autoselect | clipboard=ideaput,autoselect
| colorcolumn= | colorcolumn=
|nocommentary |nocommentary
| comments=s1:/*,mb:*,ex:*/,://,b:#,:%,:XCOMM,n:>,fb:-
|nocursorline |nocursorline
|nodigraph |nodigraph
|noexchange |noexchange

View File

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

View File

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

@@ -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 * Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at * license that can be found in the LICENSE.txt file or at
@@ -43,7 +43,6 @@ class AndFunctionTest : VimTestCase() {
@Test @Test
fun `test and function with list causes error`() { fun `test and function with list causes error`() {
enterCommand("echo and([1, 2, 3], [2, 3, 4])") enterCommand("echo and([1, 2, 3], [2, 3, 4])")
assertNoExOutput()
assertPluginError(true) assertPluginError(true)
assertPluginErrorMessage("E745: Using a List as a Number") assertPluginErrorMessage("E745: Using a List as a Number")
} }
@@ -51,7 +50,6 @@ class AndFunctionTest : VimTestCase() {
@Test @Test
fun `test and function with dict causes error`() { fun `test and function with dict causes error`() {
enterCommand("echo and({1: 2, 3: 4}, {3: 4, 5: 6})") enterCommand("echo and({1: 2, 3: 4}, {3: 4, 5: 6})")
assertNoExOutput()
assertPluginError(true) assertPluginError(true)
assertPluginErrorMessage("E728: Using a Dictionary as a Number") assertPluginErrorMessage("E728: Using a Dictionary as a Number")
} }
@@ -59,7 +57,6 @@ class AndFunctionTest : VimTestCase() {
@Test @Test
fun `test and function with float causes error`() { fun `test and function with float causes error`() {
enterCommand("echo and(1.5, 2.5)") enterCommand("echo and(1.5, 2.5)")
assertNoExOutput()
assertPluginError(true) assertPluginError(true)
assertPluginErrorMessage("E805: Using a Float as a Number") assertPluginErrorMessage("E805: Using a Float as a Number")
} }

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 * Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at * license that can be found in the LICENSE.txt file or at
@@ -28,7 +28,6 @@ class InvertFunctionTest : VimTestCase() {
@Test @Test
fun `test invert function with list causes error`() { fun `test invert function with list causes error`() {
enterCommand("echo invert([1, 2, 3])") enterCommand("echo invert([1, 2, 3])")
assertNoExOutput()
assertPluginError(true) assertPluginError(true)
assertPluginErrorMessage("E745: Using a List as a Number") assertPluginErrorMessage("E745: Using a List as a Number")
} }
@@ -36,7 +35,6 @@ class InvertFunctionTest : VimTestCase() {
@Test @Test
fun `test invert function with dict causes error`() { fun `test invert function with dict causes error`() {
enterCommand("echo invert({1: 2, 3: 4})") enterCommand("echo invert({1: 2, 3: 4})")
assertNoExOutput()
assertPluginError(true) assertPluginError(true)
assertPluginErrorMessage("E728: Using a Dictionary as a Number") assertPluginErrorMessage("E728: Using a Dictionary as a Number")
} }
@@ -44,7 +42,6 @@ class InvertFunctionTest : VimTestCase() {
@Test @Test
fun `test invert function with float causes error`() { fun `test invert function with float causes error`() {
enterCommand("echo invert(1.5)") enterCommand("echo invert(1.5)")
assertNoExOutput()
assertPluginError(true) assertPluginError(true)
assertPluginErrorMessage("E805: Using a Float as a Number") assertPluginErrorMessage("E805: Using a Float as a Number")
} }

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 * Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at * license that can be found in the LICENSE.txt file or at
@@ -43,7 +43,6 @@ class OrFunctionTest : VimTestCase() {
@Test @Test
fun `test or function with list causes error`() { fun `test or function with list causes error`() {
enterCommand("echo or([1, 2, 3], [2, 3, 4])") enterCommand("echo or([1, 2, 3], [2, 3, 4])")
assertNoExOutput()
assertPluginError(true) assertPluginError(true)
assertPluginErrorMessage("E745: Using a List as a Number") assertPluginErrorMessage("E745: Using a List as a Number")
} }
@@ -51,7 +50,6 @@ class OrFunctionTest : VimTestCase() {
@Test @Test
fun `test or function with dict causes error`() { fun `test or function with dict causes error`() {
enterCommand("echo or({1: 2, 3: 4}, {3: 4, 5: 6})") enterCommand("echo or({1: 2, 3: 4}, {3: 4, 5: 6})")
assertNoExOutput()
assertPluginError(true) assertPluginError(true)
assertPluginErrorMessage("E728: Using a Dictionary as a Number") assertPluginErrorMessage("E728: Using a Dictionary as a Number")
} }
@@ -59,7 +57,6 @@ class OrFunctionTest : VimTestCase() {
@Test @Test
fun `test or function with float causes error`() { fun `test or function with float causes error`() {
enterCommand("echo or(1.5, 2.5)") enterCommand("echo or(1.5, 2.5)")
assertNoExOutput()
assertPluginError(true) assertPluginError(true)
assertPluginErrorMessage("E805: Using a Float as a Number") assertPluginErrorMessage("E805: Using a Float as a Number")
} }

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 * Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at * license that can be found in the LICENSE.txt file or at
@@ -43,7 +43,6 @@ class XorFunctionTest : VimTestCase() {
@Test @Test
fun `test xor function with list causes error`() { fun `test xor function with list causes error`() {
enterCommand("echo xor([1, 2, 3], [2, 3, 4])") enterCommand("echo xor([1, 2, 3], [2, 3, 4])")
assertNoExOutput()
assertPluginError(true) assertPluginError(true)
assertPluginErrorMessage("E745: Using a List as a Number") assertPluginErrorMessage("E745: Using a List as a Number")
} }
@@ -51,7 +50,6 @@ class XorFunctionTest : VimTestCase() {
@Test @Test
fun `test xor function with dict causes error`() { fun `test xor function with dict causes error`() {
enterCommand("echo xor({1: 2, 3: 4}, {3: 4, 5: 6})") enterCommand("echo xor({1: 2, 3: 4}, {3: 4, 5: 6})")
assertNoExOutput()
assertPluginError(true) assertPluginError(true)
assertPluginErrorMessage("E728: Using a Dictionary as a Number") assertPluginErrorMessage("E728: Using a Dictionary as a Number")
} }
@@ -59,7 +57,6 @@ class XorFunctionTest : VimTestCase() {
@Test @Test
fun `test xor function with float causes error`() { fun `test xor function with float causes error`() {
enterCommand("echo xor(1.5, 2.5)") enterCommand("echo xor(1.5, 2.5)")
assertNoExOutput()
assertPluginError(true) assertPluginError(true)
assertPluginErrorMessage("E805: Using a Float as a Number") assertPluginErrorMessage("E805: Using a Float as a Number")
} }

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 * Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at * license that can be found in the LICENSE.txt file or at
@@ -33,7 +33,6 @@ class ToLowerFunctionTest : VimTestCase() {
@Test @Test
fun `test tolower with list causes error`() { fun `test tolower with list causes error`() {
enterCommand("echo tolower([1, 2, 3])") enterCommand("echo tolower([1, 2, 3])")
assertNoExOutput()
assertPluginError(true) assertPluginError(true)
assertPluginErrorMessage("E730: Using a List as a String") assertPluginErrorMessage("E730: Using a List as a String")
} }
@@ -41,7 +40,6 @@ class ToLowerFunctionTest : VimTestCase() {
@Test @Test
fun `test tolower with dict causes error`() { fun `test tolower with dict causes error`() {
enterCommand("echo tolower({1: 2, 3: 4})") enterCommand("echo tolower({1: 2, 3: 4})")
assertNoExOutput()
assertPluginError(true) assertPluginError(true)
assertPluginErrorMessage("E731: Using a Dictionary as a String") assertPluginErrorMessage("E731: Using a Dictionary as a String")
} }

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 * Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at * license that can be found in the LICENSE.txt file or at
@@ -33,7 +33,6 @@ class ToUpperFunctionTest : VimTestCase() {
@Test @Test
fun `test toupper with list causes error`() { fun `test toupper with list causes error`() {
enterCommand("echo toupper([1, 2, 3])") enterCommand("echo toupper([1, 2, 3])")
assertNoExOutput()
assertPluginError(true) assertPluginError(true)
assertPluginErrorMessage("E730: Using a List as a String") assertPluginErrorMessage("E730: Using a List as a String")
} }
@@ -41,7 +40,6 @@ class ToUpperFunctionTest : VimTestCase() {
@Test @Test
fun `test toupper with dict causes error`() { fun `test toupper with dict causes error`() {
enterCommand("echo toupper({1: 2, 3: 4})") enterCommand("echo toupper({1: 2, 3: 4})")
assertNoExOutput()
assertPluginError(true) assertPluginError(true)
assertPluginErrorMessage("E731: Using a Dictionary as a String") assertPluginErrorMessage("E731: Using a Dictionary as a String")
} }

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 * Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at * license that can be found in the LICENSE.txt file or at
@@ -207,7 +207,12 @@ class FunctionDeclarationTest : VimTestCase() {
typeText(commandToKeys("echo F1()")) typeText(commandToKeys("echo F1()"))
assertPluginError(true) assertPluginError(true)
assertPluginErrorMessage("E121: Undefined variable: x") assertPluginErrorMessage("E121: Undefined variable: x")
assertExOutput("0") assertExOutput(
"""
E121: Undefined variable: x
0
""".trimIndent()
)
typeText(commandToKeys("delf! F1")) typeText(commandToKeys("delf! F1"))
typeText(commandToKeys("delf! F2")) typeText(commandToKeys("delf! F2"))

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 * Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at * license that can be found in the LICENSE.txt file or at
@@ -154,7 +154,12 @@ class TryCatchTest : VimTestCase() {
), ),
) )
assertPluginError(true) assertPluginError(true)
assertExOutput("finally block") assertExOutput(
"""
finally block
my exception
""".trimIndent()
)
} }
@Test @Test

View File

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

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 * Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at * license that can be found in the LICENSE.txt file or at
@@ -223,7 +223,7 @@ class SearchGroupTest : VimTestCase() {
) { ) {
enterCommand("set nowrapscan") enterCommand("set nowrapscan")
} }
assertPluginError(false) assertPluginError(true)
assertPluginErrorMessage("E385: Search hit BOTTOM without match for: one") assertPluginErrorMessage("E385: Search hit BOTTOM without match for: one")
} }
@@ -242,7 +242,7 @@ class SearchGroupTest : VimTestCase() {
three three
""".trimIndent() """.trimIndent()
) )
assertPluginError(false) assertPluginError(true)
assertPluginErrorMessage("E486: Pattern not found: banana") assertPluginErrorMessage("E486: Pattern not found: banana")
} }
@@ -282,7 +282,7 @@ class SearchGroupTest : VimTestCase() {
) { ) {
enterCommand("set nowrapscan") enterCommand("set nowrapscan")
} }
assertPluginError(false) assertPluginError(true)
assertPluginErrorMessage("E384: Search hit TOP without match for: three") assertPluginErrorMessage("E384: Search hit TOP without match for: three")
} }
@@ -301,7 +301,7 @@ class SearchGroupTest : VimTestCase() {
three three
""".trimIndent() """.trimIndent()
) )
assertPluginError(false) assertPluginError(true)
assertPluginErrorMessage("E486: Pattern not found: banana") assertPluginErrorMessage("E486: Pattern not found: banana")
} }
@@ -615,7 +615,7 @@ class SearchGroupTest : VimTestCase() {
) )
enterCommand("set nowrapscan") enterCommand("set nowrapscan")
typeText("10", "/", searchCommand("one")) typeText("10", "/", searchCommand("one"))
assertPluginError(false) assertPluginError(true)
assertPluginErrorMessage("E385: Search hit BOTTOM without match for: one") assertPluginErrorMessage("E385: Search hit BOTTOM without match for: one")
assertPosition(2, 0) assertPosition(2, 0)
} }
@@ -679,7 +679,7 @@ class SearchGroupTest : VimTestCase() {
) )
enterCommand("set nowrapscan") enterCommand("set nowrapscan")
typeText("12", "?one<CR>") typeText("12", "?one<CR>")
assertPluginError(false) assertPluginError(true)
assertPluginErrorMessage("E384: Search hit TOP without match for: one") assertPluginErrorMessage("E384: Search hit TOP without match for: one")
assertPosition(8, 0) assertPosition(8, 0)
} }

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(),
)
}
}

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