Compare commits

..
Author SHA1 Message Date
chylex f89e51ec10 Set plugin version to 62 2026-10-15 08:54:29 +02:00
chylex dd4cb0e736 Disable paragraph-motion plugin bindings for operator-pending mode 2026-10-15 08:54:29 +02:00
chylex ffd5ccbc81 Fix backwards motions to unmatched parenthesis/brace consuming the character 2026-10-15 08:54:29 +02:00
chylex fdcd20bc19 Fix editor jumping with large scrolloff 2026-10-14 20:47:01 +02:00
chylex 183a20c936 Prevent caret from moving left when exiting insert mode at end of empty line 2026-10-14 20:47:01 +02:00
chylex 2f9a9a972a Make search highlights temporary 2026-10-14 20:47:01 +02:00
chylex c764f7455f Disable IdeaVim in popup editors 2026-10-14 20:47:01 +02:00
chylex 4a3c8b46a4 Fix AltGr not triggering Ctrl-Alt bindings on Windows 2026-10-14 20:47:00 +02:00
chylex 2ec581cdcf Add 'isinplacerenaming' function 2026-10-14 20:47:00 +02:00
chylex 0b4eecb284 Add 'isactionenabled' function 2026-10-14 20:47:00 +02:00
chylex 3c5a8760f4 Preserve visual mode after executing IDE action 2026-10-14 20:47:00 +02:00
chylex 7658d83564 Make g0/g^/g$ work with soft wraps 2026-10-14 20:47:00 +02:00
chylex 8d0b403c50 Make gj/gk jump over soft wraps 2026-10-14 17:37:23 +02:00
chylex c4362ddc01 Make camelCase motions adjust based on direction of visual selection 2026-10-14 17:37:22 +02:00
chylex 5eff0518f1 Do not switch to normal mode after inserting a live template 2026-10-14 17:37:22 +02:00
chylex f2fb835f87 Exit insert mode after refactoring 2026-10-14 17:37:22 +02:00
chylex 8c12c4a4fc Add action to run last macro in all opened files 2026-10-14 17:37:22 +02:00
chylex 500270f1ec Stop macro execution after a failed search 2026-10-14 17:37:22 +02:00
chylex 08cf6701ed Fix losing yanked text when number of carets changes 2026-10-14 17:37:22 +02:00
chylex a62604111c Apply scrolloff after executing native IDEA actions 2026-10-14 17:37:22 +02:00
chylex 38b26df573 Automatically add unambiguous imports after running a macro 2026-10-14 17:37:22 +02:00
chylex 7d414beaa0 Fix(VIM-3986): Exception when pasting register contents containing new line 2026-10-14 17:37:22 +02:00
chylex 0fae2bec79 Fix(VIM-3179): Respect virtual space below editor (imperfectly) 2026-10-14 17:37:22 +02:00
chylex f11750f4ea Fix(VIM-3178): Workaround to support "Jump to Source" action mapping 2026-10-14 17:37:22 +02:00
chylex 273beba127 Update search register when using f/t 2026-10-14 17:37:22 +02:00
chylex d57361ba0c Add support for count for visual and line motion surround 2026-10-14 17:37:22 +02:00
chylex 7437800180 Fix vim-surround not working with multiple cursors
Fixes multiple cursors with vim-surround commands `cs, ds, S` (but not `ys`).
2026-10-14 17:37:22 +02:00
chylex ddd83eac56 Make 'map' not affect Select mode 2026-10-14 17:37:22 +02:00
chylex 54d4f03c95 Change matchit plugin to use HTML patterns in unrecognized files 2026-10-14 17:37:22 +02:00
chylex 7dbf4fe83a Fix ex command panel causing Undock tool window to hide 2026-10-14 17:37:21 +02:00
chylex ebafeac717 Reset insert mode when switching active editor 2026-10-14 17:37:21 +02:00
chylex 9b30fa67e2 Remove notifications about configuration options 2026-10-14 17:37:21 +02:00
chylex e0f24cc6a7 Remove AI 2026-10-14 17:37:21 +02:00
chylex a71c8a319c Set custom plugin version 2026-10-14 17:37:21 +02:00
1grzyb1 b8f9ad7892 VIM-4228 Postpone caret movement after rider action 2026-10-14 16:28:42 +02:00
1grzyb1 0efcd70c6f VIM-4228 Move Rider tests to teamcity 2026-10-14 16:28:42 +02:00
1grzyb1 a00e382bdf VIM-4228 Refactor rider tests to use RemoteRobot 2026-10-14 16:28:42 +02:00
1grzyb1 713a61e362 Redraw panel only on current document changes
Without that even some random intelij change of gitignroe could redraw our panel what made Large file tests flaky
2026-10-14 11:50:01 +02:00
AndDe-gourav bb18bb5356 fix none selction on toggling visual form select mode on empty line and test for supporting selecting new line char 2026-10-14 08:31:08 +02:00
AndDe-gourav 2201617e81 join tests and passes deleteJoinRange an exculsive range 2026-10-14 08:31:08 +02:00
AndDe-gourav fe78002de0 fixed caret resting on new line char 2026-10-14 08:31:08 +02:00
AndDe-gourav 6cedadc455 vim-paragraph-motion test fix and other selection test fix 2026-10-14 08:31:08 +02:00
AndDe-gourav d2ac6b9fd8 fixed getRange for character wise selection 2026-10-14 08:31:08 +02:00
AndDe-gourav 8914f1da2d fix 'zf' collapse next line in visual mode 2026-10-14 08:31:08 +02:00
AndDe-gourav ee77c6de90 updated exchange test that include new line char
# Conflicts:
#	src/test/java/org/jetbrains/plugins/ideavim/extension/exchange/VimExchangeExtensionTest.kt
#	src/test/java/org/jetbrains/plugins/ideavim/extension/exchange/VimExchangeWithClipboardTest.kt
2026-10-14 08:31:08 +02:00
AndDe-gourav 3fdb747969 remove todo comment of not selecting new line char 2026-10-14 08:31:08 +02:00
AndDe-gourav f4272fe350 fix 'D' on empty(only new line char) visual line 2026-10-14 08:31:08 +02:00
AndDe-gourav 8b97cd8bed remove restriction not selecting new line char 2026-10-14 08:31:08 +02:00
claude[bot]andClaude Opus 5.5 518c5eab09 Update changelog: search count freeze in large files, message timer, ? history name
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 05:18:22 +00:00
1grzyb1 398b161704 Wait for rpc modal input before next keystrokes
When rpc call was made and it showed modal dialog keystroke were lost during that time
2026-10-13 15:41:38 +02:00
1grzyb1 2d72b1f083 Restart clear output panel timer on change
Each n after search didn't restart timer so after 10s output panel was closed. It broke new Large file tests this way.
2026-10-13 15:41:38 +02:00
1grzyb1 36ef5574bd Mae sure C-B is handled by ide in large file tests 2026-10-13 15:41:38 +02:00
claude[bot] c3f7c1c216 Update changelog: :! no longer hangs on commands reading stdin
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

 Conflicts:
	CHANGES.md
2026-10-13 11:01:29 +00:00
dependabot[bot] 1033626866 Bump org.junit.vintage:junit-vintage-engine from 6.1.2 to 6.1.3
Bumps [org.junit.vintage:junit-vintage-engine](https://github.com/junit-team/junit-framework) from 6.1.2 to 6.1.3.
- [Release notes](https://github.com/junit-team/junit-framework/releases)
- [Commits](https://github.com/junit-team/junit-framework/compare/r6.1.2...r6.1.3)

---
updated-dependencies:
- dependency-name: org.junit.vintage:junit-vintage-engine
  dependency-version: 6.1.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-13 10:57:32 +00:00
claude[bot]andClaude Opus 5.5 7cccaaa814 Support "?" as search history name in history functions
Vim's `:help hist-names` lists "?" alongside "/" as an alias for the
search history, but getTypeByString only recognised "/". As a result,
histadd('?', ...), histdel('?'), histget('?') and histnr('?') failed
instead of operating on the search history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 12:36:15 +02:00
1grzyb1 06778f5380 Generate ideavim_extensions.json only in root module 2026-10-13 12:16:43 +02:00
1grzyb1 738b73417f Load intellij.platform.rpc dependency from an optional module
intellij.platform.rpc doesn't exist in 2025.3, is embedded in the core
classloader in 2026.1-2026.2 and is a standalone module since 2026.3.
Declaring it as a dependency of the embedded ideavim-common module disables
the whole plugin on 2025.3 and breaks the 2025.3 build.

Move the dependency to a new optional ideavim-rpc module that provides
VimRemoteApiResolver via the IdeaVIM.remoteApiResolver extension point.
When the module is skipped (2025.3), fall back to calling
RemoteApiProviderService from the core classloader.
2026-10-13 12:16:43 +02:00
1grzyb1 81ad363113 VIM-4342 publish metrics for large file tests 2026-10-13 10:37:34 +02:00
1grzyb1 bd0f361afa VIM-4341 Run search count on background thread
For large files running search count blocked edt which caused IDE freezes.
Now we first try to quickly show coutn and if we are not able in given threshold move it to other thread and later on update UI accordingly.
Additionally added tests for large files
2026-10-13 10:37:34 +02:00
claude[bot]andClaude Opus 5.5 fbc9424f6f Update changelog: clever-f extension
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 05:22:27 +00:00
1grzyb1 9e142d4401 VIM-1975 implement clever-f extension 2026-10-12 12:06:01 +02:00
1grzyb1 19925409d5 VIM-4340 add standalone rpc module 2026-10-12 10:28:10 +02:00
grzybek 55549efd72 Remove in in notification massage 2026-10-12 08:55:50 +02:00
claude[bot]andClaude Opus 5.5 2275c21729 Update changelog: 2.47.3 release
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 05:21:26 +00:00
claude[bot]andClaude Opus 5.5 90f715dee5 Update changelog: :function command, cc indent, % and Vimscript fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 05:21:36 +00:00
claude[bot]andClaude Opus 5 4114fc1122 Update changelog: 2.46.1 release, percent register, g8 fixes
Adds a 2.46.1 section for the two fixes that went into the release (VIM-4310, VIM-4309), and documents the % register (VIM-1266) and the two g8 fixes under 2.47.0, the release they shipped in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:40:58 +00:00
claude[bot]andClaude Opus 5.5 644f9cf628 Fix: IndexOutOfBoundsException on % in an empty document
findMatchingPairOnCurrentLine read the character at the caret offset
without checking that the current line has any characters. On an empty
document this indexed past the end of the text. An empty line has no
pair characters to match, so return no match there, as Vim does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:30:02 +02:00
claude[bot]andClaude Opus 5 8e3aa39789 Update changelog: visual r caret position, End key handler
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-09 10:27:29 +00:00
claude[bot]andClaude Opus 5 1e46befc77 Keep replaceText character-wise for partial line ranges
determineSelectionType classified every range spanning more than one line as
line-wise. replaceText then built a line-wise VimSelection from it, which
expands to whole lines, so the text before startOffset on the first line and
after endOffset on the last one was silently discarded.

    // "one two three\nfour five six\nseven eight"
    replaceText(8, 18, "XXX")   // "three\nfour"
    // was:      "XXX\nseven eight"
    // expected: "one two XXX five six\nseven eight"

A multi-line range is now line-wise only when it actually covers those lines
completely, which is what ReplaceWithRegister's `2grr` needs; anything that
starts or ends mid-line stays character-wise and replaces exactly the
requested range.

This also drops the `isLine` check that the old code computed but could never
observe as true: it required the range to end with a line break while start
and end sat on the same line, and it was only consulted on the
startLine == endLine branch, where that cannot hold.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-09 12:18:29 +02:00
claude[bot]andClaude Opus 5 3058a337d4 Honour allowEnd in CaretTransaction's getLineEndOffset
CaretTransactionImpl declared its own getLineStartOffset, getLineEndOffset
and getLine, duplicating the implementations it already inherits through
`EditorAccessor by EditorAccessorImpl(projectId)`. The copies had drifted:
getLineEndOffset dropped the allowEnd argument and always called the
single-argument VimEditor.getLineEndOffset, so it behaved as allowEnd = true
regardless of what the caller asked for.

The visible effect is that the same EditorAccessor function disagrees with
itself depending on how deeply it is nested:

    change {
      getLineEndOffset(line, allowEnd = false)                  // last character
      withPrimaryCaret { getLineEndOffset(line, allowEnd = false) }  // line break
    }

ReadScopeImpl and TransactionImpl both rely on the delegate, so dropping the
three redundant overrides fixes the argument handling and removes the
duplication that let them drift apart in the first place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-09 12:18:29 +02:00
IdeaVim Bot 1d34cc3206 Update help file 2026-10-09 09:43:09 +00:00
1grzyb1 21ffd15bdb Add missing function command description 2026-10-09 11:40:31 +02:00
claude[bot]andClaude Opus 5 f90bc0bca6 Fix: cmdwin editor was never Vim initialised, so its buffer local options were missing
`isCommandHistoryWindow` used `Editor.getVirtualFile()`, which is not necessarily
populated while the editor is still being created - and that is exactly when it is
called, from `isAllowedFileEditor` via `vimDisabled`, to decide whether an editor
should be initialised. The command-line window opened by `q:`, `q/` and `q?` was
therefore classified as a non-Vim editor and `EditorListeners.add` never ran, so its
buffer never got its local option values.

Search count and search highlights are computed for the selected editor and for every
open editor, so searching for a pattern containing `\<` asked the cmdwin buffer for its
local 'iskeyword' and threw "Unexpected uninitialised buffer local value: iskeyword".

Resolve the file from the document instead, as every other check in `EditorHelper`
already does.

Found by the RandomActionsPropertyTest property tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-09 11:40:31 +02:00
claude[bot]andClaude Opus 5 f686ed5911 Add tests for exclusive and inclusive slice end indices
`SliceFunctionTest` covered negative and out-of-range end indices, but
not an end index that resolves to zero, which is the boundary where the
exclusive end index of `slice()` and the inclusive end index of a sublist
expression disagree. Verified against the previous implementation: the
`end index of zero` cases fail (returning the whole collection), and the
negative cases that resolve to zero or below already passed.

Also pin the sublist side of that boundary: an explicit `-1` end index,
`-length` (first item only), and `-length - 1` (empty). These passed
before, and guard the shared `VimList.slice`/`VimString.substring`
against a regression in the other direction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-09 10:46:17 +02:00
claude[bot]andClaude Opus 5 86e9130d87 Fix slice() treating an end index of zero as the whole collection
`VimList.slice` and `VimString.substring` treated an exclusive end index
of zero as "count from the end", i.e. as the size of the collection. That
convention exists only because `SublistExpression` has to convert Vim's
*inclusive* end index (which defaults to -1, the last item) into an
exclusive one, so `endInclusive + 1` legitimately produces zero for the
common `list[1:]` and `list[1:-1]` cases.

`slice()`, however, takes an end index that is already exclusive, so
`slice([1, 2, 3, 4, 5], 0, 0)` must be an empty list, and
`slice([1, 2, 3, 4, 5], 0, -5)` likewise resolves to an exclusive end of
zero. Both returned the entire collection instead. (See Vim's
`list_slice_or_index` / `eval_index_inner`, which decrement the end index
when it is exclusive and produce an empty result once it drops below
zero.)

Make the end index of `slice`/`substring` an unambiguous absolute index
and move the resolution of Vim's negative indices into the two call
sites, where the inclusive/exclusive distinction is known.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-09 10:46:17 +02:00
claude[bot]andClaude Opus 5.5 1a2cc3c401 Fix :tags failing when the line jumped from was deleted
Tag stack entries keep the line number they were recorded with and are not
adjusted on edits. When that line no longer exists, :tags called
getLineText() with an out-of-range line and failed with
"Incorrect line: N, out of M". Show empty text for such entries instead.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 10:40:55 +02:00
claude[bot]andClaude Opus 5.5 5e6c95c187 Fix: crash on empty search range such as :/ or :?
A search range consisting of a single delimiter made SearchAddress call
substring(1, 0), throwing StringIndexOutOfBoundsException. An empty
pattern now uses the last search pattern, as in Vim, and reports E35
when there is none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:39:32 +02:00
claude[bot]andClaude Opus 5.5 c78d2f326c Fix infinite loop on :continue inside :for over a List
In ForLoop (List iterable) and ForLoopWithList, the Continue branch used
Kotlin's `continue`, which jumped back to the `while` condition and
skipped `index += 1`. As a result `for x in [1, 2] | continue | endfor`
never terminated. Fall through to the increment instead.

Add regression tests for continue/break when iterating over lists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:30:23 +02:00
valisandClaude Opus 5.5 c11a903c5b Don't treat quotes inside non-string tokens as string delimiters in %
The text-based quote detection used by % treats any two quote characters
on a line as a string. In languages where quotes can be part of other
tokens (e.g., identifiers with primes like `f'` in Haskell-like languages),
brackets between such quotes were skipped, and % jumped to a wrong
bracket or did not move at all.

If PSI is available, check that the opening quote starts a token and the
closing quote ends a token. Plain text and comments keep the text-based
behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:29:15 +02:00
Matt Ellis ac52d3a0e4 Handle incomplete function definition command 2026-10-09 10:16:30 +02:00
Matt Ellis 165b9b1fa5 Report lambda functions in function command 2026-10-09 10:16:30 +02:00
Matt Ellis 2b5f5401ac Restart anonymous function names from 1 in tests 2026-10-09 10:16:30 +02:00
Matt Ellis 6620891534 Add the function command to list global functions 2026-10-09 10:16:30 +02:00
Matt Ellis f51223ffbb Fix red code referencing generated ANTLR code 2026-10-09 10:16:30 +02:00
1grzyb1 5fd38b21b3 Improve release process
Our pipeline could mark issues on youtrack as fixed even if they were not in release branch.
Added single script for all pipelines to deremine which commits enetered given release so changelog, whats new and release itself are using exactly same logic
2026-10-09 10:13:18 +02:00
1grzyb1 1e3c671c0c VIM-2524 calculate indent after cc
set proper indent after performing cc. previously we were just using that was already set not from code style
2026-10-09 09:49:32 +02:00
claude[bot]andClaude Opus 5.5 0e5e95d02d Update changelog: visual-multi extension
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 05:18:15 +00:00
1grzyb1 ca384caae4 VIM-2293 Add visual-multi documentation 2026-10-08 11:25:42 +02:00
1grzyb1 f3003d92ef VIM-2293 Align cursors 2026-10-08 11:25:42 +02:00
1grzyb1 c898281cd9 VIM-2293 Case conversion menu 2026-10-08 11:25:42 +02:00
1grzyb1 e084c387fa VIM-2293 Quit session after leaving insert mode 2026-10-08 11:25:42 +02:00
1grzyb1 52b33469ec VIM-2293 Reselect regions of the last session 2026-10-08 11:25:42 +02:00
1grzyb1 4bc3e56898 VIM-2293 Insert before and after regions 2026-10-08 11:25:42 +02:00
1grzyb1 38489c3177 VIM-2293 Run macro at cursors 2026-10-08 11:25:42 +02:00
1grzyb1 807aa55dcc VIM-2293 Run normal command at cursors 2026-10-08 11:25:42 +02:00
1grzyb1 9062301f21 VIM-2293 Test paste at cursors 2026-10-08 11:25:42 +02:00
1grzyb1 b6e2bc32a9 VIM-2293 Add switch mode <Tab> 2026-10-08 11:25:42 +02:00
1grzyb1 3b92308f9c VIM-2293 Go to next/prev caret 2026-10-08 11:25:42 +02:00
1grzyb1 2295e96509 VIM-2293 Find Prev occurrence 2026-10-08 11:25:42 +02:00
1grzyb1 e4bccc976e VIM-2293 Visual cursors actions 2026-10-08 11:25:42 +02:00
1grzyb1 ae6198400b VIM-2293 C-Up C-Down for line navigation 2026-10-08 11:25:42 +02:00
1grzyb1 b3413f5924 VIM-2293 accepts count in handlers 2026-10-08 11:25:42 +02:00
1grzyb1 5ea29e1a6e VIM-2293 add VM_leader option 2026-10-08 11:25:42 +02:00
1grzyb1 92af7a9b58 VIM-2293 Cleanup visual multi code 2026-10-08 11:25:42 +02:00
1grzyb1 7b9cf20a19 VIM-2293 Add VM session
visual multi keeps track if it has active session and depending on that different mappins are performed
2026-10-08 11:25:42 +02:00
1grzyb1 9ed7a53709 VIM-2293 Add ignore case option 2026-10-08 11:25:42 +02:00
1grzyb1 fd310620e7 VIM-2293 Add VM_maps variable for mapping 2026-10-08 11:25:42 +02:00
1grzyb1 30c876dd59 VIM-2293 Add vim visual multiple extension
Extracted base class to nto duplicated code in both multi cursor extensions. new one has different mappings for some actions
2026-10-08 11:25:42 +02:00
claude[bot]andClaude Opus 5.5 d7b5df3ebc Update changelog: remappable NERDTree navigation keys
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 05:22:03 +00:00
1grzyb1 37aeff64aa VIM-2372 Add missing mappings for navigation keys
Allow users to map missing nerdtree keys like j or k
2026-10-07 09:13:53 +02:00
claude[bot]andClaude Opus 5.5 723e45deb9 Update changelog: ideascrollcursor, sethandler for Enter/Esc/arrows, dead keys
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 05:21:26 +00:00
1grzyb1 543d577bfa VIM-1503 don't type dead keys
Composed text from dead keys and input methods was inserted into the document and replaced the Visual selection before IdeaVim saw the key, so we now drop it in Normal, Visual and Operator-pending modes and only let the committed character through, like Vim in a terminal.
2026-10-06 12:20:18 +02:00
1grzyb1 e0193e6f3b VIM-1948 Scroll caret while mouse scrolling
Move caret while mouse scrolling respecting scrolloff
guarded by ideascrollcursor to preserve old idea behaviour
2026-10-06 12:11:06 +02:00
1grzyb1 f6ee0c0f11 VIM-720 allow pass escape to IDE 2026-10-06 11:46:19 +02:00
1grzyb1 2282456ddd VIM-720 allow pass enter and arrows to IDE 2026-10-06 11:46:19 +02:00
claude[bot]andClaude Opus 5.5 d092c00547 Update changelog: :help file, action ID completion, VIM-4336
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 05:21:48 +00:00
1grzyb1 6f48fd9903 Complete action command ideavimrc file 2026-10-05 12:04:26 +02:00
1grzyb1 fe7bc72193 Complete action command in command line 2026-10-05 12:04:26 +02:00
1grzyb1 ccb0fb1677 Add descriptions to all commands
Each @ExCommand and @CommandOrMotion now describes what it does in IdeaVim, the text is rendered into the help file.
2026-10-05 10:57:50 +02:00
1grzyb1 c1d9ee7378 Generate the help file that :help opens from command annotations
Commands describe themselves in @ExCommand/@CommandOrMotion, generateHelp renders the Vim-style help file and a workflow keeps it up to date on master.
2026-10-05 10:57:50 +02:00
1grzyb1 360d3d4b69 VIM-4336 postpone caret normalization for async actions
In Rider actions like comment are async and afterActionPerformed happens right after action axecution without waiting for action to actually finish.
Which resulted in first normalizing carret by IdeaVim and then moving it by comment action in wrong place
2026-10-05 09:50:57 +02:00
claude[bot]andClaude Opus 5.5 e597ca1069 Update changelog: 2.47.1/2.47.2 releases, V on collapsed folds
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 05:20:08 +00:00
IdeaVim Bot f65c5e28f5 Preparation to 2.47.2 release 2026-10-02 10:05:53 +00:00
Grzybek e27ffe0549 VIM-1894 include collapsed region during visual selection (#2098) 2026-10-02 08:35:07 +00:00
1grzyb1 8ad1ab8bdf VIM-4335 document ideaoutsideeditor option 2026-10-02 10:22:27 +02:00
1grzyb1 69d845f92f VIM-4335 remove dispatcher listeners on option change
otherwise it wasn't detected right away
2026-10-02 10:22:27 +02:00
Grzybek f94d5b05dc VIM-4335 guard outside editor dispatcher when ideaoutsideeditor (#2100) 2026-10-02 09:27:45 +02:00
claude[bot]andClaude Opus 5.5 77d6a1d78f Update changelog: keylog extension, Replace mode paste
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 05:21:41 +00:00
1grzyb1 2ec828b529 VIM-1314 Replace mode paste with cmd+v
To be vim compatible we need to wrap idea paste during replace mode.
Vim is pasting and replacing text and we were inserting it before cursor.
So to this we wrap paste action handler and in replace mode we handle it by ourselves
2026-10-01 11:54:35 +02:00
Grzybek b00370888a VIM-1306 Add keylog extension (#2096)
Extension that logs all typed keys
2026-10-01 10:56:08 +02:00
claude[bot]andClaude Opus 5.5 a1efa6f18a Update changelog: ideaeditor main/chat values, CLion = fix
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 05:25:53 +00:00
dependabot[bot] c06802d135 Bump gradle-wrapper from 9.7.1 to 9.8.0
Bumps [gradle-wrapper](https://github.com/gradle/gradle) from 9.7.1 to 9.8.0.
- [Release notes](https://github.com/gradle/gradle/releases)
- [Commits](https://github.com/gradle/gradle/compare/v9.7.1...v9.8.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-30 15:37:08 +00:00
Grzybek 7bd8f1315c VIM-3963 Allow enabling vim for plugin editors and ai chat windows (#2091)
* VIM-3963 enable vim for main editors

* VIM-3963 enable vim for jetbrains AI chat
2026-09-30 12:28:19 +02:00
Grzybek 5bc0344547 VIM-4333 Fix auto-indent handling in clion (#2090)
use directly formatter for clion to indent lines otherwise it was formatting whole file
2026-09-30 08:38:08 +00:00
claude[bot]andClaude Opus 5.5 76a26d51f2 Update changelog: VIM-1153 . replays typed keystrokes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 05:22:58 +00:00
Grzybek d99f46937f VIM-1153 replay all keystrokes during . (#2088)
* VIM-1153 replay all keystrokes during .

we were storing actions and commands to replay during . repeat. Due to that we had to do a lot of strange logic to move cursor after ide chagnes. Becouse of that renaming tag element didn't work.

To make it work and more vim like we store real keystrokes and replay them

* VIM-1153 replay autocomplete

in new mechanism C-Y was working on active lookup and during replay there was no lookup. to fix that we go through action path which adds those keys by itself but we need to drop C-Y from RedoBuffer stack as it's replaved by actruall charachters

* VIM-1153 Fix other lookup actions

* VIM-1153 keep lookup commands as separate ones

Extracted all lookup commands as separate handlers as it was confusing to have seprate paths depending on isActiveLookup in handlers
2026-09-29 10:31:40 +00:00
claude[bot]andClaude Opus 5.5 6e47c141de Update changelog: VIM-4331 ticket link and PR 2084
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 05:19:03 +00:00
IdeaVim Bot 5b2a1725ec Preparation to 2.47.1 release 2026-09-25 09:24:56 +00:00
Grzybek 60d17b0c83 VIM-4331 keep the indent of a new line when leaving Insert mode (#2084)
`didAutoIndent` was set for `o`, `O` and Enter as well as for `cc` and `S`,
so leaving Insert mode without typing anything deleted the indent of a line
that had just been opened.

Vim does delete it, and it does so for an indent computed by 'indentexpr' or
'cindent' too (`fixthisline()` in indent.c). This is a deliberate difference:
in the IDE the indent comes from language support, and a line you have just
opened is expected to stay where it is. `cc` and `S` still follow Vim, since
there the line held text a moment ago and its leftover indent is noise.
2026-09-25 10:40:17 +02:00
claude[bot]andClaude Opus 5.5 2b55e12b15 Update changelog: | separator for :s and other Ex commands
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 05:20:56 +00:00
Grzybek f5fd057ba1 VIM-748 changing multiple commands with | (#2082)
* VIM-748 changing multiple commands with |

Added generic mechanism for chainging multiple commands with |
Main issue was with comamnds like `s` which might have | inside replaced regex
That's why we've adde whole machinery that split it in CommandVisitor.
We couldn't do it it antlr grammar itself as it would require having backwards state of grammer what antlr doesn't allow

* VIM-748 bump ksp version to 2.3.7
2026-09-24 12:36:34 +02:00
IdeaVim Bot aa14a3036e Preparation to 2.47.0 release 2026-09-24 08:46:08 +00:00
fe53a09522 Prepare What's New for the upcoming release (#2081)
* Prepare What's New for the upcoming release

Add missing changelog entries (Visual r caret, <End> sethandler, removed
surveys) and rebuild whatsnew-tbr.html from the To Be Released section.

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 09:36:46 +02:00
claude[bot]andClaude Opus 5.5 57aa1424ab Update changelog: autoindent, ideaeditor, ideavimrc path, matchit match_words
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 05:22:12 +00:00
dependabot[bot] 7ec829344e Bump io.ktor:ktor-client-core from 3.5.2 to 3.6.0
Bumps [io.ktor:ktor-client-core](https://github.com/ktorio/ktor) from 3.5.2 to 3.6.0.
- [Release notes](https://github.com/ktorio/ktor/releases)
- [Changelog](https://github.com/ktorio/ktor/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ktorio/ktor/compare/3.5.2...3.6.0)

---
updated-dependencies:
- dependency-name: io.ktor:ktor-client-core
  dependency-version: 3.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-23 15:40:36 +00:00
1grzyb1 c24adece73 VIM-3796 support match_words in matchit
it allows to define custom matching words in matchit.
Also refactor plugin to use vim regex instead of java to make it more vim like behaviour
2026-09-23 12:34:12 +02:00
1grzyb1 719335d4cb Debug failing claude actions 2026-09-23 09:13:14 +02:00
Alex PlateandClaude Opus 5 33d0e5b3ef Remove user surveys
Drop the uninstall survey browser popup and the "Take Survey" entry in the
status bar widget menu.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 15:18:15 +03:00
1grzyb1 d682906047 VIM-3198 add fast scroll in nerdtree
add mapping for <c-d> and <c-u> in nerdtree for fast scroll
2026-09-22 10:31:04 +02:00
grzybekandClaude Opus 5 a087a508b5 VIM-3093 Scope search highlights to the viewport
Vim computes 'hlsearch' per screen cell during redraw and keeps no highlight objects at all, while IdeaVim materialises a RangeHighlighter per match, so highlighting the whole buffer put tens of thousands of them in the markup model for the 35 lines the user can see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 11:58:29 +02:00
grzybekandClaude Opus 5 154f81d1d6 VIM-3093 Stop deriving the search match model from the highlighters
Once the highlighters cover only the viewport they are no longer a complete model of the matches, so the two callers that read the match list back out of them need another source.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 11:58:29 +02:00
grzybekandClaude Opus 5 c7149c3129 VIM-3093 Give search highlights a single owner per editor
Nothing kept the vimLastHighlighters list and the markup model in step, and nothing could answer which part of the document is currently highlighted - both of which scoping the highlights to the viewport needs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 11:58:29 +02:00
grzybek 46cf018d32 VIM-4326 run mapping from select mode in visual mode
Vim when mapping is run on select mode it runs it in visual and then restores
2026-09-18 09:19:01 +02:00
grzybek e552a2ba51 VIM-2627 Handle nullable PathMacroManager.expandPath on 2026. 2026-09-17 13:17:33 +02:00
grzybek f13c3e333c VIM-4318 Adjust Visual mark columns when lines merge 2026-09-17 13:17:33 +02:00
dependabot[bot] 03d4d7ab31 Bump org.jetbrains.kotlin:kotlin-stdlib from 2.4.10 to 2.4.20
Bumps [org.jetbrains.kotlin:kotlin-stdlib](https://github.com/JetBrains/kotlin) from 2.4.10 to 2.4.20.
- [Release notes](https://github.com/JetBrains/kotlin/releases)
- [Changelog](https://github.com/JetBrains/kotlin/blob/master/ChangeLog.md)
- [Commits](https://github.com/JetBrains/kotlin/compare/v2.4.10...v2.4.20)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-16 15:43:34 +00:00
dependabot[bot] 8a8ada7bcb Bump org.eclipse.jgit:org.eclipse.jgit.ssh.apache
Bumps [org.eclipse.jgit:org.eclipse.jgit.ssh.apache](https://github.com/eclipse-jgit/jgit) from 7.7.1.202607240634-r to 7.8.0.202609011348-r.
- [Commits](https://github.com/eclipse-jgit/jgit/compare/v7.7.1.202607240634-r...v7.8.0.202609011348-r)

---
updated-dependencies:
- dependency-name: org.eclipse.jgit:org.eclipse.jgit.ssh.apache
  dependency-version: 7.8.0.202609011348-r
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-16 15:42:39 +00:00
dependabot[bot] e465c9c962 Bump com.google.devtools.ksp:symbol-processing-api from 2.3.11 to 2.3.12
Bumps [com.google.devtools.ksp:symbol-processing-api](https://github.com/google/ksp) from 2.3.11 to 2.3.12.
- [Release notes](https://github.com/google/ksp/releases)
- [Commits](https://github.com/google/ksp/compare/2.3.11...2.3.12)

---
updated-dependencies:
- dependency-name: com.google.devtools.ksp:symbol-processing-api
  dependency-version: 2.3.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-16 15:41:34 +00:00
1grzyb1 bf210ba468 VIM-3999 list option to enable vim in other editors
As we now have more editors that we are not always want to enable vim in it make more sense to have list option to enable/disable them there
2026-09-14 11:51:57 +02:00
1grzyb1 f9d455073e VIM-3999 enable vim in debug console 2026-09-14 11:51:57 +02:00
1grzyb1 0ae4b2698a VIM-2629 commentary block comments variable
Allow to disbale char-ise block comments.
Original plugin doesn't support comment part of line.
Here we add `commentary_block_comments` variable to allow to disable it
2026-09-14 08:57:22 +02:00
717 changed files with 30082 additions and 4048 deletions
@@ -0,0 +1,156 @@
name: Claude run post-mortem
description: >
Unpacks the execution log that claude-code-action writes, so a failed run shows the
real cause instead of just "Claude result reported subtype success with is_error:true".
inputs:
execution_file:
description: The execution_file output of the claude-code-action step.
required: true
label:
description: Shown in the log/summary to identify which Claude step this belongs to.
required: false
default: Claude run
outputs:
verdict:
description: One of ok, credentials, quota, timeout, tool_error, unknown, no_log.
value: ${{ steps.postmortem.outputs.verdict }}
detail:
description: One-line human-readable explanation of the verdict.
value: ${{ steps.postmortem.outputs.detail }}
runs:
using: composite
steps:
- id: postmortem
shell: bash
env:
EXEC_FILE: ${{ inputs.execution_file }}
LABEL: ${{ inputs.label }}
run: |
set -uo pipefail
VERDICT="unknown"
DETAIL=""
if [ -z "$EXEC_FILE" ] || [ ! -f "$EXEC_FILE" ]; then
# No log at all means the CLI died before the first turn - usually auth or a
# bad base URL, because a quota error still produces a session log.
echo "::warning title=No Claude execution log::claude-code-action produced no execution_file; the CLI failed before it started a session (bad base URL or rejected credentials are the usual causes)."
VERDICT="no_log"
DETAIL="no execution log was produced - the CLI never started a session"
{
echo "### $LABEL post-mortem"
echo ""
echo "- Verdict: \`no_log\`"
echo "- $DETAIL"
echo ""
} >> "$GITHUB_STEP_SUMMARY"
{
echo "verdict=$VERDICT"
echo "detail=$DETAIL"
} >> "$GITHUB_OUTPUT"
exit 0
fi
echo "::group::$LABEL - result entry"
# The log is a JSON array of session entries; the last `result` entry is the verdict.
RESULT=$(jq -c '[.[] | select(.type == "result")] | last // {}' "$EXEC_FILE" 2>/dev/null || echo '{}')
jq -r '
"subtype: \(.subtype // "<none>")",
"is_error: \(.is_error // false)",
"num_turns: \(.num_turns // "<none>")",
"duration_ms: \(.duration_ms // "<none>")",
"total_cost_usd: \(.total_cost_usd // "<none>")",
"usage: \(.usage // {} | tojson)",
"denials: \((.permission_denials // []) | map(.tool_name) | unique | join(", "))"
' <<<"$RESULT"
echo "::endgroup::"
echo "::group::$LABEL - result text"
RESULT_TEXT=$(jq -r '.result // .error // ""' <<<"$RESULT")
echo "${RESULT_TEXT:-<empty>}"
echo "::endgroup::"
# Anything in the whole transcript that looks like an API-level failure. The API
# error that killed the run is usually in an assistant/system entry, not the result.
echo "::group::$LABEL - API error lines in the transcript"
# Walk every string in the log rather than regexing the raw JSON: API errors
# arrive as JSON embedded in a string, so their quotes are escaped.
API_ERRORS=$(jq -r '[.. | strings] | .[]' "$EXEC_FILE" 2>/dev/null \
| grep -iE 'rate.?limit|quota|credit balance|insufficient|authenticat|unauthori|forbidden|expired|revoked|invalid.?(api.?key|x-api-key|token|bearer)|overloaded|api error|\b(401|402|403|429|500|502|503|529)\b' \
| cut -c1-300 | sort -u | head -20 || true)
echo "${API_ERRORS:-<none found>}"
echo "::endgroup::"
HAYSTACK=$(printf '%s\n%s' "$RESULT_TEXT" "$API_ERRORS" | tr '[:upper:]' '[:lower:]')
IS_ERROR=$(jq -r '.is_error // false' <<<"$RESULT")
SUBTYPE=$(jq -r '.subtype // ""' <<<"$RESULT")
case "$HAYSTACK" in
*authentication_error*|*unauthorized*|*"401"*|*"invalid api key"*|*"invalid x-api-key"*|*"token has expired"*|*"jwt expired"*)
VERDICT="credentials"
DETAIL="the transcript contains an authentication failure - the token is expired or revoked"
;;
*rate_limit_error*|*"rate limit"*|*quota*|*"credit balance"*|*"insufficient"*|*"429"*|*"402"*)
VERDICT="quota"
DETAIL="the transcript contains a rate-limit/quota failure"
;;
*permission_error*|*forbidden*|*"403"*)
VERDICT="credentials"
DETAIL="the transcript contains a 403 - the token is not authorized for this model/service"
;;
*overloaded*|*"500"*|*"502"*|*"503"*|*"529"*)
VERDICT="proxy"
DETAIL="the transcript contains upstream 5xx/overloaded errors"
;;
*)
if [ "$SUBTYPE" = "error_max_turns" ]; then
VERDICT="timeout"
DETAIL="the run hit the max-turns limit"
elif [ "$IS_ERROR" = "true" ]; then
VERDICT="tool_error"
DETAIL="Claude finished with is_error=true but no API-level error - the prompt or a tool failed, see the result text above"
else
VERDICT="ok"
DETAIL="the run completed without an error result"
fi
;;
esac
echo "$LABEL post-mortem verdict: $(printf '%s' "$VERDICT" | tr '[:lower:]' '[:upper:]') - $DETAIL"
{
echo "### $LABEL post-mortem"
echo ""
echo "- Verdict: \`$VERDICT\`"
echo "- \`subtype\`: \`${SUBTYPE:-<none>}\`, \`is_error\`: \`$IS_ERROR\`"
echo "- Detail: $DETAIL"
if [ -n "$RESULT_TEXT" ]; then
echo ""
echo "<details><summary>Result text</summary>"
echo ""
echo '```'
printf '%s\n' "$RESULT_TEXT" | head -c 4000
echo '```'
echo ""
echo "</details>"
fi
if [ -n "$API_ERRORS" ]; then
echo ""
echo "<details><summary>API error lines</summary>"
echo ""
echo '```'
printf '%s\n' "$API_ERRORS" | head -c 4000
echo '```'
echo ""
echo "</details>"
fi
echo ""
} >> "$GITHUB_STEP_SUMMARY"
{
echo "verdict=$VERDICT"
echo "detail=$DETAIL"
} >> "$GITHUB_OUTPUT"
+240
View File
@@ -0,0 +1,240 @@
name: Claude pre-flight check
description: >
Probes the Claude proxy before (or after) running claude-code-action and tells you
whether the credentials are dead or the quota is gone. claude-code-action only ever
reports "result is_error:true", which hides the actual HTTP failure.
inputs:
base_url:
description: Proxy base URL (the value passed as ANTHROPIC_BASE_URL, i.e. secrets.PROXY_URL).
required: true
jwt:
description: The Grazie JWT / API key (secrets.ANTHROPIC_API_KEY).
required: true
model:
description: Model id used for the probe request. Must be a model the proxy accepts.
required: false
default: claude-haiku-3-5-20241022
label:
description: Shown in the log/summary so a "before" and an "after" probe can be told apart.
required: false
default: pre-flight
fail_on_error:
description: Fail the step when the probe does not come back healthy. Diagnostics only by default.
required: false
default: 'false'
outputs:
verdict:
description: One of ok, credentials, quota, proxy, network, unknown.
value: ${{ steps.probe.outputs.verdict }}
http_status:
description: HTTP status returned by the proxy (000 when the request never completed).
value: ${{ steps.probe.outputs.http_status }}
detail:
description: One-line human-readable explanation of the verdict.
value: ${{ steps.probe.outputs.detail }}
runs:
using: composite
steps:
- id: probe
shell: bash
env:
BASE_URL: ${{ inputs.base_url }}
JWT: ${{ inputs.jwt }}
MODEL: ${{ inputs.model }}
LABEL: ${{ inputs.label }}
FAIL_ON_ERROR: ${{ inputs.fail_on_error }}
run: |
set -uo pipefail
# The secret is masked by Actions already, but this step is also used with
# values that did not come from `secrets.` in every caller.
[ -n "$JWT" ] && echo "::add-mask::$JWT"
echo "::group::Claude $LABEL check"
VERDICT="unknown"
DETAIL=""
HTTP_STATUS="000"
JWT_EXPIRED="false"
# ---------------------------------------------------------------
# 1. Are the inputs even present?
# ---------------------------------------------------------------
if [ -z "$BASE_URL" ]; then
echo "ANTHROPIC_BASE_URL / PROXY_URL is EMPTY - the secret is missing or not exposed to this workflow."
VERDICT="credentials"
DETAIL="PROXY_URL secret is empty"
fi
if [ -z "$JWT" ]; then
echo "ANTHROPIC_API_KEY is EMPTY - the secret is missing or not exposed to this workflow."
VERDICT="credentials"
DETAIL="ANTHROPIC_API_KEY secret is empty"
fi
echo "Base URL host: $(printf '%s' "$BASE_URL" | sed -E 's#^[a-z]+://([^/]+).*#\1#')"
echo "Token length: ${#JWT} chars"
# ---------------------------------------------------------------
# 2. If the token is a JWT, has it simply expired?
# Only timestamps and claim NAMES are printed - never claim values,
# which may carry user identity.
# ---------------------------------------------------------------
NOW=$(date -u +%s)
# GNU date on the runner, BSD date when this is run locally.
fmt_ts() { date -u -d "@$1" '+%Y-%m-%d %H:%M:%S UTC' 2>/dev/null || date -u -r "$1" '+%Y-%m-%d %H:%M:%S UTC' 2>/dev/null || printf 'epoch %s' "$1"; }
if [ "$(printf '%s' "$JWT" | awk -F. '{print NF}')" -eq 3 ]; then
PAYLOAD=$(printf '%s' "$JWT" | cut -d. -f2)
PAD=$(( (4 - ${#PAYLOAD} % 4) % 4 ))
[ "$PAD" -gt 0 ] && PAYLOAD="${PAYLOAD}$(printf '=%.0s' $(seq 1 "$PAD"))"
CLAIMS=$(printf '%s' "$PAYLOAD" | tr '_-' '/+' | base64 -d 2>/dev/null || true)
if printf '%s' "$CLAIMS" | jq -e . >/dev/null 2>&1; then
echo "Token is a JWT. Claims present: $(printf '%s' "$CLAIMS" | jq -r 'keys | join(", ")')"
IAT=$(printf '%s' "$CLAIMS" | jq -r '.iat // empty')
EXP=$(printf '%s' "$CLAIMS" | jq -r '.exp // empty')
[ -n "$IAT" ] && echo " issued at: $(fmt_ts "$IAT")"
if [ -n "$EXP" ]; then
echo " expires at: $(fmt_ts "$EXP")"
if [ "$EXP" -le "$NOW" ]; then
echo " >>> TOKEN IS EXPIRED (by $(( (NOW - EXP) / 86400 )) day(s)). Rotate the ANTHROPIC_API_KEY secret."
VERDICT="credentials"
DETAIL="JWT expired $(( (NOW - EXP) / 86400 )) day(s) ago"
JWT_EXPIRED="true"
else
echo " valid for another $(( (EXP - NOW) / 86400 )) day(s)."
fi
else
echo " no exp claim - the token does not expire on its own."
fi
else
echo "Token has three dot-separated parts but the payload is not JSON; treating it as an opaque key."
fi
else
echo "Token is not a JWT (no exp claim to inspect); relying on the live probe below."
fi
# ---------------------------------------------------------------
# 3. Live probe: one 1-token request through the same proxy and the
# same headers claude-code-action uses. The HTTP status is the
# answer the action swallows.
# ---------------------------------------------------------------
URL="${BASE_URL%/}/v1/messages"
BODY_FILE="$RUNNER_TEMP/claude-probe-body.json"
HDR_FILE="$RUNNER_TEMP/claude-probe-headers.txt"
if [ -n "$BASE_URL" ] && [ -n "$JWT" ]; then
HTTP_STATUS=$(curl -sS --max-time 60 -o "$BODY_FILE" -D "$HDR_FILE" -w '%{http_code}' \
-X POST "$URL" \
-H 'content-type: application/json' \
-H 'anthropic-version: 2023-06-01' \
-H "x-api-key: $JWT" \
-H "authorization: Bearer $JWT" \
-H "Grazie-Authenticate-JWT: $JWT" \
-H 'Grazie-Agent: {"name":"ideavim-claude-code-action","version":"github-actions-preflight"}' \
-d "{\"model\":\"$MODEL\",\"max_tokens\":1,\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}]}" \
2>"$RUNNER_TEMP/claude-probe-curl.err") || true
# A curl that never connected prints nothing usable; normalise it to 000.
case "$HTTP_STATUS" in [0-9][0-9][0-9]) ;; *) HTTP_STATUS="000" ;; esac
echo "Probe: POST \$BASE_URL/v1/messages (model $MODEL) -> HTTP $HTTP_STATUS"
[ -s "$RUNNER_TEMP/claude-probe-curl.err" ] && sed 's/^/ curl: /' "$RUNNER_TEMP/claude-probe-curl.err"
# Rate-limit and retry headers say "quota" far more clearly than the body does.
grep -iE '^(retry-after|anthropic-ratelimit|x-ratelimit|x-should-retry|grazie-)' "$HDR_FILE" \
| sed 's/^/ header: /' || true
ERR_TYPE=$(jq -r '.error.type // .type // empty' "$BODY_FILE" 2>/dev/null || true)
ERR_MSG=$(jq -r '.error.message // .message // empty' "$BODY_FILE" 2>/dev/null || true)
if [ -n "$ERR_TYPE$ERR_MSG" ]; then
echo " error.type: ${ERR_TYPE:-<none>}"
echo " error.message: ${ERR_MSG:-<none>}"
else
# Not an Anthropic-shaped error - the proxy answered in its own format.
echo " raw response (first 400 chars): $(head -c 400 "$BODY_FILE" | tr -d '\n')"
fi
LOWER=$(printf '%s %s' "$ERR_TYPE" "$ERR_MSG" | tr '[:upper:]' '[:lower:]')
case "$HTTP_STATUS" in
200|201)
VERDICT="ok"
DETAIL="proxy accepted a live request"
;;
401)
VERDICT="credentials"
DETAIL="HTTP 401 - the token is rejected (expired or revoked)"
;;
403)
# Grazie returns 403 both for a revoked grant and for an exhausted licence quota.
case "$LOWER" in
*quota*|*limit*|*balance*|*credit*)
VERDICT="quota"
DETAIL="HTTP 403 mentioning quota/limit - the licence quota is exhausted"
;;
*)
VERDICT="credentials"
DETAIL="HTTP 403 - the token is valid but no longer authorized for this model/service"
;;
esac
;;
402)
VERDICT="quota"
DETAIL="HTTP 402 - billing/credit balance exhausted"
;;
429)
VERDICT="quota"
DETAIL="HTTP 429 - rate limit or quota exhausted"
;;
400|404|405|422)
VERDICT="proxy"
DETAIL="HTTP $HTTP_STATUS - request rejected by the proxy (likely an unsupported model id: $MODEL)"
;;
5*)
VERDICT="proxy"
DETAIL="HTTP $HTTP_STATUS - the proxy or upstream is failing"
;;
000)
VERDICT="network"
DETAIL="the request never completed (DNS, TLS or timeout)"
;;
*)
VERDICT="unknown"
DETAIL="unexpected HTTP $HTTP_STATUS"
;;
esac
fi
# A live 200 outranks the local exp claim (the proxy may not enforce it), but the
# stale token still deserves to show up in the verdict line.
if [ "$VERDICT" = "ok" ] && [ "$JWT_EXPIRED" = "true" ]; then
DETAIL="$DETAIL (note: the JWT's own exp claim is in the past)"
fi
echo "::endgroup::"
case "$VERDICT" in
ok) ICON="OK" ;;
credentials) ICON="CREDENTIALS" ;;
quota) ICON="QUOTA" ;;
*) ICON="$(printf '%s' "$VERDICT" | tr '[:lower:]' '[:upper:]')" ;;
esac
echo "Claude $LABEL verdict: $ICON - $DETAIL"
{
echo "### Claude $LABEL check"
echo ""
echo "- Verdict: \`$VERDICT\`"
echo "- HTTP status: \`$HTTP_STATUS\`"
echo "- Detail: $DETAIL"
echo ""
} >> "$GITHUB_STEP_SUMMARY"
{
echo "verdict=$VERDICT"
echo "http_status=$HTTP_STATUS"
echo "detail=$DETAIL"
} >> "$GITHUB_OUTPUT"
if [ "$FAIL_ON_ERROR" = "true" ] && [ "$VERDICT" != "ok" ]; then
echo "::error title=Claude $LABEL check failed::$DETAIL"
exit 1
fi
+119 -8
View File
@@ -1,5 +1,10 @@
name: Prepare What's New with Claude
# The PR goes to master so that master keeps every page and the full changelog, but the page itself
# describes the `release` branch: the release is published from that branch as is (only the EAP build
# moves it to master), so changes that are only on master are not part of the upcoming release. The
# TeamCity release build takes the merged whatsnew-tbr.html from master and promotes it.
on:
workflow_dispatch: # Manually triggered by a maintainer
@@ -21,6 +26,32 @@ jobs:
with:
fetch-depth: 0 # Full history so the changelog can be analyzed and the PR branch pushed
- name: Fetch the release branch
id: release
run: |
git fetch origin release:refs/remotes/origin/release
echo "commit=$(git rev-parse origin/release)" >> "$GITHUB_OUTPUT"
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
# Claude runs prepareReleaseChangelog.ts to see which changelog entries the release contains
- name: Install script dependencies
run: npm ci --no-fund --no-audit
working-directory: scripts-ts
# Tells apart "the JWT expired" from "the quota is gone" BEFORE Claude runs -
# claude-code-action reports both as a bare `result is_error:true`.
- name: Check Claude credentials and quota
id: preflight
uses: ./.github/actions/claude-preflight
with:
base_url: ${{ secrets.PROXY_URL }}
jwt: ${{ secrets.ANTHROPIC_API_KEY }}
label: pre-flight
- name: Run Claude Code to update the changelog and prepare the What's New page
id: claude
uses: anthropics/claude-code-action@v1
@@ -53,23 +84,34 @@ jobs:
gone, so USUALLY it does NOT exist when this workflow runs — only the version-specific
pages for older releases (`whatsnew-<version>.html`) remain.
Read the now-updated `## [To Be Released]` section of `CHANGES.md` (everything between
that heading and the next dated release heading, e.g. `## 2.39.0, ...`). Use only its
Features and Fixes; you may skip Merged PRs.
### Describe only what the release will contain
The release is built from the `origin/release` branch, which may be BEHIND master, so not
everything under `## [To Be Released]` in `CHANGES.md` is part of it. After Step 1, run
exactly this command (do not work it out from `git log` yourself):
scripts-ts/node_modules/.bin/tsx scripts-ts/src/prepareReleaseChangelog.ts --print . origin/release
It prints the `## To Be Released` section the release will be published with - the same one
the release build puts in the plugin. Below, "the release section" means this output. Use
only its Features and Fixes; you may skip Merged PRs. The entries it leaves out stay in
`CHANGES.md`; they will be on the page of the next release. Run it again if you change
`CHANGES.md` afterwards.
Leave the `<!-- whatsnew-release-commit: ... -->` line at the top of the page as it is
(or do not add one): this workflow records there which release commit the page describes.
First decide what to do by checking the working tree:
- List `src/main/resources/whatsnew-*.html`.
- **If `whatsnew-tbr.html` does NOT exist (the normal case):** create it fresh,
describing every Feature and Fix currently under `## [To Be Released]`.
describing every Feature and Fix in the release section.
- **If `whatsnew-tbr.html` ALREADY exists (it was generated earlier this cycle):**
do NOT rebuild it from scratch. Diff it against the current `## [To Be Released]`
section — work out which changelog entries are NOT yet represented on the page — and
do NOT rebuild it from scratch. Diff it against the release section — work out which
changelog entries are NOT yet represented on the page — and
update the page to incorporate ONLY those new/changed items, preserving the existing
sections, ordering and wording already present. This is an incremental update, not a
rewrite. (If nothing in the changelog is new relative to the page, make no changes
and do not open a PR.)
- If, after Step 1, `## [To Be Released]` still has no Features or Fixes, STOP:
- If the release section has no Features or Fixes, STOP:
do not create or change the HTML and do not open a PR. Explain why in your final message.
### Style — match the existing pages EXACTLY
@@ -111,7 +153,76 @@ jobs:
- Body: a short summary of the changelog entries added and the What's New highlights.
# Changelog needs the skill + git/gh/web; the page generation needs file read/write.
claude_args: '--allowed-tools "Skill,Read,Write,Edit,Glob,Grep,Bash(git:*),Bash(gh:*),WebSearch,WebFetch(domain:plugins.jetbrains.com),WebFetch(domain:youtrack.jetbrains.com),WebFetch(domain:github.com)"'
claude_args: '--allowed-tools "Skill,Read,Write,Edit,Glob,Grep,Bash(git:*),Bash(gh:*),Bash(scripts-ts/node_modules/.bin/tsx scripts-ts/src/prepareReleaseChangelog.ts:*),WebSearch,WebFetch(domain:plugins.jetbrains.com),WebFetch(domain:youtrack.jetbrains.com),WebFetch(domain:github.com)"'
# The release build refuses a page written for another commit of the release branch: an EAP build or a
# cherry-pick after this run changes what the release contains. So the commit is recorded even when
# the page itself needed no changes - then in a PR of its own.
- name: Record the release commit the page describes
env:
GH_TOKEN: ${{ github.token }}
RELEASE_COMMIT: ${{ steps.release.outputs.commit }}
run: |
set -e
tbr="src/main/resources/whatsnew-tbr.html"
marker="<!-- whatsnew-release-commit: $RELEASE_COMMIT -->"
git config user.name "IdeaVim Bot"
git config user.email "maintainers@ideavim.dev"
git fetch origin master
pr=$(gh pr list --head whatsnew-tbr --state open --json number --jq '.[0].number // empty')
if [ -n "$pr" ]; then
git fetch origin whatsnew-tbr
git checkout -f -B whatsnew-tbr origin/whatsnew-tbr
elif git cat-file -e "origin/master:$tbr" 2>/dev/null; then
git checkout -f -B whatsnew-tbr origin/master
else
echo "No What's New page to record the release commit in"
exit 0
fi
if grep -qxF "$marker" "$tbr"; then
echo "The page already describes release commit $RELEASE_COMMIT"
exit 0
fi
sed -i '/^<!-- whatsnew-release-commit: .* -->$/d' "$tbr"
sed -i "1i $marker" "$tbr"
git commit -qam "Record release commit $RELEASE_COMMIT for What's New"
if [ -n "$pr" ]; then
git push origin whatsnew-tbr
echo "Recorded release commit $RELEASE_COMMIT in PR #$pr"
else
git push -f origin whatsnew-tbr
gh pr create --base master --head whatsnew-tbr \
--title "Prepare What's New for the upcoming release" \
--body "The release branch moved to $RELEASE_COMMIT, and the What's New page needed no changes for it. This only records the new commit, so the release build accepts the page."
fi
# Decodes the session log that the action writes but never prints.
- name: Claude run post-mortem
if: ${{ always() }}
id: postmortem
uses: ./.github/actions/claude-postmortem
with:
execution_file: ${{ steps.claude.outputs.execution_file }}
label: Prepare What's New
# Re-probe after a failure: a pre-flight that was OK plus a post-flight that is not
# means the quota ran out during this very run.
- name: Re-check Claude credentials and quota after a failure
if: ${{ failure() }}
uses: ./.github/actions/claude-preflight
with:
base_url: ${{ secrets.PROXY_URL }}
jwt: ${{ secrets.ANTHROPIC_API_KEY }}
label: post-failure
- name: Upload the Claude execution log
if: ${{ always() && steps.claude.outputs.execution_file != '' }}
uses: actions/upload-artifact@v4
with:
name: claude-execution-log
path: ${{ steps.claude.outputs.execution_file }}
if-no-files-found: ignore
retention-days: 14
- name: Comment rendered preview link on the PR
if: ${{ always() }}
-159
View File
@@ -1,159 +0,0 @@
name: Run UI Rider Tests macOS
on:
workflow_dispatch:
schedule:
- cron: '0 12 * * *'
jobs:
build-for-ui-test-mac-os:
if: github.repository == 'JetBrains/ideavim'
runs-on: macos-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: zulu
java-version: 21
- name: Setup FFmpeg
run: brew install ffmpeg
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
with:
cache-read-only: false
- name: Build Plugin
run: gradle :buildPlugin
- name: Run Idea
run: |
mkdir -p build/reports
gradle :runIdeForUiTests -PideaType=RD > build/reports/idea.log &
- name: List available capture devices
run: ffmpeg -f avfoundation -list_devices true -i "" 2>&1 || true
continue-on-error: true
- name: Start screen recording
run: |
mkdir -p build/reports/ci-screen-recording
ffmpeg -f avfoundation -capture_cursor 1 -i "0:none" -r 30 -vcodec libx264 -pix_fmt yuv420p build/reports/ci-screen-recording/screen-recording.mp4 &
echo $! > /tmp/ffmpeg_pid.txt
continue-on-error: true
- name: Auto-click Allow button for screen recording permission
run: |
sleep 3
brew install cliclick || true
for coords in "512:367" "960:540" "640:400" "800:450"; do
x=$(echo $coords | cut -d: -f1)
y=$(echo $coords | cut -d: -f2)
echo "Trying coordinates: $x,$y"
cliclick c:$x,$y 2>/dev/null && echo "cliclick succeeded" && break
sleep 0.5
osascript -e "tell application \"System Events\" to click at {$x, $y}" 2>/dev/null && echo "AppleScript succeeded" && break
sleep 1
done
continue-on-error: true
- name: Wait for Idea started
uses: jtalk/url-health-check-action@v3
with:
url: http://127.0.0.1:8082
max-attempts: 100
retry-delay: 10s
- name: Tests
run: gradle :tests:ui-rd-tests:testUi
env:
RIDER_LICENSE: ${{ secrets.RIDER_LICENSE }}
- name: Stop screen recording
if: always()
run: |
if [ -f /tmp/ffmpeg_pid.txt ]; then
kill $(cat /tmp/ffmpeg_pid.txt) || true
sleep 2
fi
continue-on-error: true
- name: Move sandbox logs
if: always()
run: mv build/idea-sandbox/RD-*/log_runIdeForUiTests idea-sandbox-log
- name: AI Analysis of Test Failures
if: failure()
uses: anthropics/claude-code-action@v1
env:
ANTHROPIC_BASE_URL: ${{ secrets.PROXY_URL }}
ANTHROPIC_CUSTOM_HEADERS: |
Grazie-Agent: {"name":"ideavim-claude-code-action","version":"github-actions"}
Grazie-Authenticate-JWT: ${{ secrets.ANTHROPIC_API_KEY }}
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
claude_args: '--allowed-tools "Read,Write,Edit,Glob,Grep,Bash(ffmpeg:*),Bash(ffprobe:*),Bash(mkdir:*),Bash(touch:*),Bash(echo:*),Bash(ls:*),Bash(cat:*),Bash(grep:*),Bash(find:*),Bash(cd:*),Bash(rm:*),Bash(git:*),Bash(gh:*),Bash(gradle:*),Bash(./gradlew:*),Bash(java:*),Bash(which:*)"'
prompt: |
## Task: Analyze UI Test Failures
Please analyze the UI test failures in the current directory.
Key information:
- Test reports are located in: build/reports and tests/ui-rd-tests/build/reports
- There is a CI screen recording at build/reports/ci-screen-recording/screen-recording.mp4 that shows what happened during the entire test run - this video is usually very useful for understanding what went wrong visually
- There is also a single screenshot at tests/ui-rd-tests/build/reports/ideaVimTest.png showing the state when the test failed
- IDE sandbox logs are in the idea-sandbox-log directory
- ffmpeg is already installed and available. Useful commands for video analysis:
* Extract frame at specific time: `ffmpeg -i video.mp4 -ss 00:01:30 -vframes 1 output.png`
* Extract multiple frames: `ffmpeg -i video.mp4 -vf fps=1/10 frame_%04d.png` (1 frame every 10 seconds)
* Get video duration: `ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 video.mp4`
* Extract last N seconds: `ffmpeg -sseof -10 -i video.mp4 -update 1 last_frame.png` (last 10 seconds)
* Create thumbnail grid: `ffmpeg -i video.mp4 -vf "select='not(mod(n\,300))',scale=160:120,tile=5x5" -frames:v 1 grid.png`
Special troubleshooting for timeout failures:
- If the test fails because it waited for something to appear, but according to the video and screenshot the element actually appeared on screen, check the UI hierarchy file at build/reports/hierarchy-ideaVimTest.html
- This hierarchy file shows the actual structure of UI elements and their properties at the time of failure
- The failure may be caused by a renamed property or changed class name in the UI element
- If you find this is the case, suggest a new query or selector that matches the current element structure
Please provide:
1. A detailed analysis of what went wrong
2. The root cause of the failure
3. Potential fixes or suggestions
Write your analysis to build/reports/ai-analysis.txt
## UI Test Best Practices
When fixing UI tests, follow these principles:
**Cause-Effect Over Timeouts**: UI tests should wait for specific conditions rather than arbitrary timeouts.
- ✅ GOOD: Wait for a button to become visible or enabled before clicking it
- ✅ GOOD: Wait for specific text to appear in a component
- ✅ GOOD: Wait for a dialog to be present with a specific accessible name
- ❌ BAD: Use Thread.sleep() or fixed delays
- ❌ BAD: Wait for arbitrary timeouts without checking for specific conditions
Only use timeouts as a maximum wait duration with explicit condition checks (e.g., "wait up to 30 seconds for dialog to appear").
Tests should be deterministic and based on observable state changes in the UI, not time-based assumptions.
IMPORTANT: If you have a concrete suggestion for fixing the test, ALWAYS proceed with creating a branch and PR. Never ask for permission - just do it.
If you have a concrete suggestion for fixing the test:
1. Create a new branch with a descriptive name (e.g., fix/ui-test-accessible-name)
2. Apply your suggested fix to the codebase
3. CRITICAL: When staging changes, NEVER use `git add -A` or `git add .`. Always add modified files explicitly by path (e.g., `git add path/to/file.kt path/to/other.kt`). This prevents accidentally staging unrelated files.
4. MANDATORY: Verify compilation succeeds with your changes: `gradle compileKotlin compileTestKotlin`
5. MANDATORY: Run the specific failing test to verify the fix improves or resolves the issue
- For Rider UI tests: `gradle :tests:ui-rd-tests:testUi --tests "YourTestClassName.yourTestMethod"`
- To run all Rider UI tests: `gradle :tests:ui-rd-tests:testUi`
- The test MUST either pass completely or show clear improvement (e.g., progressing further before failure)
6. If the test passes or shows improvement with your fix, create a PR with:
- Clear title describing the fix
- Description explaining the root cause and solution
- Test results showing the fix works
- Reference to the failing CI run
7. Use the base branch 'master' for the PR
- name: Save report
if: always()
uses: actions/upload-artifact@v4
with:
name: ui-test-fails-report-mac
path: |
build/reports
tests/ui-rd-tests/build/reports
idea-sandbox-log
-153
View File
@@ -1,153 +0,0 @@
name: Run UI Rider Tests Linux
on:
workflow_dispatch:
schedule:
- cron: '0 12 * * *'
jobs:
build-for-ui-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 FFmpeg
run: sudo apt-get update && sudo apt-get install -y ffmpeg
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
- name: Build Plugin
run: gradle :buildPlugin --configuration-cache
- name: Start Xvfb
run: |
export DISPLAY=:99.0
Xvfb :99 -screen 0 1280x720x24 &
echo "DISPLAY=:99.0" >> $GITHUB_ENV
- name: Run Idea
run: |
mkdir -p build/reports
gradle :runIdeForUiTests -PideaType=RD > build/reports/idea.log 2>&1 &
- name: Start screen recording
run: |
mkdir -p build/reports/ci-screen-recording
ffmpeg -f x11grab -video_size 1280x720 -i :99.0 -r 15 -vcodec libx264 -preset ultrafast -crf 28 -pix_fmt yuv420p build/reports/ci-screen-recording/screen-recording.mp4 &
echo $! > /tmp/ffmpeg_pid.txt
continue-on-error: true
- name: Wait for Idea started
uses: jtalk/url-health-check-action@v3
with:
url: http://127.0.0.1:8082
max-attempts: 100
retry-delay: 10s
- name: Tests
run: gradle :tests:ui-rd-tests:testUi
env:
RIDER_LICENSE: ${{ secrets.RIDER_LICENSE }}
- name: Stop screen recording
if: always()
run: |
if [ -f /tmp/ffmpeg_pid.txt ]; then
kill $(cat /tmp/ffmpeg_pid.txt) || true
sleep 2
fi
continue-on-error: true
- name: Move sandbox logs
if: always()
run: mv build/idea-sandbox/RD-*/log_runIdeForUiTests idea-sandbox-log
- name: AI Analysis of Test Failures
if: failure()
uses: anthropics/claude-code-action@v1
env:
ANTHROPIC_BASE_URL: ${{ secrets.PROXY_URL }}
ANTHROPIC_CUSTOM_HEADERS: |
Grazie-Agent: {"name":"ideavim-claude-code-action","version":"github-actions"}
Grazie-Authenticate-JWT: ${{ secrets.ANTHROPIC_API_KEY }}
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
claude_args: '--allowed-tools "Read,Write,Edit,Glob,Grep,Bash(ffmpeg:*),Bash(ffprobe:*),Bash(mkdir:*),Bash(touch:*),Bash(echo:*),Bash(ls:*),Bash(cat:*),Bash(grep:*),Bash(find:*),Bash(cd:*),Bash(rm:*),Bash(git:*),Bash(gh:*),Bash(gradle:*),Bash(./gradlew:*),Bash(java:*),Bash(which:*)"'
prompt: |
## Task: Analyze UI Test Failures
Please analyze the UI test failures in the current directory.
Key information:
- Test reports are located in: build/reports and tests/ui-rd-tests/build/reports
- There is a CI screen recording at build/reports/ci-screen-recording/screen-recording.mp4 that shows what happened during the entire test run - this video is usually very useful for understanding what went wrong visually
- There is also a single screenshot at tests/ui-rd-tests/build/reports/ideaVimTest.png showing the state when the test failed
- IDE sandbox logs are in the idea-sandbox-log directory
- ffmpeg is already installed and available. Useful commands for video analysis:
* Extract frame at specific time: `ffmpeg -i video.mp4 -ss 00:01:30 -vframes 1 output.png`
* Extract multiple frames: `ffmpeg -i video.mp4 -vf fps=1/10 frame_%04d.png` (1 frame every 10 seconds)
* Get video duration: `ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 video.mp4`
* Extract last N seconds: `ffmpeg -sseof -10 -i video.mp4 -update 1 last_frame.png` (last 10 seconds)
* Create thumbnail grid: `ffmpeg -i video.mp4 -vf "select='not(mod(n\,300))',scale=160:120,tile=5x5" -frames:v 1 grid.png`
Special troubleshooting for timeout failures:
- If the test fails because it waited for something to appear, but according to the video and screenshot the element actually appeared on screen, check the UI hierarchy file at build/reports/hierarchy-ideaVimTest.html
- This hierarchy file shows the actual structure of UI elements and their properties at the time of failure
- The failure may be caused by a renamed property or changed class name in the UI element
- If you find this is the case, suggest a new query or selector that matches the current element structure
Please provide:
1. A detailed analysis of what went wrong
2. The root cause of the failure
3. Potential fixes or suggestions
Write your analysis to build/reports/ai-analysis.txt
## UI Test Best Practices
When fixing UI tests, follow these principles:
**Cause-Effect Over Timeouts**: UI tests should wait for specific conditions rather than arbitrary timeouts.
- ✅ GOOD: Wait for a button to become visible or enabled before clicking it
- ✅ GOOD: Wait for specific text to appear in a component
- ✅ GOOD: Wait for a dialog to be present with a specific accessible name
- ❌ BAD: Use Thread.sleep() or fixed delays
- ❌ BAD: Wait for arbitrary timeouts without checking for specific conditions
Only use timeouts as a maximum wait duration with explicit condition checks (e.g., "wait up to 30 seconds for dialog to appear").
Tests should be deterministic and based on observable state changes in the UI, not time-based assumptions.
IMPORTANT: If you have a concrete suggestion for fixing the test, ALWAYS proceed with creating a branch and PR. Never ask for permission - just do it.
If you have a concrete suggestion for fixing the test:
1. Create a new branch with a descriptive name (e.g., fix/ui-test-accessible-name)
2. Apply your suggested fix to the codebase
3. CRITICAL: When staging changes, NEVER use `git add -A` or `git add .`. Always add modified files explicitly by path (e.g., `git add path/to/file.kt path/to/other.kt`). This prevents accidentally staging unrelated files.
4. MANDATORY: Verify compilation succeeds with your changes: `gradle compileKotlin compileTestKotlin`
5. MANDATORY: Run the specific failing test to verify the fix improves or resolves the issue
- For Rider UI tests: `gradle :tests:ui-rd-tests:testUi --tests "YourTestClassName.yourTestMethod"`
- To run all Rider UI tests: `gradle :tests:ui-rd-tests:testUi`
- The test MUST either pass completely or show clear improvement (e.g., progressing further before failure)
6. If the test passes or shows improvement with your fix, create a PR with:
- Clear title describing the fix
- Description explaining the root cause and solution
- Test results showing the fix works
- Reference to the failing CI run
7. Use the base branch 'master' for the PR
- name: Save report
if: always()
uses: actions/upload-artifact@v4
with:
name: ui-test-fails-report-linux
path: |
build/reports
tests/ui-rd-tests/build/reports
idea-sandbox-log
+53
View File
@@ -0,0 +1,53 @@
# Regenerates the help file that :help opens (src/main/resources/help/ideavim.txt) from the descriptions in the
# @ExCommand and @CommandOrMotion annotations, and commits it when it changed.
name: Update Help
on:
workflow_dispatch:
push:
branches: [ master ]
# The commit of this workflow changes only the help file, there is nothing to regenerate after it
paths-ignore:
- 'src/main/resources/help/**'
# A newer push regenerates the help from a newer state, so the older run can be dropped
concurrency:
group: update-help
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
if: github.repository == 'JetBrains/ideavim'
steps:
- uses: actions/checkout@v4
with:
# A key that is allowed to push to the protected master branch
ssh-key: ${{ secrets.PUSH_TO_PROTECTED_BRANCH_SECRET }}
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'corretto'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
- name: Generate help
run: ./gradlew generateHelp
env:
ORG_GRADLE_PROJECT_downloadIdeaSources: false
ORG_GRADLE_PROJECT_instrumentPluginCode: false
- name: Commit changes
uses: stefanzweifel/git-auto-commit-action@v4
with:
commit_message: Update help file
commit_user_name: IdeaVim Bot
commit_user_email: maintainers@ideavim.dev
commit_author: IdeaVim Bot <maintainers@ideavim.dev>
file_pattern: src/main/resources/help/ideavim.txt
@@ -67,6 +67,7 @@ jobs:
- Keep the entire `<head>`/`<style>` block, every CSS class, the
`data-theme="__THEME__"` attribute and the `__VERSION__` token unchanged.
- Never hard-code a version; leave `__VERSION__` exactly as-is.
- Keep the `<!-- whatsnew-release-commit: ... -->` line at the top unchanged.
- Escape HTML correctly inside `code`/`kbd`/`pre` (`&lt;` `&gt;` `&amp;`).
- Keep the friendly, user-facing tone; no `VIM-####` ids or raw issue URLs.
+2
View File
@@ -19,10 +19,12 @@
<option value="$PROJECT_DIR$/ideavim-common" />
<option value="$PROJECT_DIR$/ideavim-frontend" />
<option value="$PROJECT_DIR$/ideavim-rider" />
<option value="$PROJECT_DIR$/ideavim-rpc" />
<option value="$PROJECT_DIR$/ideavim-terminal" />
<option value="$PROJECT_DIR$/scripts" />
<option value="$PROJECT_DIR$/tests" />
<option value="$PROJECT_DIR$/tests/java-tests" />
<option value="$PROJECT_DIR$/tests/large-file-tests" />
<option value="$PROJECT_DIR$/tests/long-running-tests" />
<option value="$PROJECT_DIR$/tests/property-tests" />
<option value="$PROJECT_DIR$/tests/split-mode-tests" />
+69
View File
@@ -0,0 +1,69 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package _Self
import _Self.buildTypes.LargeFileTests
import jetbrains.buildServer.configs.kotlin.v2019_2.CustomChart
import jetbrains.buildServer.configs.kotlin.v2019_2.CustomChart.Serie
import jetbrains.buildServer.configs.kotlin.v2019_2.CustomChart.SeriesKey
import jetbrains.buildServer.configs.kotlin.v2019_2.ProjectFeatures
import jetbrains.buildServer.configs.kotlin.v2019_2.projectCustomChart
/**
* Trend charts for the timings that the large file tests publish (see IdeaVimLargeFileTestBase.publishMetric), shown in
* the project's Statistics tab
*/
fun ProjectFeatures.largeFileCharts() {
largeFileChart("GotoDeclaration", "Large files: Go to Declaration with other large files visible") {
listOf(0, 1, 3).map { serie("$it other large editors", "gotoDeclaration.otherLargeEditors$it") }
}
largeFileChart("SearchLarge", "Large files: search in a 200k-line file") {
listOf(
serie("/ in 1k lines (baseline)", "search.nohlsearch.log1k.search.caretMs"),
serie("/", "search.nohlsearch.log200k.search.caretMs"),
serie("n", "search.nohlsearch.log200k.next.caretMs"),
serie("*", "search.nohlsearch.log200k.star.caretMs"),
serie("/ count shown", "search.nohlsearch.log200k.search.countMs"),
serie("n count shown", "search.nohlsearch.log200k.next.countMs"),
serie("* count shown", "search.nohlsearch.log200k.star.countMs"),
)
}
largeFileChart("SearchNextToLarge", "Large files: search in a small file next to a 200k-line one") {
listOf(
serie("/ next to 1k lines (baseline)", "search.nohlsearch.smallNextTo.log1k.search.caretMs"),
serie("/", "search.nohlsearch.smallNextTo.log200k.search.caretMs"),
serie("n", "search.nohlsearch.smallNextTo.log200k.next.caretMs"),
serie("*", "search.nohlsearch.smallNextTo.log200k.star.caretMs"),
)
}
largeFileChart("Freezes", "Large files: UI freezes", CustomChart.Format.INTEGER) {
listOf(
serie("Search tests", "SearchLargeFileTest.freezes"),
serie("Go to Declaration tests", "GotoDeclarationVisibleEditorsTest.freezes"),
)
}
}
private fun ProjectFeatures.largeFileChart(
id: String,
title: String,
format: CustomChart.Format = CustomChart.Format.DURATION,
series: () -> List<Serie>,
) {
projectCustomChart {
this.id = "IdeaVimLargeFileChart_$id"
this.title = title
this.seriesTitle = "Serie"
this.format = format
this.series = series()
}
}
private fun serie(title: String, metric: String) =
Serie(title, SeriesKey("ideavim.largeFile.$metric"), sourceBuildTypeId = LargeFileTests.id?.value)
+8
View File
@@ -9,11 +9,13 @@
package _Self
import _Self.buildTypes.Compatibility
import _Self.buildTypes.LargeFileTests
import _Self.buildTypes.LongRunning
import _Self.buildTypes.Nvim
import _Self.buildTypes.PluginVerifier
import _Self.buildTypes.PropertyBased
import _Self.buildTypes.RandomOrderTests
import _Self.buildTypes.RiderTests
import _Self.buildTypes.SplitModeTests
import _Self.buildTypes.TestingBuildType
@@ -43,10 +45,16 @@ object Project : Project({
buildType(LongRunning)
buildType(RandomOrderTests)
buildType(SplitModeTests)
buildType(LargeFileTests)
buildType(RiderTests)
buildType(Nvim)
buildType(PluginVerifier)
buildType(Compatibility)
features {
largeFileCharts()
}
})
// Agent size configurations (CPU count)
+71
View File
@@ -0,0 +1,71 @@
/*
* 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.BuildStep
import jetbrains.buildServer.configs.kotlin.v2019_2.CheckoutMode
import jetbrains.buildServer.configs.kotlin.v2019_2.DslContext
import jetbrains.buildServer.configs.kotlin.v2019_2.buildSteps.script
import jetbrains.buildServer.configs.kotlin.v2019_2.triggers.vcs
object LargeFileTests : IdeaVimBuildType({
id("IdeaVimLargeFileTests")
name = "Large file tests"
description = "Tests for IdeaVim in a full IDE with large files (search, search count)"
// The IDE logs include the thread dumps of UI freezes, which show where a slow operation spent its time
artifactRules = """
+:tests/large-file-tests/build/reports => large-file-tests-reports.zip
+:out/ide-tests/tests/**/log => ide-tests-log.zip
""".trimIndent()
params {
param("env.ORG_GRADLE_PROJECT_downloadIdeaSources", "false")
param("env.ORG_GRADLE_PROJECT_instrumentPluginCode", "false")
}
vcs {
root(DslContext.settingsRoot)
branchFilter = "+:<default>"
checkoutMode = CheckoutMode.AUTO
}
steps {
script {
name = "Run large file tests"
scriptContent = """
export JAVA_HOME=/usr/lib/jvm/java-21-amazon-corretto
export PATH="${'$'}JAVA_HOME/bin:${'$'}PATH"
xvfb-run -a -s '-screen 0 1920x1080x24' ./gradlew :tests:large-file-tests:testLargeFiles --console=plain --build-cache --configuration-cache --stacktrace
""".trimIndent()
}
// The tests write their timings as service messages. Printing them publishes them as build statistics, charted in
// the project's Statistics tab (see LargeFileCharts). Also for failed runs, so the tests that passed still count
script {
name = "Publish metrics"
executionMode = BuildStep.ExecutionMode.ALWAYS
scriptContent = "cat tests/large-file-tests/build/metrics/*.txt || true"
}
}
triggers {
vcs {
branchFilter = "+:<default>"
}
}
requirements {
// Use a larger agent, the same as for split-mode tests, so searching the large files doesn't hit the timeouts
equals("teamcity.agent.hardware.cpuCount", AgentSize.XLARGE)
equals("teamcity.agent.os.family", "Linux")
}
})
+86 -12
View File
@@ -81,6 +81,47 @@ sealed class ReleasePlugin(private val releaseType: String) : IdeaVimBuildType({
set -e
git checkout release
echo Checked out release branch. It is published as is - only the EAP build moves it to master
git fetch origin +master:refs/remotes/origin/master
""".trimIndent()
}
script {
name = "Take the changelog and what's new from master"
scriptContent = """
set -e
# Both are maintained on master (the What's New PR is merged there), while master may be ahead
# of the release branch. The changelog is taken from master without the To Be Released entries
# for changes that are only on master; the What's New page was written for the release branch
# by the Prepare What's New workflow, so it is taken as is. A patch release does not use it: it gets
# the page of its minor, and the prepared page stays on master for the next minor or major release.
cd scripts-ts
npm ci --silent --no-fund --no-audit
npx tsx src/prepareReleaseChangelog.ts .. origin/master
cd ..
if [ "%env.ORG_GRADLE_PROJECT_releaseType%" = "patch" ]; then
echo "Patch release: What's New is the page of the minor it patches"
exit 0
fi
tbr="src/main/resources/whatsnew-tbr.html"
if ! git cat-file -e "origin/master:${'$'}tbr" 2>/dev/null; then
echo "ERROR: master has no ${'$'}tbr. Run the Prepare What's New workflow and merge its PR first"
exit 1
fi
git checkout origin/master -- "${'$'}tbr"
# The page describes the release branch as it was when Prepare What's New ran. An EAP build or
# a cherry-pick since then changed what the release contains, so the page may miss some of it.
described=${'$'}(sed -n 's/^<!-- whatsnew-release-commit: \([0-9a-f]*\) -->${'$'}/\1/p' "${'$'}tbr")
released=${'$'}(git rev-parse HEAD)
if [ -z "${'$'}described" ]; then
echo "ERROR: ${'$'}tbr does not say which release commit it describes. Run the Prepare What's New workflow and merge its PR first"
exit 1
fi
if [ "${'$'}described" != "${'$'}released" ]; then
echo "ERROR: ${'$'}tbr describes release commit ${'$'}described, but the release branch is at ${'$'}released."
echo "Run the Prepare What's New workflow again and merge its PR. Commits since the page was written:"
git log --oneline "${'$'}described..${'$'}released" || true
exit 1
fi
echo "Took ${'$'}tbr from master, written for release commit ${'$'}released"
""".trimIndent()
}
gradle {
@@ -114,12 +155,28 @@ sealed class ReleasePlugin(private val releaseType: String) : IdeaVimBuildType({
version=${'$'}(echo "%build.number%" | tr '[:upper:]' '[:lower:]')
tbr="src/main/resources/whatsnew-tbr.html"
target="src/main/resources/whatsnew-${'$'}version.html"
if [ -f "${'$'}tbr" ]; then
cp "${'$'}tbr" "${'$'}target"
if [ "%env.ORG_GRADLE_PROJECT_releaseType%" = "patch" ]; then
# Patches roll into their parent minor, so they get the newest page of that minor. This way the IDE
# and the What's New site have a page for every released version.
minor=${'$'}(echo "${'$'}version" | cut -d. -f1,2)
previous=${'$'}(ls src/main/resources/whatsnew-${'$'}minor.*.html 2>/dev/null | sort -V | tail -n 1)
if [ -f "${'$'}target" ]; then
echo "${'$'}target already exists"
elif [ -n "${'$'}previous" ]; then
cp "${'$'}previous" "${'$'}target"
git add "${'$'}target"
echo "Copied ${'$'}previous to ${'$'}target"
else
echo "WARN: no whatsnew-${'$'}minor.*.html page found; skipping What's New"
fi
elif [ -f "${'$'}tbr" ]; then
git mv "${'$'}tbr" "${'$'}target"
sed -i '/^<!-- whatsnew-release-commit: .* -->${'$'}/d' "${'$'}target"
git add "${'$'}target"
echo "Promoted whatsnew-tbr.html to whatsnew-${'$'}version.html"
else
echo "WARN: ${'$'}tbr not found; skipping What's New promotion"
echo "ERROR: ${'$'}tbr not found"
exit 1
fi
""".trimIndent()
}
@@ -160,17 +217,29 @@ sealed class ReleasePlugin(private val releaseType: String) : IdeaVimBuildType({
name = "Sync changelog and what's new to master"
scriptContent = """
set -e
# Master may be ahead of the release branch: the EAP build is the only thing that moves the
# release branch to master, and fixes keep landing on master afterwards. So nothing here is
# promoted from master's own To Be Released content - the released changelog section and the
# What's New page are taken from the release branch, and master keeps the rest as unreleased.
git checkout master
git show release:CHANGES.md > /tmp/release-CHANGES.md
cd scripts-ts
npx tsx src/promoteChangelog.ts "%build.number%" "%env.ORG_GRADLE_PROJECT_releaseType%" ..
npx tsx src/syncChangelogToMaster.ts "%build.number%" ../CHANGES.md /tmp/release-CHANGES.md
cd ..
version=${'$'}(echo "%build.number%" | tr '[:upper:]' '[:lower:]')
tbr="src/main/resources/whatsnew-tbr.html"
target="src/main/resources/whatsnew-${'$'}version.html"
if [ -f "${'$'}tbr" ] && [ ! -f "${'$'}target" ]; then
cp "${'$'}tbr" "${'$'}target"
git add "${'$'}target"
echo "Promoted whatsnew-tbr.html to whatsnew-${'$'}version.html on master"
if git cat-file -e "release:${'$'}target" 2>/dev/null; then
git checkout release -- "${'$'}target"
echo "Took whatsnew-${'$'}version.html from the release branch"
# The page is published now; the next What's New run starts a fresh one for the next release.
# A patch release did not use it, so it stays for the next minor or major release.
if [ "%env.ORG_GRADLE_PROJECT_releaseType%" != "patch" ] && [ -f "${'$'}tbr" ]; then
git rm -q "${'$'}tbr"
echo "Removed whatsnew-tbr.html on master"
fi
else
echo "WARN: the release branch has no ${'$'}target; master's What's New is left as is"
fi
git config user.name "IdeaVim Bot"
git config user.email "maintainers@ideavim.dev"
@@ -197,11 +266,16 @@ sealed class ReleasePlugin(private val releaseType: String) : IdeaVimBuildType({
git push origin %build.number%
""".trimIndent()
}
gradle {
script {
name = "Run Integrations"
tasks = "releaseActions"
gradleParams = "--build-cache --configuration-cache"
jdkHome = "/usr/lib/jvm/java-21-amazon-corretto"
scriptContent = """
set -e
cd scripts-ts
npm ci --silent --no-fund --no-audit
: "${'$'}{ORG_GRADLE_PROJECT_youtrackToken:?ORG_GRADLE_PROJECT_youtrackToken is not set}"
export YOUTRACK_TOKEN="${'$'}ORG_GRADLE_PROJECT_youtrackToken"
npx tsx src/releaseActions.ts "%build.number%" "%env.ORG_GRADLE_PROJECT_releaseType%" .. origin/master release
""".trimIndent()
}
}
+62
View File
@@ -0,0 +1,62 @@
/*
* 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
import jetbrains.buildServer.configs.kotlin.v2019_2.triggers.vcs
object RiderTests : IdeaVimBuildType({
name = "Rider tests"
description = "Tests for IdeaVim in Rider, where many editor features run on the ReSharper backend"
artifactRules = """
+:tests/ui-rd-tests/build/reports => rider-tests-reports.zip
+:out/ide-tests/tests/**/log => ide-tests-log.zip
""".trimIndent()
params {
param("env.ORG_GRADLE_PROJECT_downloadIdeaSources", "false")
param("env.ORG_GRADLE_PROJECT_instrumentPluginCode", "false")
}
vcs {
root(DslContext.settingsRoot)
branchFilter = "+:<default>"
checkoutMode = CheckoutMode.AUTO
}
steps {
script {
name = "Run Rider tests"
// Without RIDER_LICENSE_KEY, Rider runs on a trial. It installs the .NET SDK for the test project itself
scriptContent = """
export JAVA_HOME=/usr/lib/jvm/java-21-amazon-corretto
export PATH="${'$'}JAVA_HOME/bin:${'$'}PATH"
xvfb-run -a -s '-screen 0 1920x1080x24' ./gradlew :tests:ui-rd-tests:testUi --console=plain --build-cache --configuration-cache --stacktrace
""".trimIndent()
}
}
triggers {
vcs {
branchFilter = "+:<default>"
}
}
requirements {
// Use a larger agent, the same as for split-mode tests: Rider runs a frontend and a ReSharper backend
equals("teamcity.agent.hardware.cpuCount", AgentSize.XLARGE)
equals("teamcity.agent.os.family", "Linux")
}
})
+145 -1
View File
@@ -25,8 +25,105 @@ usual beta standards.
## To Be Released
### Features:
* [VIM-748](https://youtrack.jetbrains.com/issue/VIM-748) [`|`](https://vimhelp.org/cmdline.txt.html#%3Abar) now separates Ex commands that take a pattern or free-form argument, such as [`:s`](https://vimhelp.org/change.txt.html#%3As) and [`:sort`](https://vimhelp.org/change.txt.html#%3Asort), so `:%s/foo/bar/|%s/baz/qux/` runs both substitutions and `:%sort u|1d` sorts and then deletes the first line. A `\|` inside the pattern or replacement is still regex alternation or a literal `|`, and a `|` inside a `:sort` pattern such as `:%sort /a|b/` stays part of it. As in Vim, [`:global`](https://vimhelp.org/repeat.txt.html#%3Aglobal), [`:vglobal`](https://vimhelp.org/repeat.txt.html#%3Avglobal), [`:normal`](https://vimhelp.org/various.txt.html#%3Anormal) and `:!` take the `|` as part of their argument
* [VIM-3963](https://youtrack.jetbrains.com/issue/VIM-3963) Vim can now be enabled in two more kinds of editors with the `'ideaeditor'` option: `main` for tabs in the main editor area that are not backed by a regular file, such as editors that third-party plugins open for in-memory or decrypted content, and `chat` for the prompt input of [JetBrains AI Assistant](https://www.jetbrains.com/help/ai-assistant/ai-chat.html) chats. Both are off by default — enable them with `:set ideaeditor+=main` or `:set ideaeditor+=chat`. The chat input starts in Insert mode, so `<Enter>` still sends the message
* [VIM-1306](https://youtrack.jetbrains.com/issue/VIM-1306) New `keylog` extension writes every key you type to a file, like Vim's [`-w`](https://vimhelp.org/starting.txt.html#-w) command line argument. Enable it with `set keylog`. The keys go to `ideavim.log` next to your ideavimrc, or to the file set with `let g:keylog_file = "~/vimlogs/ideavim.log"`
* [`:help`](https://vimhelp.org/helphelp.txt.html#%3Ahelp) now opens IdeaVim's own read-only help file inside the IDE instead of a web page. It lists every supported command with a description of what it does in IdeaVim, and `:help :s` or `:help i_CTRL-W` jumps straight to that command
* Action IDs can now be completed: press `<Tab>`/`<S-Tab>` after `:action` or `:actionlist` in the command line to cycle through matching IDs, and in `.ideavimrc` code completion suggests action IDs, with their names and descriptions, inside `<Action(...)>` and after `action`
* [VIM-1948](https://youtrack.jetbrains.com/issue/VIM-1948) New `'ideascrollcursor'` option makes the caret move with the text when scrolling with the mouse wheel or the scrollbar, keeping it [`'scrolloff'`](https://vimhelp.org/options.txt.html#%27scrolloff%27) lines from the edge of the window, as in Vim. It is off by default — enable it with `:set ideascrollcursor`
* [VIM-720](https://youtrack.jetbrains.com/issue/VIM-720) `<CR>`, `<Esc>` and the arrow keys can now be handed over to the IDE with [`sethandler`](https://github.com/JetBrains/ideavim/blob/master/doc/sethandler.md) for that key, e.g. `sethandler <CR> i:ide` or `sethandler <Esc> n:ide`. Handlers set for all shortcuts at once, such as `sethandler a:ide`, still leave these keys to Vim
* [VIM-2372](https://youtrack.jetbrains.com/issue/VIM-2372) The [NERDTree](https://github.com/JetBrains/ideavim/blob/master/doc/NERDTree-support.md) navigation keys `j`, `k`, `gg`, `G`, `<C-D>`, `<C-U>` and `/` can now be changed like the other NERDTree keys, e.g. `let g:NERDTreeMapSelectNext = '<C-N>'` or `let g:NERDTreeMapSpeedSearch = 'f'`. Changed `g:NERDTreeMap*` variables are applied the next time the Project tool window gets focus, without restarting the IDE
* [VIM-2293](https://youtrack.jetbrains.com/issue/VIM-2293) New `visual-multi` extension emulates [vim-visual-multi](https://github.com/mg979/vim-visual-multi) using the IDE's native carets. Enable it with `Plug 'mg979/vim-visual-multi'`. Press `<C-n>` to select the word under the cursor and add its next occurrence, `\\A` to select all occurrences, or `<C-Down>`/`<C-Up>` to add cursors on the lines below/above. During a session, `n`/`N` find the next/previous occurrence, `q`/`Q` skip or remove a region, `<Tab>` switches between regions and cursors, and `\\z`, `\\@`, `\\C` and `\\a` run a `:normal` command, run a macro, convert case and align the cursors. Keys can be changed with `g:VM_maps` and `g:VM_leader`. See the [documentation](https://github.com/JetBrains/ideavim/blob/master/doc/IdeaVim%20Plugins.md) for all keys and options
* Added the [`:function`](https://vimhelp.org/userfunc.txt.html#%3Afunction) command for listing user-defined functions. `:function` lists every global function with its arguments and flags. `:function MyFunc` shows a single function, and `:function /pattern` lists the functions whose names match the pattern
* [VIM-1975](https://youtrack.jetbrains.com/issue/VIM-1975) New `clever-f` extension emulates [clever-f.vim](https://github.com/rhysd/clever-f.vim). Enable it with `Plug 'rhysd/clever-f.vim'`. After `f{char}`, pressing `f` again jumps to the next `{char}` and `F` to the previous one, so `;` and `,` are not needed. The same works for `F`, `t` and `T`. Searches cross line boundaries, and the characters the search can jump to on the cursor line are highlighted. Behaviour can be changed with `g:clever_f_*` variables such as `g:clever_f_smart_case` or `g:clever_f_across_no_line`. See the [documentation](https://github.com/JetBrains/ideavim/blob/master/doc/IdeaVim%20Plugins.md) for all options
### Fixes:
* [VIM-4341](https://youtrack.jetbrains.com/issue/VIM-4341) Fixed the IDE freezing when searching with `/`, `n` or `*` in large files. The search count is now worked out in the background and shown when it is ready
* Fixed a single-line message, such as the search pattern shown by `n`, closing while it was still being updated. The [`'messagesopt'`](https://vimhelp.org/options.txt.html#%27messagesopt%27) `wait` delay now restarts each time the message changes, so pressing `n` repeatedly keeps the message open
* Fixed [`histadd()`](https://vimhelp.org/builtin.txt.html#histadd%28%29), `histdel()`, `histget()` and `histnr()` failing with `"?"` as the history name. As in Vim, `"?"` now refers to the search history, the same as `"/"`
* [VIM-1153](https://youtrack.jetbrains.com/issue/VIM-1153) [`.`](https://vimhelp.org/repeat.txt.html#.) now replays
the keys you typed, as Vim does, instead of the recorded edits. Repeating a tag rename such as `cwdiv<Esc>` on
another HTML tag now renames its closing tag too, and a completion accepted with `<C-Y>` or a mapping, or
picked with `<C-N>`/`<C-P>`, is repeated with the completed text
* [VIM-4333](https://youtrack.jetbrains.com/issue/VIM-4333) Fixed [`=`](https://vimhelp.org/change.txt.html#%3D) and `==` reformatting the whole file instead of only the given lines in C/C++ files in CLion Nova
* [VIM-1314](https://youtrack.jetbrains.com/issue/VIM-1314) Pasting with the IDE paste action (`Cmd+V`/`Ctrl+V`) in Replace mode now overwrites the characters under the caret, as in Vim, instead of inserting the text before them. `<BS>` puts the original characters back
* [VIM-1894](https://youtrack.jetbrains.com/issue/VIM-1894) Fixed [`V`](https://vimhelp.org/visual.txt.html#V) on a collapsed fold selecting only its first line. Linewise Visual mode now takes in the whole folded region, so `Vd` on a collapsed `<div>` deletes the entire tag, and `Vj` onto a collapsed fold selects all of it
* [VIM-1503](https://youtrack.jetbrains.com/issue/VIM-1503) Fixed dead keys and input methods typing text into the document in Normal, Visual and Operator-pending modes. Pressing a dead key such as `'` after `vi` no longer replaces the Visual selection, and a composed character like `é` can be used as a command argument, e.g. `fé`
* [VIM-2524](https://youtrack.jetbrains.com/issue/VIM-2524) [`cc`](https://vimhelp.org/change.txt.html#cc) and [`S`](https://vimhelp.org/change.txt.html#S) on an empty or blank line now indent it to match the surrounding code, the same indent `o` on the line above would give, instead of keeping whatever white space the line already had
* Fixed [`%`](https://vimhelp.org/motion.txt.html#%25) jumping to the wrong bracket, or not moving, when an apostrophe that does not start a string sits between the brackets — in `(it's) y's`, `%` on `(` now jumps to the matching `)`
* Fixed `%` throwing an exception in an empty file
* Fixed `:/` and `:?` with an empty pattern throwing an exception. As in Vim, they now use the last search pattern, and report `E35: No previous regular expression` when there is none
* Fixed [`:continue`](https://vimhelp.org/eval.txt.html#%3Acontinue) inside a `:for` loop over a List hanging the IDE — `for x in [1, 2] | continue | endfor` now finishes
* Fixed [`slice()`](https://vimhelp.org/builtin.txt.html#slice%28%29) returning the whole list or string when the end index works out to zero — `slice([1, 2, 3], 0, 0)` now returns `[]`
* Fixed [`:tags`](https://vimhelp.org/tagsrch.txt.html#%3Atags) reporting an error when the line a tag jump was made from has since been deleted
* Fixed searching for a pattern containing `\<` throwing an exception while the [command-line window](https://vimhelp.org/cmdline.txt.html#cmdline-window) opened by `q:`, `q/` or `q?` is open
* Fixed the `ReplaceWithRegister` extension's `gr` in Visual mode deleting whole lines when the selection spans lines — with `vj` selecting from the middle of one line into the next, `gr` now replaces only the selected text and keeps the rest of both lines
### Merged PRs:
* [2082](https://github.com/JetBrains/ideavim/pull/2082) by [1grzyb1](https://github.com/1grzyb1): VIM-748 changing multiple commands with |
* [2088](https://github.com/JetBrains/ideavim/pull/2088) by [1grzyb1](https://github.com/1grzyb1): VIM-1153 replay all keystrokes during .
* [2096](https://github.com/JetBrains/ideavim/pull/2096) by [1grzyb1](https://github.com/1grzyb1): VIM-1306 Add keylog extension
* [2097](https://github.com/JetBrains/ideavim/pull/2097) by [1grzyb1](https://github.com/1grzyb1): VIM-1314 Replace mode paste with cmd+v
* [2090](https://github.com/JetBrains/ideavim/pull/2090) by [1grzyb1](https://github.com/1grzyb1): VIM-4333 Fix auto-indent handling in clion
* [2091](https://github.com/JetBrains/ideavim/pull/2091) by [1grzyb1](https://github.com/1grzyb1): VIM-3963 Allow enabling vim for plugin editors and ai chat windows
* [2098](https://github.com/JetBrains/ideavim/pull/2098) by [1grzyb1](https://github.com/1grzyb1): VIM-1894 include collapsed region during visual selection
* [2102](https://github.com/JetBrains/ideavim/pull/2102) by [1grzyb1](https://github.com/1grzyb1): VIM-1948 Scroll caret while mouse scrolling
* [2112](https://github.com/JetBrains/ideavim/pull/2112) by [1grzyb1](https://github.com/1grzyb1): VIM-720 Allow handling esc, enter, arrows directly by ide
* [2113](https://github.com/JetBrains/ideavim/pull/2113) by [1grzyb1](https://github.com/1grzyb1): VIM-1503 don't type dead keys
* [2115](https://github.com/JetBrains/ideavim/pull/2115) by [1grzyb1](https://github.com/1grzyb1): VIM-2372 Add missing mappings for navigation keys
* [2117](https://github.com/JetBrains/ideavim/pull/2117) by [1grzyb1](https://github.com/1grzyb1): VIM-2293 Implement Vim visual multi plugin
* [2119](https://github.com/JetBrains/ideavim/pull/2119) by [1grzyb1](https://github.com/1grzyb1): VIM-2524 calculate indent after cc
* [2103](https://github.com/JetBrains/ideavim/pull/2103) by [citizenmatt](https://github.com/citizenmatt): Implement :function command to list functions
* [2086](https://github.com/JetBrains/ideavim/pull/2086) by [valis](https://github.com/valis): Don't treat quotes inside non-string tokens as string delimiters in %
## 2.47.3, 2026-10-05
### Fixes:
* [VIM-4336](https://youtrack.jetbrains.com/issue/VIM-4336) Fixed the caret ending up in the wrong place after IDE actions that run asynchronously in Rider, such as commenting a line
### Merged PRs:
* [2107](https://github.com/JetBrains/ideavim/pull/2107) by [1grzyb1](https://github.com/1grzyb1): VIM-4336 postpone caret normalization for async actions
## 2.47.2, 2026-10-02
### Features:
* [VIM-4335](https://youtrack.jetbrains.com/issue/VIM-4335) Added the `'ideaoutsideeditor'` option (on by default), which controls whether mappings to IDE actions such as `map <leader>sf <Action>(GotoFile)` keep working when no file is open. Use `:set noideaoutsideeditor` to leave all keys typed outside an editor to the IDE
### Merged PRs:
* [2100](https://github.com/JetBrains/ideavim/pull/2100) by [1grzyb1](https://github.com/1grzyb1): VIM-4335 guard outside editor dispatcher when ideaoutsideeditor
## 2.47.1, 2026-09-25
### Fixes:
* [VIM-2478](https://youtrack.jetbrains.com/issue/VIM-2478) [
`:set noautoindent`](https://vimhelp.org/options.txt.html#%27autoindent%27) no longer removes the indent the IDE works
out from your code. The option only copies the indent of the current line, and in Vim an indent from [
`'indentexpr'`](https://vimhelp.org/options.txt.html#%27indentexpr%27) or [
`'cindent'`](https://vimhelp.org/options.txt.html#%27cindent%27) is added whatever `'autoindent'` is set to, so `o`,
`O` and `<CR>` in Insert mode now keep the indent of the new line in a file of a language the IDE has a formatter for,
as Neovim does with its default `filetype indent on`. The caret still goes to column 0 where the IDE cannot work an
indent out on its own, such as in a plain text file, and the indent is still removed again if you leave Insert mode
without typing anything
### Changes:
* [VIM-4331](https://youtrack.jetbrains.com/issue/VIM-4331) A new line opened with `o`, `O` or `<CR>` now keeps its indent when you leave Insert mode without typing anything. Vim
deletes such an indent, so that `o<Esc>` leaves a completely empty line, but in the IDE a line you have just opened is
expected to stay where it is. `cc` and `S` still follow Vim and leave the line they empty without any indent
* IdeaVim now respects "Keep indents on empty lines" in Settings | Editor | Code Style. With it on, the line that `cc`
or `S` empties keeps its indent instead of being left completely empty. The setting is off by default
### Merged PRs:
* [2084](https://github.com/JetBrains/ideavim/pull/2084) by [1grzyb1](https://github.com/1grzyb1): VIM-4331 keep the indent of a new line when leaving Insert mode
## 2.47.0, 2026-09-24
### Features:
* [VIM-3058](https://youtrack.jetbrains.com/issue/VIM-3058) Added the [`"#` register](https://vimhelp.org/change.txt.html#quote_%23), which holds the name of the alternate file — the file you last switched away from, the one [`CTRL-^`](https://vimhelp.org/editing.txt.html#CTRL-%5E) jumps back to. `"#p` puts it into the text, `@#` reads it in an expression, and `:registers` lists it under `%`. Like `%`, it reports the path relative to the content root, since IdeaVim has no current directory, and it is read only — `:let @# = 'other.txt'` reports `E354: Invalid register name: '#'`
* [VIM-1266](https://youtrack.jetbrains.com/issue/VIM-1266) Added the [`"%` register](https://vimhelp.org/change.txt.html#quote_%25), which holds the name of the file you are editing — `"%p` puts it into the text, and `:registers` now lists it along with the rest. Vim's `%` holds the buffer name as it was typed, which is usually a path relative to the current directory; IdeaVim has no current directory, so it reports the path relative to the content root, the same path that `CTRL-G` and `:file` already show
* [VIM-1370](https://youtrack.jetbrains.com/issue/VIM-1370) Added the [tag stack](https://vimhelp.org/tagsrch.txt.html#tag-stack), so a jump to a definition can be retraced the way Vim does it. [`CTRL-]`](https://vimhelp.org/tagsrch.txt.html#CTRL-%5D) now records the position it was jumped from, [`CTRL-T`](https://vimhelp.org/tagsrch.txt.html#CTRL-T) and [`:pop`](https://vimhelp.org/tagsrch.txt.html#%3Apop) walk back up those positions, [`:tag`](https://vimhelp.org/tagsrch.txt.html#%3Atag) without an argument walks back down again, and [`:tags`](https://vimhelp.org/tagsrch.txt.html#%3Atags) lists the stack with `>` marking where you are in it. All of them take a count, so `2<C-T>` and `:2pop` step back two tag jumps at once. The stack holds twenty entries, dropping the oldest once it is full, and a new tag jump made after walking back drops the entries you walked past. `gd` and `gD` are not tag commands and leave the stack alone, as in Vim
* Added the [`'tagstack'`](https://vimhelp.org/options.txt.html#%27tagstack%27) option (`'tgst'`), on by default. With `:set notagstack` a tag jump still navigates but records nothing, which is useful for a mapping that should not disturb the stack. Walking the stack is unaffected — `CTRL-T` keeps using whatever is already on it
* [VIM-1347](https://youtrack.jetbrains.com/issue/VIM-1347) Added built-in [`vim-signature`](https://github.com/kshenoy/vim-signature) extension — enable it with `Plug 'kshenoy/vim-signature'` and every local mark `a`-`z` of the file gets an icon in the gutter, so you can see where your marks are instead of having to remember. `m,` places the next free mark, `m-` clears the marks of the current line and `m<Space>` those of the whole buffer. `]'` and `['` walk through the marked lines in the order they appear, while `']`, `'[`, `` `] `` and `` `[ `` walk through the marks in the order of their letters — all of them take a count, and each one is a jump, so `''` and `<C-O>` bring you back. Those last four are Vim's own marks of the last change or yank and the plugin takes them over, as the original does; every key is a mapping to a `<Plug>` name, so `nmap gn <Plug>(SignatureGotoNextSpotAlpha)` and friends let you keep Vim's. `` ]` `` and `` [` `` are left alone, since IdeaVim already implements them as real motions. Global marks `A`-`Z` are not drawn, because with the default `ideamarks` they are IDE bookmarks, which the IDE shows in the gutter itself
@@ -36,11 +133,22 @@ usual beta standards.
* Added built-in [`vim-textobj-line`](https://github.com/kana/vim-textobj-line) extension — enable it with `Plug 'kana/vim-textobj-line'` and you get two text objects for the current line: `al` selects every character of the line without the end of line character (the same as `0v$h`), and `il` leaves the leading and trailing whitespace out of it (the same as `^vg_`). So on an indented line, `dil` deletes the code and keeps the indent, while `dal` empties the line completely, and `yil`, `cil`, `gUal` and the rest work the same way from anywhere in the line, no matter where the caret is. As in the original, neither selects anything on an empty line, and `il` also does nothing on a line of whitespace only, since there is no text there to select. Set `let g:textobj_line_no_default_key_mappings = 1` before enabling the plugin to keep `al` and `il` free and map `<Plug>(textobj-line-al)` and `<Plug>(textobj-line-il)` yourself
* [VIM-2089](https://youtrack.jetbrains.com/issue/VIM-2089) Added built-in [`vim-visual-star-search`](https://github.com/bronson/vim-visual-star-search) extension — enable it with `Plug 'bronson/vim-visual-star-search'` and [`*`](https://vimhelp.org/pattern.txt.html#star) and [`#`](https://vimhelp.org/pattern.txt.html#%23) in Visual mode search forwards and backwards for the selected text instead of for the word under the caret. The selection is searched for literally, without the word boundaries the built-in `*` adds, so selecting `foo` also finds it inside `foobar`, and characters such as `.`, `*`, `[` and `~` match themselves instead of being read as a pattern. Every kind of selection works: `V*` searches for the whole line together with its end of line character, so it only matches text that ends a line, and a blockwise selection searches for its rows joined by end of line characters, as the original does. `n` and `N` repeat the search, [`gv`](https://vimhelp.org/visual.txt.html#gv) brings back the text you searched for, and the position you searched from goes on the [jump list](https://vimhelp.org/motion.txt.html#jumplist), so `''` and `<C-O>` return to it. Unlike the built-in `*`, the search honours [`'ignorecase'`](https://vimhelp.org/options.txt.html#%27ignorecase%27) and [`'smartcase'`](https://vimhelp.org/options.txt.html#%27smartcase%27), because the original searches with a plain `/`. A count is applied, so `3*` finds the third occurrence — the original loses it, as the `:<C-u>` in its mapping wipes the count off the command line. The plugin's `<leader>*` mapping, which greps the selection through the whole project, is not implemented
* [VIM-2663](https://youtrack.jetbrains.com/issue/VIM-2663) Added the [`:startinsert`](https://vimhelp.org/insert.txt.html#%3Astartinsert) (`:star`) and [`:startreplace`](https://vimhelp.org/insert.txt.html#%3Astartreplace) (`:startr`) commands, the counterparts of [`:stopinsert`](https://vimhelp.org/insert.txt.html#%3Astopinsert). `:startinsert` enters Insert mode where the caret is, exactly as `i` does, and `:startinsert!` first moves to the end of the line, as `A` does. `:startreplace` enters Replace mode as `R` does, and `:startreplace!` works like `$R`, so it starts by overwriting the last character of the line rather than appending after it — with `:set virtualedit=onemore` it goes one further and appends, just as `$` itself does. `<BS>` in the Replace mode they start restores the overwritten characters, like any other Replace session. Every caret takes part, so with several carets each one moves to the end of its own line. Like `:stopinsert`, they are meant for autocommands — `autocmd BufEnter scratch startinsert` opens a scratch buffer ready to type in. Neither takes a range or an argument; `:1startinsert` reports `E481: No range allowed` and `:startinsert foo` reports `E488: Trailing characters`
* [VIM-2478](https://youtrack.jetbrains.com/issue/VIM-2478) Added the [`'autoindent'`](https://vimhelp.org/options.txt.html#%27autoindent%27) option (`'ai'`). With `:set noautoindent`, `o`, `O`, `cc`, `S` and `<CR>` in Insert mode start the new line without any indent, and `:setlocal noautoindent` does this for the current buffer only. The option is on by default, as in Neovim. As in Vim, the indent that `cc` and `S` keep on the line is removed again when you leave Insert mode without typing anything, so `cc<Esc>` on an indented line now leaves an empty line instead of one full of white space
* [VIM-4324](https://youtrack.jetbrains.com/issue/VIM-4324) Added [`CTRL-W q`](https://vimhelp.org/windows.txt.html#CTRL-W_q), which closes the current window like `CTRL-W c`
* [VIM-2627](https://youtrack.jetbrains.com/issue/VIM-2627) The location of the ideavimrc file can now be set in Settings | Editor | Vim. The path may use `~`, environment variables such as `$VAR` or `${VAR}`, and IDE path macros such as `$APPLICATION_CONFIG_DIR$`, so `$APPLICATION_CONFIG_DIR$/ideavim/ideavimrc` keeps a separate configuration for each IDE. Leave it empty to use the default locations, which are also used while the configured file does not exist yet
* [VIM-3999](https://youtrack.jetbrains.com/issue/VIM-3999) Vim now works in the console of a run or debug configuration, e.g. the console of a debugged application. The new `'ideaeditor'` option lists the non-file editors IdeaVim is active in — `python` for the [Python console](https://www.jetbrains.com/help/pycharm/using-consoles.html) and `debug` for run and debug consoles, both on by default — so `:set ideaeditor-=debug` gives the debug console its native behaviour back
* [VIM-2629](https://youtrack.jetbrains.com/issue/VIM-2629) Added the `g:commentary_block_comments` variable to the [`vim-commentary`](https://github.com/tpope/vim-commentary) extension. IdeaVim uses a block comment when the range is not linewise, so `gciw` comments out a single word; set `let g:commentary_block_comments = 0` to always comment out whole lines, as the original plugin does
* [VIM-3796](https://youtrack.jetbrains.com/issue/VIM-3796) The [`matchit`](https://github.com/adelarsq/vim-matchit) extension now supports [`b:match_words`](https://vimhelp.org/pi_matchit.txt.html#b%3Amatch_words), so `%` can jump between your own pairs of words — `let b:match_words = 'begin:end,if:elsif:else:end'` makes `%` cycle through `if`, `elsif`, `else` and `end`
* [VIM-3198](https://youtrack.jetbrains.com/issue/VIM-3198) Added `<C-D>` and `<C-U>` to [NERDTree](https://github.com/JetBrains/ideavim/wiki/NERDTree-support), which move the selection half a page down and up
### Fixes:
* [VIM-3667](https://youtrack.jetbrains.com/issue/VIM-3667) Fixed mappings to IDE actions not working when no file is open. With `map <leader>sf <Action>(GotoFile)` in `~/.ideavimrc`, pressing `<leader>sf` used to do nothing once every editor tab was closed, because IdeaVim only listened for keys inside an editor. The keys of such Normal mode mappings are now picked up while the focus is on a non-text component of a project with no open editor — the Project tool window, for example — and go through the regular mapping machinery, so counts, `'timeout'` and recursive mappings behave as in an editor; `<Esc>` cancels a half-typed sequence. Only mappings whose right-hand side consists of `<Action>(...)` sequences take part, since there is no editor for anything else to act on: `nmap j gj` keeps `j` for the tool window, and the `:action GotoFile<CR>` form of the mapping is not picked up. Text fields such as Search Everywhere keep receiving typed characters, modal dialogs are left alone, and keys that do not start a mapping are left to the IDE
* Fixed [`gx`](https://vimhelp.org/pi_netrw.txt.html#netrw-gx) adding to the [jump list](https://vimhelp.org/motion.txt.html#jumplist) — pressing `gx` used to record the caret position as a jump and overwrite the [`'`](https://vimhelp.org/motion.txt.html#%27quote) mark, so a following [`CTRL-O`](https://vimhelp.org/motion.txt.html#CTRL-O) landed back on the caret's own line instead of returning to the previous jump. As in Vim, `gx` opens the URL without touching the jump list
* Fixed the [word text objects](https://vimhelp.org/motion.txt.html#v_iw) throwing an exception when they reached the very start of the file — growing a right-to-left Visual selection with `iw`, `aw`, `iW` or `aW` looks one character back to decide where the previous word begins, and at offset 0 there is no such character. Starting on the second character of the file and pressing `v`, `h`, `iw` now selects back to the start of the file instead of reporting an internal error
* Fixed [`g8`](https://vimhelp.org/various.txt.html#g8) reporting the hex value of a single UTF-16 code unit instead of the UTF-8 bytes of the character under the caret, which is what it is supposed to print — `é` now reports `c3 a9` rather than `e9`, and a character outside the Basic Multilingual Plane such as `😀` reports all four of its bytes rather than half of a surrogate pair. ASCII characters are unchanged. Vim also appends the bytes of any trailing composing characters, separated by `+`; IdeaVim still does not
* Fixed `g8` throwing an exception when there is no character under the caret — in an empty file, or on an empty line, the caret sits where the line break is. As in Vim, which puts the cursor on the line's terminating NUL, `g8` now reports `NUL` instead of reporting an internal error or, on an empty line in the middle of the file, the hex value of the line feed
* Fixed the caret position after [`r`](https://vimhelp.org/change.txt.html#v_r) in Visual mode — replacing a selection left the caret at the end of the replaced text instead of on its first character. With the caret on the first `f` of `abcdffffff`, `v2lra` now leaves it on the first of the three `a`s, as in Vim
* Fixed `<End>` always being captured by Vim — `<End>`, `<S-End>`, `<C-End>` and `<C-S-End>` are no longer Vim-only editor keys, so [`sethandler <End> a:ide`](https://jb.gg/vim-sethandler) can release them to the IDE. IdeaVim still handles them when no handler is set
* [VIM-4308](https://youtrack.jetbrains.com/issue/VIM-4308) Fixed the current search match being drawn with a box around it after the search was over. The box now only appears while [`'incsearch'`](https://vimhelp.org/options.txt.html#%27incsearch%27) is previewing the pattern you are typing, and only in the window you are searching in — accepting the search with `<CR>`, cancelling it with `<Esc>`, `n`, `N`, moving the caret, editing the text, toggling [`'hlsearch'`](https://vimhelp.org/options.txt.html#%27hlsearch%27) and clicking into another window all leave every match looking the same. This is what a stock Vim looks like: its default colour scheme links the [`CurSearch`](https://vimhelp.org/syntax.txt.html#hl-CurSearch) highlight group to `Search`, so the match under the cursor is drawn no differently from the rest unless you define `CurSearch` yourself. Neovim does define it, which is why the box shows up there
* Fixed [`gv`](https://vimhelp.org/visual.txt.html#gv) reselecting one character too many. In Visual mode `gv` swaps the current selection for the previous one, and the restored selection ran one character past where it ended — with `foo bar` on the first line and `bar foo` on the second, `viw` on `foo`, `<Esc>`, `j`, `viw` and then `gv` selected `foo ` instead of `foo`
* Fixed `da(` inside a nested block swallowing the enclosing brackets. A characterwise delete or yank spanning several lines is promoted to [linewise](https://vimhelp.org/change.txt.html#linewise) only when there is nothing but white space before its start and after its end, but the check for what comes after skipped the very first character following the range. So with an outer block opened on one line, an inner one opened on the next, and both closed by `))` on a third, `da(` on the inner `(` deleted the last two lines whole and took the outer `)` with them, instead of deleting just the inner block
@@ -48,12 +156,32 @@ usual beta standards.
* [VIM-4319](https://youtrack.jetbrains.com/issue/VIM-4319) Fixed IdeaVim reporting an internal error when it was disabled, uninstalled or updated without restarting the IDE. The plugin used to shut itself down after the platform had already unregistered its services, so the shutdown could no longer reach them and failed with a null pointer exception. It now shuts down just before the platform unloads it, while everything it needs is still there, which also means its listeners are properly removed and typing is handed back to the IDE — so after switching IdeaVim off the keys behave as they would with the plugin never installed, without a restart
* Fixed [`CTRL-R CTRL-F`](https://vimhelp.org/cmdline.txt.html#c_CTRL-R_CTRL-F) reporting an internal error on the command line when the [`'incsearch'`](https://vimhelp.org/options.txt.html#%27incsearch%27) match ended at the very end of the file. The file name is looked for just past the match, where there is nothing left, so searching for the last word of the file and pressing `CTRL-R CTRL-F` now reports `E446: No file name under cursor`, as Vim does
* [VIM-2010](https://youtrack.jetbrains.com/issue/VIM-2010) Fixed [`.`](https://vimhelp.org/repeat.txt.html#.) replaying an import that the IDE added while you were typing. IdeaVim records every change the document goes through during an insert, which is how auto-inserted brackets and indents get replayed too, but an `import` line put in by [Add unambiguous imports on the fly](https://www.jetbrains.com/help/idea/creating-and-optimizing-imports.html#auto-importing), by the Alt+Enter fix or by the completion popup is not something you typed. Those changes are now left out of the recording — in a Java file with two `Set<String>` fields, changing the first one with `cf>List<String>` also adds `import java.util.List;` at the top of the file, and `.` on the second field now changes just that field instead of typing the text of the import into it as well. Picking the class from the completion popup is recorded correctly too: it inserts the fully qualified `java.util.List`, adds the import and then shortens the name again, and `.` used to replay that whole round trip and leave the caret in the middle of it
* [VIM-2127](https://youtrack.jetbrains.com/issue/VIM-2127) Fixed the closing HTML or XML tag not being renamed together with the opening one. Editing a tag name in Insert mode or with `r` now updates its pair as it does without IdeaVim — `r` on the last letter of `<tag></tag>` followed by `s` gives `<tas></tas>`
* [VIM-4318](https://youtrack.jetbrains.com/issue/VIM-4318) Fixed [`gv`](https://vimhelp.org/visual.txt.html#gv) after `:'<,'>sort` selecting only the last line. `gv` now reselects all the sorted lines, and after `:'<,'>sort u` the lines that remain once the duplicates are gone. The same applied to other commands that rewrite the whole selection at once, such as `gU` and `g?`
* [VIM-4326](https://youtrack.jetbrains.com/issue/VIM-4326) Fixed Visual mode mappings in [Select mode](https://vimhelp.org/visual.txt.html#Select-mode-mapping). As in Vim, the right-hand side of a `:map` or `:vmap` mapping is now run as if in Visual mode and Select mode is restored afterwards, instead of the keys being typed over the selection — with `:noremap j nzz`, pressing `j` in Select mode extends the selection to the next match
* [VIM-3093](https://youtrack.jetbrains.com/issue/VIM-3093) Fixed typing and scrolling slowing down in large files with [`'hlsearch'`](https://vimhelp.org/options.txt.html#%27hlsearch%27) on and a pattern with many matches. Only the matches in and around the visible part of the editor are highlighted now, and more are highlighted as you scroll
* [VIM-1180](https://youtrack.jetbrains.com/issue/VIM-1180) Fixed [`:!`](https://vimhelp.org/various.txt.html#%3A%21cmd) hanging on a command that reads standard input — `:!sort` never finished, because its input was left open and the command kept waiting for text that could never arrive, and the IDE stayed busy until the process was killed. The input is now closed as soon as there is nothing to send, so the command sees the end of input and exits, as it does in Neovim. Filtering lines through a command, as in `:%!sort`, is unaffected, and interactive commands are still not supported
### Changes:
* IdeaVim's own options are now all prefixed with `idea`, so it is clear at a glance which options are IdeaVim's and which are Vim's. `'lookupkeys'`, `'pythonconsole'`, `'trackactionids'` and `'visualdelay'` are now `'idealookupkeys'`, `'ideapythonconsole'`, `'ideatrackactionids'` and `'ideavisualdelay'`. The old names still work, so an existing `~/.ideavimrc` needs no changes, but they are deprecated and only the new name is reported by `:set` and `:set all`. `'trackactionids'` keeps its `'tai'` abbreviation
* IdeaVim's own options are now all prefixed with `idea`, so it is clear at a glance which options are IdeaVim's and which are Vim's. `'lookupkeys'`, `'trackactionids'` and `'visualdelay'` are now `'idealookupkeys'`, `'ideatrackactionids'` and `'ideavisualdelay'`. The old names still work, so an existing `~/.ideavimrc` needs no changes, but they are deprecated and only the new name is reported by `:set` and `:set all`. `'trackactionids'` keeps its `'tai'` abbreviation
* The `'pythonconsole'` option has been removed in favour of the `python` value of the new `'ideaeditor'` option. Replace `set nopythonconsole` in `~/.ideavimrc` with `set ideaeditor-=python`
* [`CTRL-T`](https://vimhelp.org/tagsrch.txt.html#CTRL-T) is no longer a second key for [`CTRL-O`](https://vimhelp.org/motion.txt.html#CTRL-O). It used to step back along the [jump list](https://vimhelp.org/motion.txt.html#jumplist); it now walks back up the [tag stack](https://vimhelp.org/tagsrch.txt.html#tag-stack) as in Vim, and reports `E73: Tag stack empty` when no tag jump has been made. `CTRL-O` is unchanged and remains the way to walk the jump list
* Removed the user surveys — the "Take Survey" entry is gone from the IdeaVim status bar menu, and uninstalling IdeaVim no longer opens a survey in the browser
### Merged PRs:
* [2074](https://github.com/JetBrains/ideavim/pull/2074) by [1grzyb1](https://github.com/1grzyb1): VIM-3796 support match_words in matchit
* [2072](https://github.com/JetBrains/ideavim/pull/2072) by [1grzyb1](https://github.com/1grzyb1): VIM-3198 add fast scroll in nerdtree
* [2071](https://github.com/JetBrains/ideavim/pull/2071) by [1grzyb1](https://github.com/1grzyb1): VIM-3093 Render only visible highlights
* [2070](https://github.com/JetBrains/ideavim/pull/2070) by [1grzyb1](https://github.com/1grzyb1): VIM-4326 run mapping from select mode in visual mode
* [2065](https://github.com/JetBrains/ideavim/pull/2065) by [1grzyb1](https://github.com/1grzyb1): VIM-3999 enable vim in debug console
* [2064](https://github.com/JetBrains/ideavim/pull/2064) by [1grzyb1](https://github.com/1grzyb1): VIM-2629 commentary block comments variable
* [2063](https://github.com/JetBrains/ideavim/pull/2063) by [1grzyb1](https://github.com/1grzyb1): VIM-2627 Configure ideavimrc path
* [2062](https://github.com/JetBrains/ideavim/pull/2062) by [1grzyb1](https://github.com/1grzyb1): VIM-4324 add close window command
* [2060](https://github.com/JetBrains/ideavim/pull/2060) by [1grzyb1](https://github.com/1grzyb1): VIM2663 Implement startinsert and startrepeat commands
* [2059](https://github.com/JetBrains/ideavim/pull/2059) by [1grzyb1](https://github.com/1grzyb1): VIM-2478 restore auto indent after actions
* [2058](https://github.com/JetBrains/ideavim/pull/2058) by [1grzyb1](https://github.com/1grzyb1): VIM-2127 Update corresponding html tag while editing
* [2057](https://github.com/JetBrains/ideavim/pull/2057) by [1grzyb1](https://github.com/1grzyb1): VIM-2089 Implement visual star search plugin
* [2038](https://github.com/JetBrains/ideavim/pull/2038) by [1grzyb1](https://github.com/1grzyb1): VIM-4318 Restore selection with gv after sort
* [2053](https://github.com/JetBrains/ideavim/pull/2053) by [1grzyb1](https://github.com/1grzyb1): VIM-2010 fix repeat insert with import
* [2052](https://github.com/JetBrains/ideavim/pull/2052) by [1grzyb1](https://github.com/1grzyb1): VIM-2700 Navigate between split diff windows
* [2049](https://github.com/JetBrains/ideavim/pull/2049) by [1grzyb1](https://github.com/1grzyb1): VIM-1974 implement textobj-line extension
@@ -63,10 +191,16 @@ usual beta standards.
* [2037](https://github.com/JetBrains/ideavim/pull/2037) by [1grzyb1](https://github.com/1grzyb1): VIM-4314 Expand % signs in commands
* [2034](https://github.com/JetBrains/ideavim/pull/2034) by [1grzyb1](https://github.com/1grzyb1): VIM-4308 Don't update the current search match highlight when no search is active
* [2033](https://github.com/JetBrains/ideavim/pull/2033) by [AndDe-gourav](https://github.com/AndDe-gourav): fix range of visual selection
* [2029](https://github.com/JetBrains/ideavim/pull/2029) by [AndDe-gourav](https://github.com/AndDe-gourav): Fix caret position after change visual character
* [2022](https://github.com/JetBrains/ideavim/pull/2022) by [1grzyb1](https://github.com/1grzyb1): Remove end from Vim only editor keys
* [2029](https://github.com/JetBrains/ideavim/pull/2029) by [AndDe-gourav](https://github.com/AndDe-gourav): Fix caret position after change visual character
* [2022](https://github.com/JetBrains/ideavim/pull/2022) by [1grzyb1](https://github.com/1grzyb1): Remove end from Vim only editor keys
* [2021](https://github.com/JetBrains/ideavim/pull/2021) by [1grzyb1](https://github.com/1grzyb1): VIM-3058 alternate file name register
* [2020](https://github.com/JetBrains/ideavim/pull/2020) by [1grzyb1](https://github.com/1grzyb1): VIM-1370 Implement tag stack in IdeaVim
* [2018](https://github.com/JetBrains/ideavim/pull/2018) by [1grzyb1](https://github.com/1grzyb1): VIM-1347 Implements Signature marks plugin
* [2014](https://github.com/JetBrains/ideavim/pull/2014) by [1grzyb1](https://github.com/1grzyb1): VIM-4312 correct linewise delete promotion when range end has trailing non-whitespace
* [2007](https://github.com/JetBrains/ideavim/pull/2007) by [1grzyb1](https://github.com/1grzyb1): VIM-1266 file name from percent register
* [2005](https://github.com/JetBrains/ideavim/pull/2005) by [1grzyb1](https://github.com/1grzyb1): VIM-1180 Pass eof to external command
* [2001](https://github.com/JetBrains/ideavim/pull/2001) by [1grzyb1](https://github.com/1grzyb1): VIM-934 support for wildmenu option
* [2000](https://github.com/JetBrains/ideavim/pull/2000) by [1grzyb1](https://github.com/1grzyb1): VIM-2168 separate history per split window
@@ -78,6 +212,16 @@ usual beta standards.
### Merged PRs:
* [2013](https://github.com/JetBrains/ideavim/pull/2013) by [1grzyb1](https://github.com/1grzyb1): VIM-4312 Option to disbale vim in python console
## 2.46.1, 2026-08-21
### Fixes:
* [VIM-4310](https://youtrack.jetbrains.com/issue/VIM-4310) Fixed an unknown `:` command reporting the parser's own diagnostic, complete with the full list of token names it was expecting — `:^` now reports `E492: Not an editor command: ^` as Vim does, and `:1,2^abc` reports the command without the range. An error inside a command that is recognised, such as `:echo ^523`, no longer lists the expected tokens either
* [VIM-4309](https://youtrack.jetbrains.com/issue/VIM-4309) Fixed the `YankRing` extension's `<C-P>` and `<C-N>` appearing to do nothing when [`'clipboard'`](https://vimhelp.org/options.txt.html#%27clipboard%27) contains `unnamed` or `unnamedplus` — cycling to another entry in the ring re-pasted from the system clipboard, putting the text it was replacing straight back
### Merged PRs:
* [2010](https://github.com/JetBrains/ideavim/pull/2010) by [1grzyb1](https://github.com/1grzyb1): VIM-4310 Don't show full parser error message
* [2009](https://github.com/JetBrains/ideavim/pull/2009) by [1grzyb1](https://github.com/1grzyb1): VIM-4309 cycle pastes from unnamed register
## 2.46.0, 2026-08-18
### Features:
+6
View File
@@ -81,6 +81,12 @@ If you are looking for:
- Vim script parsing: package `com.maddyhome.idea.vim.vimscript.parser`.
- Vim script executor: `Executor`.
- Help (`:help`):
- Every `@CommandOrMotion` and `@ExCommand` has a `description`. It's rendered into the
[help file](src/main/resources/help/ideavim.txt) that `:help` opens.
- The help file is regenerated with `./gradlew generateHelp`. On `master`, the "Update Help" workflow does it
automatically, so there is no need to commit it with your change.
- Extensions:
- Extensions handler: `VimExtensionHandler`.
- Available extensions: package `com/maddyhome/idea/vim/extension`.
+2 -1
View File
@@ -12,6 +12,7 @@ plugins {
}
val kotlinxSerializationVersion: String by project
val kspVersion: String by project
group = "com.intellij"
version = "SNAPSHOT"
@@ -21,7 +22,7 @@ repositories {
}
dependencies {
compileOnly("com.google.devtools.ksp:symbol-processing-api:2.3.11")
compileOnly("com.google.devtools.ksp:symbol-processing-api:$kspVersion")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:$kotlinxSerializationVersion") {
// kotlin stdlib is provided by IJ, so there is no need to include it into the distribution
exclude("org.jetbrains.kotlin", "kotlin-stdlib")
@@ -17,7 +17,24 @@ package com.intellij.vim.annotations
*/
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
annotation class CommandOrMotion(val keys: Array<String>, vararg val modes: Mode)
annotation class CommandOrMotion(
val keys: Array<String>,
vararg val modes: Mode,
/**
* Registers this handler for the same keys as its normal counterpart, to be used only while a completion popup is
* open.
*
* `<C-Y>` accepts the selected item with a popup open and inserts the character above the caret without one. Those
* are two commands sharing a keystroke, so they are two handlers: `AcceptLookupAction` declares `lookup = true` and
* `InsertCharacterAboveCursorAction` does not. Exactly one of each pair may exist for a given key and mode.
*/
val lookup: Boolean = false,
/**
* A short description of what the command does, written for users. It's rendered into IdeaVim's help file
* (`:help`), so keep it in the same style as Vim's own help: a sentence or two, in the imperative mood.
*/
val description: String,
)
annotation class TextObject(val keys: String)
@@ -10,7 +10,23 @@ package com.intellij.vim.annotations
/**
* [command] is formatted the same way it is formatted in Vim (with optional part in square brackets).
*
* [barSeparates] and [delimitedSections] say how a `|` in the argument is treated, the job Vim's command table does
* with its `TRLBAR` flag. Repeat the annotation when the names it declares need different values.
* See `CommandVisitor.splitOffNextCommand` and `:help :bar`.
*/
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
annotation class ExCommand(val command: String)
@Repeatable
annotation class ExCommand(
val command: String,
/**
* A short description of what the command does, written for users. It's rendered into IdeaVim's help file
* (`:help`), so keep it in the same style as Vim's own help: a sentence or two, in the imperative mood.
*/
val description: String,
/** `false` when the command takes a `|` as part of its argument, so it can never be followed by another command */
val barSeparates: Boolean = true,
/** The number of delimiter-wrapped sections the argument holds, like the pattern and replacement of `:s/pat/sub/` */
val delimitedSections: Int = 0,
)
@@ -11,4 +11,4 @@ package com.intellij.vim.processors
import kotlinx.serialization.Serializable
@Serializable
data class CommandBean(val keys: String, val `class`: String, val modes: String)
data class CommandBean(val keys: String, val `class`: String, val modes: String, val lookup: Boolean = false)
@@ -41,7 +41,12 @@ class CommandOrMotionProcessor(private val environment: SymbolProcessorEnvironme
val commandAnnotation = classDeclaration.getAnnotationsByType(CommandOrMotion::class).firstOrNull() ?: return
for (key in commandAnnotation.keys) {
commands.add(
CommandBean(key, classDeclaration.qualifiedName!!.asString(), commandAnnotation.modes.map { it.abbrev }.joinToString(separator = ""))
CommandBean(
key,
classDeclaration.qualifiedName!!.asString(),
commandAnnotation.modes.map { it.abbrev }.joinToString(separator = ""),
commandAnnotation.lookup,
)
)
}
}
@@ -18,10 +18,19 @@ import com.google.devtools.ksp.symbol.KSClassDeclaration
import com.google.devtools.ksp.symbol.KSFile
import com.google.devtools.ksp.symbol.KSVisitorVoid
import com.intellij.vim.annotations.ExCommand
import kotlinx.serialization.Serializable
/** One entry of the generated ex-command table, read back by `ExCommandProvider` */
@Serializable
internal data class ExCommandEntry(
val className: String,
val barSeparates: Boolean = true,
val delimitedSections: Int = 0,
)
class ExCommandProcessor(private val environment: SymbolProcessorEnvironment) : SymbolProcessor {
private val visitor = EXCommandVisitor()
private val commandToClass = mutableMapOf<String, String>()
private val commandToClass = mutableMapOf<String, ExCommandEntry>()
private val fileWriter = JsonFileWriter(environment)
override fun process(resolver: Resolver): List<KSAnnotated> {
@@ -29,7 +38,8 @@ class ExCommandProcessor(private val environment: SymbolProcessorEnvironment) :
resolver.getAllFiles().forEach { it.accept(visitor, Unit) }
val sortedCommandToClass = commandToClass.toList().sortedWith(compareBy({ it.first }, { it.second })).toMap()
val sortedCommandToClass =
commandToClass.toList().sortedWith(compareBy({ it.first }, { it.second.className })).toMap()
fileWriter.write(exCommandsFile, sortedCommandToClass)
return emptyList()
@@ -38,10 +48,15 @@ class ExCommandProcessor(private val environment: SymbolProcessorEnvironment) :
private inner class EXCommandVisitor : KSVisitorVoid() {
@OptIn(KspExperimental::class)
override fun visitClassDeclaration(classDeclaration: KSClassDeclaration, data: Unit) {
val exCommandAnnotation = classDeclaration.getAnnotationsByType(ExCommand::class).firstOrNull() ?: return
val commands = exCommandAnnotation.command.split(",")
for (command in commands) {
commandToClass[command] = classDeclaration.qualifiedName!!.asString()
// The annotation is repeatable, so that one class can declare different bar handling for different names
for (exCommandAnnotation in classDeclaration.getAnnotationsByType(ExCommand::class)) {
for (command in exCommandAnnotation.command.split(",")) {
commandToClass[command] = ExCommandEntry(
classDeclaration.qualifiedName!!.asString(),
exCommandAnnotation.barSeparates,
exCommandAnnotation.delimitedSections,
)
}
}
}
@@ -0,0 +1,100 @@
/*
* 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.intellij.vim.processors
import com.google.devtools.ksp.KspExperimental
import com.google.devtools.ksp.getAnnotationsByType
import com.google.devtools.ksp.processing.Resolver
import com.google.devtools.ksp.processing.SymbolProcessor
import com.google.devtools.ksp.processing.SymbolProcessorEnvironment
import com.google.devtools.ksp.symbol.KSAnnotated
import com.google.devtools.ksp.symbol.KSClassDeclaration
import com.google.devtools.ksp.symbol.KSFile
import com.google.devtools.ksp.symbol.KSVisitorVoid
import com.intellij.vim.annotations.CommandOrMotion
import com.intellij.vim.annotations.ExCommand
import kotlinx.serialization.Serializable
/** The descriptions of one module's commands, merged with the other modules' into the help file by `generateHelp` */
@Serializable
internal data class HelpData(
val exCommands: List<ExCommandHelp>,
val commands: List<CommandHelp>,
)
@Serializable
internal data class ExCommandHelp(
/** The command names as declared by [ExCommand.command], e.g. `h[elp]` */
val names: List<String>,
val description: String,
val className: String,
)
@Serializable
internal data class CommandHelp(
val keys: List<String>,
/** The abbreviations of the modes, as in [CommandBean.modes] */
val modes: String,
val lookup: Boolean,
val description: String,
val className: String,
)
/**
* Collects the descriptions of [ExCommand] and [CommandOrMotion] declarations.
*
* Unlike the command tables, the output is not needed at runtime, so it goes to `help_directory` (in the build
* directory) rather than to the resources.
*/
class HelpProcessor(private val environment: SymbolProcessorEnvironment) : SymbolProcessor {
private val visitor = HelpVisitor()
private val exCommands = mutableListOf<ExCommandHelp>()
private val commands = mutableListOf<CommandHelp>()
private val fileWriter = JsonFileWriter(environment)
override fun process(resolver: Resolver): List<KSAnnotated> {
val helpFile = environment.options["help_file"] ?: return emptyList()
val helpDirectory = environment.options["help_directory"] ?: return emptyList()
resolver.getAllFiles().forEach { it.accept(visitor, Unit) }
val data = HelpData(
exCommands.sortedWith(compareBy({ it.names.first() }, { it.className })),
commands.sortedWith(compareBy({ it.keys.first() }, { it.modes }, { it.className })),
)
fileWriter.write(helpFile, data, helpDirectory)
return emptyList()
}
private inner class HelpVisitor : KSVisitorVoid() {
@OptIn(KspExperimental::class)
override fun visitClassDeclaration(classDeclaration: KSClassDeclaration, data: Unit) {
val className = classDeclaration.qualifiedName!!.asString()
for (annotation in classDeclaration.getAnnotationsByType(ExCommand::class)) {
exCommands.add(ExCommandHelp(annotation.command.split(","), annotation.description, className))
}
for (annotation in classDeclaration.getAnnotationsByType(CommandOrMotion::class)) {
commands.add(
CommandHelp(
annotation.keys.toList(),
annotation.modes.map { it.abbrev }.joinToString(separator = ""),
annotation.lookup,
annotation.description,
className,
)
)
}
}
override fun visitFile(file: KSFile, data: Unit) {
file.declarations.forEach { it.accept(this, Unit) }
}
}
}
@@ -28,8 +28,8 @@ internal class JsonFileWriter(
prettyPrintIndent = " "
}
inline fun <reified T> write(fileName: String, data: T) {
val generatedDirPath = Path(environment.options["generated_directory"]!!)
inline fun <reified T> write(fileName: String, data: T, directory: String = environment.options["generated_directory"]!!) {
val generatedDirPath = Path(directory)
Files.createDirectories(generatedDirPath)
val filePath = generatedDirPath.resolve(fileName)
@@ -0,0 +1,20 @@
/*
* 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.intellij.vim.providers
import com.google.devtools.ksp.processing.SymbolProcessor
import com.google.devtools.ksp.processing.SymbolProcessorEnvironment
import com.google.devtools.ksp.processing.SymbolProcessorProvider
import com.intellij.vim.processors.HelpProcessor
class HelpProcessorProvider : SymbolProcessorProvider {
override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor {
return HelpProcessor(environment)
}
}
@@ -2,3 +2,4 @@ com.intellij.vim.providers.CommandOrMotionProcessorProvider
com.intellij.vim.providers.ExCommandProcessorProvider
com.intellij.vim.providers.VimscriptFunctionProcessorProvider
com.intellij.vim.providers.ExtensionsProcessorProvider
com.intellij.vim.providers.HelpProcessorProvider
+15 -10
View File
@@ -25,14 +25,14 @@ buildscript {
classpath("org.eclipse.jgit:org.eclipse.jgit:6.6.0.202305301015-r")
// This is needed for jgit to connect to ssh
classpath("org.eclipse.jgit:org.eclipse.jgit.ssh.apache:7.7.1.202607240634-r")
classpath("org.eclipse.jgit:org.eclipse.jgit.ssh.apache:7.8.0.202609011348-r")
classpath("org.kohsuke:github-api:1.305")
classpath("io.ktor:ktor-client-core:3.5.2")
classpath("io.ktor:ktor-client-cio:3.5.2")
classpath("io.ktor:ktor-client-auth:3.5.2")
classpath("io.ktor:ktor-client-content-negotiation:3.5.2")
classpath("io.ktor:ktor-serialization-kotlinx-json:3.5.2")
classpath("io.ktor:ktor-client-core:3.6.0")
classpath("io.ktor:ktor-client-cio:3.6.0")
classpath("io.ktor:ktor-client-auth:3.6.0")
classpath("io.ktor:ktor-client-content-negotiation:3.6.0")
classpath("io.ktor:ktor-serialization-kotlinx-json:3.6.0")
// This comes from the changelog plugin
// classpath("org.jetbrains:markdown:0.3.1")
@@ -52,7 +52,7 @@ plugins {
id("org.jetbrains.changelog") version "2.5.0"
id("com.dorongold.task-tree") version "4.0.2"
id("com.google.devtools.ksp") version "2.3.7"
id("com.google.devtools.ksp")
}
val moduleSources by configurations.registering
@@ -134,6 +134,7 @@ dependencies {
pluginModule(runtimeOnly(project(":ideavim-rider")))
pluginModule(runtimeOnly(project(":ideavim-clion-nova")))
pluginModule(runtimeOnly(project(":ideavim-terminal")))
pluginModule(runtimeOnly(project(":ideavim-rpc")))
bundledModule("intellij.spellchecker")
if (bookmarksIsSeparatePlugin) bundledPlugin("intellij.bookmarks.plugin")
@@ -175,7 +176,7 @@ dependencies {
// Temp workaround suggested in https://plugins.jetbrains.com/docs/intellij/tools-intellij-platform-gradle-plugin-faq.html#junit5-test-framework-refers-to-junit4
// Can be removed when IJPL-159134 is fixed
// testRuntimeOnly("junit:junit:4.13.2")
testImplementation("org.junit.vintage:junit-vintage-engine:6.1.2")
testImplementation("org.junit.vintage:junit-vintage-engine:6.1.3")
// testFixturesImplementation("org.junit.vintage:junit-vintage-engine:5.10.3")
}
@@ -326,7 +327,7 @@ tasks {
}
val runRider by intellijPlatformTesting.runIde.registering {
type = IntelliJPlatformType.Rider
version = "2026.2.0.2"
version = "2026.2.3.1"
// Using Rider as a target IntelliJ Platform with `useInstaller = true` is currently not supported, please set `useInstaller = false` instead. See: https://github.com/JetBrains/intellij-platform-gradle-plugin/issues/1852
useInstaller = false
task {
@@ -506,7 +507,7 @@ gradle.projectsEvaluated {
// The rendered change notes body, plus the version whose CHANGES.md section it came from.
// Patch releases have no section of their own (promoteChangelog.ts rolls them into the parent
// minor), so 2.45.1 resolves to 2.45.0 - and so does its YouTrack link, because releaseActions.kt
// minor), so 2.45.1 resolves to 2.45.0 - and so does its YouTrack link, because releaseActions.ts
// only ever creates fix versions for minors. Note that getLatest() returns the first section in
// file order, not the highest version, so CHANGES.md must stay sorted and free of empty headers.
val changeNotesParts = provider {
@@ -570,9 +571,13 @@ ksp {
arg("ex_commands_file", "frontend_ex_commands.json")
arg("vimscript_functions_file", "frontend_vimscript_functions.json")
arg("extensions_file", "ideavim_extensions.json")
arg("help_directory", layout.buildDirectory.dir("help").get().asFile.path)
arg("help_file", "frontend_help.json")
}
afterEvaluate {
// Written by HelpProcessor, see the generateHelp task
tasks.named("kspKotlin").configure { outputs.dir(layout.buildDirectory.dir("help")) }
tasks.named("kspTestFixturesKotlin").configure { enabled = false }
tasks.named("kspTestKotlin").configure { enabled = false }
}
+158
View File
@@ -221,6 +221,57 @@ When classes are nested, the innermost enclosing class is selected.
</details>
<details>
<summary><h2>clever-f: Repeat f, F, t and T by pressing the same key again</h2></summary>
Original plugin: [clever-f.vim](https://github.com/rhysd/clever-f.vim).
### Summary:
Extends `f`, `F`, `t` and `T` so that pressing the same key again repeats the last search, instead of `;` and `,`.
After `f{char}`, `f` jumps to the next `{char}` and `F` to the previous one. The direction is relative to the first
search, so after `F{char}`, `f` keeps searching backward. Unlike the builtin motions, searches cross line boundaries.
Moving the cursor in any other way, changing the text or entering Insert mode starts a new search. Works in normal,
visual and operator-pending modes, and takes a `[count]`. The characters the search can jump to on the cursor line
are highlighted until the search ends.
### Setup:
- Add the following command to `~/.ideavimrc`: `Plug 'rhysd/clever-f.vim'`
<details>
<summary>Alternative syntax</summary>
<code>Plugin 'rhysd/clever-f.vim'</code>
<br/>
<code>Plug 'https://github.com/rhysd/clever-f.vim'</code>
<br/>
<code>Plug 'clever-f.vim'</code>
<br/>
<code>set clever-f</code>
</details>
### Instructions
https://github.com/rhysd/clever-f.vim/blob/master/doc/clever_f.txt
Supported variables (`g:clever_f_not_overwrites_standard_mappings` must be set before the plugin is loaded):
| Variable | Description |
|----------|-------------|
| `g:clever_f_across_no_line` | Search only in the current line |
| `g:clever_f_ignore_case` | Ignore case |
| `g:clever_f_smart_case` | Ignore case unless the input is an uppercase character |
| `g:clever_f_fix_key_direction` | `f` always searches forward and `F` always backward |
| `g:clever_f_chars_match_any_signs` | Characters which match any sign, e.g. `';'` |
| `g:clever_f_repeat_last_char_inputs` | Keys which repeat the last searched character, `<CR>` by default |
| `g:clever_f_timeout_ms` | Start a new search if the last one is older than this |
| `g:clever_f_mark_char` | Highlight the characters the search can jump to, enabled by default |
| `g:clever_f_not_overwrites_standard_mappings` | Don't map `f`, `F`, `t` and `T` |
Available `<Plug>` mappings: `<Plug>(clever-f-f)`, `<Plug>(clever-f-F)`, `<Plug>(clever-f-t)`, `<Plug>(clever-f-T)`,
`<Plug>(clever-f-repeat-forward)`, `<Plug>(clever-f-repeat-back)` and `<Plug>(clever-f-reset)`.
`g:clever_f_mark_cursor`, `g:clever_f_mark_direct`, custom highlight colors and migemo are not currently supported.
</details>
<details>
<summary><h2>commentary: Adds mapping for quickly commenting stuff out</h2></summary>
@@ -255,6 +306,9 @@ That's it.
https://github.com/tpope/vim-commentary/blob/master/doc/commentary.txt
Unlike vim-commentary, IdeaVim uses a block comment when the range is not linewise, so `gciw` can
comment out a single argument. Set `let g:commentary_block_comments = 0` to always comment whole lines.
</details>
<details>
@@ -501,6 +555,33 @@ absolute-indent motions (`[_`, `]_`) from the original plugin are not currently
</details>
<details>
<summary><h2>keylog: Writes every typed key to a file</h2></summary>
### Summary:
Logs all the keys you type, like Vim's `vim -w {scriptout}` command line argument. Use the log to find out which
commands you use most often.
### Setup:
- Add the following command to `~/.ideavimrc`: `set keylog`
### Instructions
The keys are appended to `ideavim.log` in the same directory as your ideavimrc file (e.g. `~/ideavim.log` for
`~/.ideavimrc`). To use another file:
`let g:keylog_file = "~/vimlogs/ideavim.log"`
A relative path is relative to the directory of your ideavimrc file.
The keys are written in Vim's key notation, as used by `:map`, e.g. `ciwfoo<Esc>`. A typed `<` is written as `<lt>`.
Only the keys you type are logged, not the keys produced by mappings, macros or `.`.
The keys are buffered in memory and written to the file every few seconds, and when the extension is disabled or the
IDE exits.
The log contains everything you type, including passwords typed into the editor. Keep it private.
</details>
<details>
<summary><h2>matchit.vim: Extends the % key functionality</h2></summary>
@@ -525,6 +606,8 @@ The matchit plugin extends this in several ways...
### Instructions
Your own pairs can be defined in the `b:match_words` buffer variable, e.g. `let b:match_words = 'begin:end,if:elsif:else:end'`.
https://github.com/adelarsq/vim-matchit/blob/master/doc/matchit.txt
</details>
@@ -1132,6 +1215,81 @@ component has focus.
</details>
<details>
<summary><h2>visual-multi: Multiple cursors and selections</h2></summary>
Original plugin: [vim-visual-multi](https://github.com/mg979/vim-visual-multi).
### Setup:
- Add the following command to `~/.ideavimrc`: `Plug 'mg979/vim-visual-multi'`
<details>
<summary>Alternative syntax</summary>
<code>Plugin 'mg979/vim-visual-multi'</code>
<br/>
<code>Plug 'https://github.com/mg979/vim-visual-multi'</code>
<br/>
<code>Plug 'vim-visual-multi'</code>
<br/>
<code>set visual-multi</code>
</details>
Don't enable it together with `multiple-cursors`, both use `<C-n>`.
### Instructions
The cursors are the native IDE carets. The regions are their Visual selections, so motions and commands work on all
of them. Press `<Tab>` to switch between regions and cursors.
`\\` is the leader, see `g:VM_leader` below.
Available anytime:
| Action | Shortcut |
|---------------------------------------------------------|------------------------|
| Select word under cursor / selection, add next one | `<C-n>` |
| Select all occurrences of word / selection | `\\A` |
| Add cursor down / up | `<C-Down>` / `<C-Up>` |
| Cursor on each line of selection | `\\c` (Visual) |
| Selection becomes regions | `\\a` (Visual) |
| Reselect regions of the last session | `\\gS` |
With multiple cursors or regions (a session):
| Action | Shortcut |
|---------------------------------------------------------|------------------------|
| Find next / previous occurrence | `n` / `N` |
| Go to next / previous region | `]` / `[` |
| Skip region / remove region | `q` / `Q` |
| Switch between regions and cursors | `<Tab>` |
| Insert before / after regions, at line start / end | `i` / `a` / `I` / `A` |
| Run `:normal` command / repeat it | `\\z` / `\\Z` |
| Run macro | `\\@` |
| Case conversion (`u` `U` `C` `t` `c` `P` `s` `S` `-` `.` `<Space>`) | `\\C` |
| Align cursors | `\\a` |
| Exit | `<Esc>` |
`n`, `N`, `]`, `[`, `q`, `Q`, `i`, `a`, `I`, `A` and `<Esc>` work on regions, the others on regions and cursors.
Outside a session, these keys keep their Vim meaning. Every action has a `<Plug>` mapping named after it, e.g.
`<Plug>(VM-Find-Under)`.
### Options
```
" Change mappings, an empty string disables one
let g:VM_maps = {}
let g:VM_maps['Find Under'] = '<C-d>'
let g:VM_maps['Find Subword Under'] = '<C-d>'
let g:VM_leader = '\' " Default is '\\'
let g:VM_default_mappings = 0 " Keep only <C-n>
let g:VM_case_setting = 'smart' " 'smart', 'sensitive' or 'ignore', default follows 'ignorecase'
let g:VM_skip_shorter_lines = 0 " <C-Down> and <C-Up> don't skip shorter lines
let g:VM_skip_empty_lines = 1 " <C-Down> and <C-Up> skip empty lines
let g:VM_quit_after_leaving_insert_mode = 1
```
</details>
<details>
<summary><h2>visual-star-search: Makes * and # search for the selected text</h2></summary>
+18 -1
View File
@@ -41,7 +41,7 @@ Plug 'nerdtree'
- `:NERDTreeRefreshRoot`
| Key | Description | Map Setting |
|---------|--------------------------------------------------------|--------------------------------|
|---------|--------------------------------------------------------|-----------------------------------|
| `o` | Open files, directories and bookmarks | `g:NERDTreeMapActivateNode` |
| `go` | Open selected file, but leave cursor in the NERDTree | `g:NERDTreeMapPreview` |
| `t` | Open selected node/bookmark in a new tab | `g:NERDTreeMapOpenInTab` |
@@ -59,6 +59,13 @@ Plug 'nerdtree'
| `J` | Jump down inside directories at the current tree depth | `g:NERDTreeMapJumpLastChild` |
| `<C-J>` | Jump down to next sibling of the current directory | `g:NERDTreeMapJumpNextSibling` |
| `<C-K>` | Jump up to previous sibling of the current directory | `g:NERDTreeMapJumpPrevSibling` |
| `j` | Move the selection down | `g:NERDTreeMapSelectNext` |
| `k` | Move the selection up | `g:NERDTreeMapSelectPrevious` |
| `gg` | Jump to the first node | `g:NERDTreeMapSelectFirst` |
| `G` | Jump to the last node | `g:NERDTreeMapSelectLast` |
| `<C-D>` | Move the selection half a page down | `g:NERDTreeMapScrollHalfPageDown` |
| `<C-U>` | Move the selection half a page up | `g:NERDTreeMapScrollHalfPageUp` |
| `/` | Start speed search | `g:NERDTreeMapSpeedSearch` |
| `r` | Recursively refresh the current directory | `g:NERDTreeMapRefresh` |
| `R` | Recursively refresh the current root | `g:NERDTreeMapRefreshRoot` |
| `m` | Display the NERDTree menu | `g:NERDTreeMapMenu` |
@@ -68,6 +75,16 @@ Plug 'nerdtree'
| `n` | Create File | `g:NERDTreeMapNewFile` |
| `N` | Create Directory | `g:NERDTreeMapNewDir` |
### Changing the keys
Every key from the table above can be changed by setting the corresponding variable in your `~/.ideavimrc`:
```vim
let g:NERDTreeMapActivateNode = 'l'
let g:NERDTreeMapJumpParent = 'h'
let g:NERDTreeMapSelectNext = '<C-N>'
set NERDTree
```
### Troubleshooting
If you see the the file is opened automatically on every movement in the Project View (`j`, `k`, etc.)
+57 -13
View File
@@ -9,9 +9,13 @@ However, some differences are inevitable.
```
'autoindent' 'ai' Copy indent from current line when starting a new line
Unlike Vim, this option is on by default, which matches Neovim. With
the option on, the indent is removed again if you leave Insert mode
without typing anything.
On by default, unlike Vim, matching Neovim. Only the copying is
controlled: the indent the IDE works out from the code is the
counterpart of 'indentexpr' and stays either way, so 'noautoindent'
gives column 0 only in a file the IDE has no formatter for.
Unlike Vim, a line opened by 'o', 'O' or Enter keeps its indent when
you leave Insert mode without typing. 'cc' and 'S' clear it, unless
"Keep indents on empty lines" (Editor | Code Style) is on.
'clipboard' 'cb' Defines clipboard behavior
A comma-separated list of words to control clipboard behaviour:
@@ -238,6 +242,37 @@ The old name is deprecated, but still accepted by `:set` and by the `&{option}`
string literals or if statements. See the wiki for more examples. Not
all languages support smart join functionality.
'ideaeditor' string (default "python,debug")
global
Comma-separated list of non-file editors that IdeaVim is active in, in
addition to the regular editors it always supports:
python The Python console tool window. The console still
intercepts Enter (execute) and the horizontal arrow
keys (caret movement within the input line) as normal;
Up/Down navigate command history from any Vim mode.
debug The console of a run or debug configuration, e.g. the
console of a debugged Spring Boot application. It is
focused in Normal mode, so the first <Esc> leaves
Insert or Visual mode and the next one defocuses the
tool window.
main Tabs in the main editor area that are not backed by
a regular file, e.g. editors opened by third-party
plugins for in-memory or decrypted content. Not
enabled by default. Enable with
`:set ideaeditor+=main`.
chat The prompt input of AI chats: JetBrains AI Assistant
(including Junie in AI Assistant) and AIR. The input
is focused in Insert mode, so <Enter> still sends the
message; in Normal mode <Enter> is a Vim motion. Chat
answers are not affected. Not enabled by default.
Enable with `:set ideaeditor+=chat`.
Remove a value to revert that editor to its native behaviour with no
Vim keybindings. The change takes effect immediately for all open
console and chat input windows.
This option replaces 'ideapythonconsole', which has been removed.
'idealookupkeys' string (default "<Tab>,<Down>,<Up>,<Enter>,
<Left>,<Right>,<C-Down>,<C-Up>,
<PageUp>,<PageDown>, <C-J>,<C-Q>")
@@ -254,19 +289,20 @@ The old name is deprecated, but still accepted by `:set` and by the `&{option}`
global
Maps Vim's global marks to IDE bookmarks.
'ideapythonconsole' boolean (default on)
'ideaoutsideeditor' boolean (default on)
global
When on, IdeaVim is active in the Python console tool window. The
console still intercepts Enter (execute) and the horizontal arrow keys
(caret movement within the input line) as normal; Up/Down navigate
command history from any Vim mode.
When on, Normal mode mappings to IDE actions keep working when no
file is open, e.g. `map <leader>sf <Action>(GotoFile)` pressed while
the Project tool window has the focus and every editor tab is closed.
Set 'noideapythonconsole' to revert to the console's native
behaviour with no Vim keybindings. The change takes effect immediately
for all open Python console windows.
Only user mappings whose right-hand side consists of <Action>(...)
sequences take part, since there is no editor for anything else to
act on. Text fields, such as Search Everywhere, and modal dialogs are
not affected, and keys that do not start such a mapping are left to
the IDE. <Esc> cancels a partially typed mapping.
This option was previously called 'pythonconsole'. The old name is
deprecated, but still accepted.
Turn the option off to leave all keys typed outside an editor to the
IDE. The change takes effect immediately.
'idearefactormode' string (default "select")
global or local to buffer
@@ -279,6 +315,14 @@ The old name is deprecated, but still accepted by `:set` and by the `&{option}`
This option is only used when the refactoring is started in Normal,
Insert or Replace mode. Visual or Select modes are not changed.
'ideascrollcursor' boolean (default off)
global
When on, scrolling with the mouse wheel or by dragging the scrollbar
moves the cursor along with the text, so it stays on screen, like in
Vim. The cursor is kept 'scrolloff' lines away from the edge of the
window. When off, IntelliJ's default behaviour is kept and the cursor
stays where it is, even if it scrolls out of view.
'ideastatusicon' string (default "enabled")
global
This option controls the behaviour and appearance of the IdeaVim icon
+3
View File
@@ -33,6 +33,9 @@ By using `sethandler` you can define handlers:
- For a single shortcut: `sethandler <C-A> n:vim i-x:ide` - use Vim handler in normal mode and IDE handler in insert and visual modes,
- For all shortcuts: `sethandler n:vim i:ide` - use Vim handlers in normal mode and IDE handlers in insert mode.
`<CR>`, `<Esc>` and the plain arrow keys (`<Up>`, `<Down>`, `<Left>`, `<Right>`) are always handled by Vim unless
you set a handler for that particular key, e.g. `sethandler <CR> i:ide`. Handlers for all shortcuts don't apply to them.
Later definitions override earlier ones, so `a:ide c:vim` first assigns every mode to the IDE
and then hands command-line mode back to Vim. This is useful for keeping a `cnoremap` working
while the IDE owns the shortcut everywhere else:
+2 -1
View File
@@ -18,13 +18,14 @@ ideaVersion=2026.1
# Values for type: https://plugins.jetbrains.com/docs/intellij/tools-gradle-intellij-plugin.html#intellij-extension-type
ideaType=IU
instrumentPluginCode=true
version=9999.59-chylex
version=9999.62-chylex
javaVersion=21
remoteRobotVersion=0.11.23
antlrVersion=4.10.1
# Please don't forget to update kotlin version in buildscript section
# Also update kotlinxSerializationVersion version
kotlinVersion=2.3.20
kspVersion=2.3.7
publishToken=token
publishChannels=eap
# Kotlinx serialization also uses some version of kotlin stdlib under the hood. However,
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
Vendored
+41 -11
View File
@@ -26,6 +26,33 @@
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
@rem Catch executions from older scripts and ensure they exit cleanly.
@rem This can be removed once we can be reasonably confident that few people
@rem will be migrating directly to this new wrapper.
goto afterSafetyNet
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
goto exitWithErrorLevel
:afterSafetyNet
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
@@ -45,13 +72,14 @@ set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
1>&2 echo.
1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
1>&2 echo.
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
1>&2 echo location of your Java installation.
"%COMSPEC%" /c exit 1
goto exitWithErrorLevel
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
@@ -59,13 +87,14 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
1>&2 echo.
1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
1>&2 echo.
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
1>&2 echo location of your Java installation.
"%COMSPEC%" /c exit 1
goto exitWithErrorLevel
:execute
@rem Setup the command line
@@ -75,8 +104,9 @@ echo location of your Java installation. 1>&2
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel
@rem This label must not be changed. We rely on old scripts being able to jump to this point.
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
@@ -0,0 +1,18 @@
/*
* 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.action
/**
* RPC handler for [ActionIdRemoteApi]. Stores the frontend's actions for the ideavimrc completion.
*/
internal class ActionIdRemoteApiImpl : ActionIdRemoteApi {
override suspend fun setFrontendActions(actions: List<ActionIdInfo>) {
FrontendActionIds.getInstance().actions = actions.associateBy { it.id }
}
}
@@ -0,0 +1,24 @@
/*
* 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.action
import com.intellij.platform.rpc.backend.RemoteApiProvider
import fleet.rpc.remoteApiDescriptor
/**
* Registers [ActionIdRemoteApiImpl] as the backend RPC handler for [ActionIdRemoteApi].
* Registered via `platform.rpc.backend.remoteApiProvider` extension point in ideavim-backend.xml.
*/
internal class ActionIdRemoteApiProvider : RemoteApiProvider {
override fun RemoteApiProvider.Sink.remoteApis() {
remoteApi(remoteApiDescriptor<ActionIdRemoteApi>()) {
ActionIdRemoteApiImpl()
}
}
}
@@ -8,66 +8,126 @@
package com.maddyhome.idea.vim.group.format
import com.intellij.application.options.CodeStyle
import com.intellij.lang.LanguageFormatting
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.actionSystem.IdeActions
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.command.CommandProcessor
import com.intellij.openapi.editor.Document
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.impl.EditorId
import com.intellij.openapi.editor.impl.findEditorOrNull
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.TextRange
import com.intellij.psi.PsiDocumentManager
import com.intellij.psi.PsiFile
import com.intellij.psi.codeStyle.ExternalFormatProcessor
import com.intellij.psi.util.PsiUtilCore
import com.maddyhome.idea.vim.group.onEdt
/**
* RPC handler for [FormatRemoteApi].
*
* Sets selection on the backend editor and executes the platform's auto-indent action.
* Because this runs on the backend, [CommandProcessor] groups all document modifications
* as a single undo step.
*
* Multiple ranges are processed bottom-to-top so that formatting one range does not
* shift the line numbers of ranges above it.
*
* Caret positioning after formatting is handled by the caller (vim-engine layer),
* not by this backend handler, so that each caller can position the correct caret
* (important for per-caret handlers like `==`).
* Runs on the backend, so [CommandProcessor] groups the document modifications into a single undo
* step. Caret positioning is left to the caller (vim-engine layer), which knows which caret the
* range belongs to - important for per-caret handlers like `==`.
*/
class FormatRemoteApiImpl : FormatRemoteApi {
internal class FormatRemoteApiImpl : FormatRemoteApi {
override suspend fun format(
editorId: EditorId,
startOffsets: List<Int>,
endOffsets: List<Int>,
startOffset: Int,
endOffset: Int,
) = onEdt {
val editor = editorId.findEditorOrNull() ?: return@onEdt
val document = editor.document
val project = editor.project ?: return@onEdt
val lines = expandToWholeLines(editor.document, startOffset, endOffset)
// Convert offsets to line ranges, adjusting endLine when it falls exactly on a line start
val lineRanges = getLineRanges(startOffsets, endOffsets, document)
val psiFile = PsiDocumentManager.getInstance(project).getPsiFile(editor.document)
val externalFormatter = psiFile?.let { ExternalFormatProcessor.activeExternalFormatProcessor(it) }
if (psiFile != null && externalFormatter != null) {
formatExternally(externalFormatter, project, psiFile, lines)
} else {
autoIndentLines(editor, lines)
}
}
// Sort ranges by startLine descending so we format bottom-to-top.
// This ensures formatting a later range doesn't shift line numbers of earlier ranges.
val sortedRanges = lineRanges.sortedByDescending { it.first }
/**
* Goes through [CodeStyle.getLineIndent], as the Enter handler does: a
* [LineIndentProvider][com.intellij.psi.codeStyle.lineIndent.LineIndentProvider] of the language answers straight
* from the text, and the formatter, which needs the PSI to be up to date, is only asked if there is none.
*
* The document is committed up front because the fallback to the formatter would otherwise commit it from inside the
* read action.
*/
override suspend fun lineIndent(editorId: EditorId, offset: Int): String? = onEdt {
val editor = editorId.findEditorOrNull() ?: return@onEdt null
val project = editor.project ?: return@onEdt null
val documentManager = PsiDocumentManager.getInstance(project)
ApplicationManager.getApplication().runWriteAction { documentManager.commitDocument(editor.document) }
ApplicationManager.getApplication().runReadAction<String?> {
val psiFile = documentManager.getPsiFile(editor.document) ?: return@runReadAction null
if (LanguageFormatting.INSTANCE.forContext(psiFile) == null) return@runReadAction null
val language = PsiUtilCore.getLanguageAtOffset(psiFile, offset)
CodeStyle.getLineIndent(editor, language, offset, true)
}
}
/**
* A file owned by an [ExternalFormatProcessor] (C/C++ in CLion Nova, where formatting lives in the
* Radler process) cannot be handled by the auto-indent action: that action is delegated further,
* to the external process, which reads its range from the editor selection - and a selection set
* in the same event-loop turn as the dispatch does not get there in time, so the action falls back
* to its no-selection behaviour and reformats the whole file.
*
* The processor is called directly rather than through `CodeStyleManager.reformatText`, which
* re-derives the range from PSI smart pointers (`CoreCodeStyleUtil.RangeFormatInfo`). The C++ PSI
* in Nova is a dummy tree of coarse nodes, so the range that comes out of it is not the one that
* went in.
*
* Note that this reformats [lines] instead of only re-indenting them; Nova exposes no indent-only
* entry point.
*/
private fun formatExternally(
formatter: ExternalFormatProcessor,
project: Project,
psiFile: PsiFile,
lines: TextRange,
) {
CommandProcessor.getInstance().executeCommand(project, {
ApplicationManager.getApplication().runWriteAction {
val documentManager = PsiDocumentManager.getInstance(project)
documentManager.getDocument(psiFile)?.let { documentManager.commitDocument(it) }
formatter.format(psiFile, lines, false, false, true, -1)
}
}, COMMAND_NAME, null)
}
/** The auto-indent action takes its range from the editor selection, hence the selection dance. */
private fun autoIndentLines(editor: Editor, lines: TextRange) {
CommandProcessor.getInstance().executeCommand(editor.project, {
val action = ActionManager.getInstance().getAction(IdeActions.ACTION_EDITOR_AUTO_INDENT_LINES)
for ((startLine, endLine) in sortedRanges) {
val selStart = document.getLineStartOffset(startLine)
val selEnd = document.getLineEndOffset(endLine)
editor.caretModel.currentCaret.setSelection(selStart, selEnd, false)
editor.caretModel.currentCaret.setSelection(lines.startOffset, lines.endOffset, false)
try {
ActionManager.getInstance().tryToExecute(action, null, editor.contentComponent, "IdeaVim", true).waitFor(5_000)
}
} finally {
editor.selectionModel.removeSelection()
}, "AutoIndent", null)
}
}, COMMAND_NAME, null)
}
private fun getLineRanges(
startOffsets: List<Int>,
endOffsets: List<Int>,
document: Document,
): List<Pair<Int, Int>> = startOffsets.zip(endOffsets).map { (start, end) ->
val startLine = document.getLineNumber(start)
var endLine = document.getLineNumber(end)
if (endLine > startLine && document.getLineStartOffset(endLine) == end) {
private fun expandToWholeLines(document: Document, startOffset: Int, endOffset: Int): TextRange {
val startLine = document.getLineNumber(startOffset)
var endLine = document.getLineNumber(endOffset)
// A linewise range ends at the start of the line *after* the last one - don't pull that line in
if (endLine > startLine && document.getLineStartOffset(endLine) == endOffset) {
endLine--
}
startLine to endLine
return TextRange(document.getLineStartOffset(startLine), document.getLineEndOffset(endLine))
}
private companion object {
private const val COMMAND_NAME = "AutoIndent"
}
}
@@ -49,5 +49,9 @@
<!-- RPC: expose document changes with undo grouping for split mode -->
<platform.rpc.backend.remoteApiProvider
implementation="com.maddyhome.idea.vim.group.change.ChangeRemoteApiProvider"/>
<!-- RPC: receive the frontend's action IDs for the ideavimrc completion -->
<platform.rpc.backend.remoteApiProvider
implementation="com.maddyhome.idea.vim.group.action.ActionIdRemoteApiProvider"/>
</extensions>
</idea-plugin>
+11 -1
View File
@@ -20,6 +20,16 @@ val ideaType: String by project
val ideaVersion: String by project
val javaVersion: String by project
val ideaBranchNumber = ideaVersion.substringBefore('-').split('.').let { parts ->
val head = parts[0].toIntOrNull() ?: Int.MAX_VALUE // LATEST-EAP-SNAPSHOT
val isProductVersion = head > 1000 // 2026.2 rather than 262.8665.258
if (isProductVersion) (head % 100) * 10 + (parts.getOrNull(1)?.toIntOrNull() ?: 1) else head
}
// intellij.platform.rpc has a module descriptor since 2026.1; in 2025.3 its classes are in the core and
// product-info.json doesn't list it, which fails resolution.
val rpcModuleExists = ideaBranchNumber >= 261
repositories {
maven { url = uri("https://cache-redirector.jetbrains.com/repo.maven.apache.org/maven2") }
maven("https://cache-redirector.jetbrains.com/packages.jetbrains.team/maven/p/ij/intellij-dependencies")
@@ -52,6 +62,7 @@ dependencies {
bundledModule("intellij.spellchecker")
bundledModule("intellij.platform.kernel.impl")
if (rpcModuleExists) bundledModule("intellij.platform.rpc")
}
}
@@ -80,7 +91,6 @@ ksp {
arg("vimscript_functions_file", "intellij_vimscript_functions.json")
arg("ex_commands_file", "intellij_ex_commands.json")
arg("commands_file", "intellij_commands.json")
arg("extensions_file", "ideavim_extensions.json")
}
afterEvaluate {
@@ -0,0 +1,82 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package com.maddyhome.idea.vim.completion
import com.intellij.codeInsight.AutoPopupController
import com.intellij.codeInsight.completion.CompletionContributor
import com.intellij.codeInsight.completion.CompletionParameters
import com.intellij.codeInsight.completion.CompletionResultSet
import com.intellij.codeInsight.completion.PlainPrefixMatcher
import com.intellij.codeInsight.editorActions.TypedHandlerDelegate
import com.intellij.codeInsight.lookup.LookupElement
import com.intellij.codeInsight.lookup.LookupElementBuilder
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.project.Project
import com.intellij.psi.PsiFile
import com.maddyhome.idea.vim.api.ActionIdCompletion
import com.maddyhome.idea.vim.group.action.ActionIdInfo
import com.maddyhome.idea.vim.group.action.FrontendActionIds
import com.maddyhome.idea.vim.vimscript.services.VimRcService
/**
* Completes IDE action IDs in the ideavimrc file, in `<Action>(...)` and in the `:action` argument.
*
* IdeaVim doesn't register a file type for the ideavimrc: the platform opens it with the bundled TextMate VimL
* grammar, or the user's own file type association. So the contributor is registered for any language and recognises
* the file by its name. It is registered in the common module because in split mode the completion runs on the
* backend.
*/
internal class IdeaVimRcCompletionContributor : CompletionContributor() {
override fun fillCompletionVariants(parameters: CompletionParameters, result: CompletionResultSet) {
if (!isIdeaVimRc(parameters.originalFile)) return
val text = parameters.originalFile.viewProvider.contents
val prefix = ActionIdCompletion.findPrefix(lineBeforeOffset(text, parameters.offset))?.prefix ?: return
// PlainPrefixMatcher is one of the matchers that can be sent to the frontend lookup in split mode
val actionIdResult = result.withPrefixMatcher(PlainPrefixMatcher(prefix, true))
val actions = FrontendActionIds.getInstance()
ActionIdCompletion.findMatches(actions.allActionIds(), prefix).forEach {
actionIdResult.addElement(createLookupElement(it, actions.findInfo(it)))
}
// Don't mix action IDs with the words from the file offered by the TextMate completion
result.stopHere()
}
}
/** `ReformatCode Reformat Code` with the description, if any, aligned to the right */
private fun createLookupElement(actionId: String, info: ActionIdInfo?): LookupElement {
return LookupElementBuilder.create(actionId)
.withTailText(info?.text?.let { " $it" }, true)
.withTypeText(info?.description)
}
/**
* Opens the completion lookup when an action ID is about to be typed: after `<Action>(` or `:action `.
*/
internal class IdeaVimRcTypedHandler : TypedHandlerDelegate() {
override fun checkAutoPopup(charTyped: Char, project: Project, editor: Editor, file: PsiFile): Result {
if (charTyped != '(' && charTyped != ' ') return Result.CONTINUE
if (!isIdeaVimRc(file)) return Result.CONTINUE
// Called before the typed character is inserted
val line = lineBeforeOffset(editor.document.charsSequence, editor.caretModel.offset) + charTyped
if (ActionIdCompletion.findPrefix(line)?.prefix?.isEmpty() != true) return Result.CONTINUE
AutoPopupController.getInstance(project).scheduleAutoPopup(editor)
return Result.STOP
}
}
private fun isIdeaVimRc(file: PsiFile): Boolean = file.name.endsWith(VimRcService.VIMRC_FILE_NAME)
private fun lineBeforeOffset(text: CharSequence, offset: Int): String {
val lineStart = text.lastIndexOf('\n', offset - 1) + 1
return text.substring(lineStart, offset)
}
@@ -0,0 +1,38 @@
/*
* 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.action
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.actionSystem.AnAction
import kotlinx.serialization.Serializable
/**
* An action ID with the action's text and description, shown in the ideavimrc completion lookup.
* Sent via [ActionIdRemoteApi] from the frontend to the backend.
*/
@Serializable
data class ActionIdInfo(
val id: String,
val text: String?,
val description: String?,
) {
companion object {
/** Reads the text and description of [action], which can be an action stub, so the action is not instantiated */
fun of(id: String, action: AnAction): ActionIdInfo {
val presentation = action.templatePresentation
return ActionIdInfo(id, presentation.text?.ifBlank { null }, presentation.description?.ifBlank { null })
}
/** All actions of the local ActionManager */
fun collect(): List<ActionIdInfo> {
val actionManager = ActionManager.getInstance()
return actionManager.getActionIdList("").mapNotNull { id -> actionManager.getActionOrStub(id)?.let { of(id, it) } }
}
}
}
@@ -0,0 +1,36 @@
/*
* 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.action
import com.maddyhome.idea.vim.group.rpc.VimRemoteApiResolver
import fleet.rpc.RemoteApi
import fleet.rpc.Rpc
import fleet.rpc.remoteApiDescriptor
import org.jetbrains.annotations.ApiStatus
/**
* RPC interface for sharing the frontend's actions with the backend.
*
* In split mode, completion in the ideavimrc editor runs on the backend, whose ActionManager doesn't know the
* frontend-only actions. `:action` and `<Action>(...)` are executed on the frontend, so the frontend sends its action
* IDs, with texts and descriptions for the lookup, and the backend offers them in completion alongside its own.
* See [FrontendActionIds].
*/
@Rpc
@ApiStatus.Internal
interface ActionIdRemoteApi : RemoteApi<Unit> {
suspend fun setFrontendActions(actions: List<ActionIdInfo>)
companion object {
@JvmStatic
suspend fun getInstance(): ActionIdRemoteApi {
return VimRemoteApiResolver.resolve(remoteApiDescriptor<ActionIdRemoteApi>())
}
}
}
@@ -0,0 +1,40 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package com.maddyhome.idea.vim.group.action
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
/**
* Actions that can be used in `:action` and `<Action>(...)`.
*
* Those are executed on the frontend, so in split mode the backend's own ActionManager isn't enough: the frontend
* sends its actions over [ActionIdRemoteApi] and they are kept here. In monolith and on the frontend, nothing is
* received and the local ActionManager already has every action.
*/
@Service(Service.Level.APP)
class FrontendActionIds {
/** Actions received from the frontend, by ID */
@Volatile
var actions: Map<String, ActionIdInfo> = emptyMap()
fun allActionIds(): Set<String> = ActionManager.getInstance().getActionIdList("").toSet() + actions.keys
/** Prefers the local action, the frontend only knows the actions missing here */
fun findInfo(actionId: String): ActionIdInfo? {
val action = ActionManager.getInstance().getActionOrStub(actionId) ?: return actions[actionId]
return ActionIdInfo.of(actionId, action)
}
companion object {
@JvmStatic
fun getInstance(): FrontendActionIds = service()
}
}
@@ -10,7 +10,7 @@ package com.maddyhome.idea.vim.group.bookmark
import com.intellij.ide.vfs.VirtualFileId
import com.intellij.platform.project.ProjectId
import com.intellij.platform.rpc.RemoteApiProviderService
import com.maddyhome.idea.vim.group.rpc.VimRemoteApiResolver
import fleet.rpc.RemoteApi
import fleet.rpc.Rpc
import fleet.rpc.remoteApiDescriptor
@@ -78,7 +78,7 @@ interface BookmarkRemoteApi : RemoteApi<Unit> {
companion object {
@JvmStatic
suspend fun getInstance(): BookmarkRemoteApi {
return RemoteApiProviderService.resolve(remoteApiDescriptor<BookmarkRemoteApi>())
return VimRemoteApiResolver.resolve(remoteApiDescriptor<BookmarkRemoteApi>())
}
}
}
@@ -9,7 +9,7 @@
package com.maddyhome.idea.vim.group.change
import com.intellij.openapi.editor.impl.EditorId
import com.intellij.platform.rpc.RemoteApiProviderService
import com.maddyhome.idea.vim.group.rpc.VimRemoteApiResolver
import fleet.rpc.RemoteApi
import fleet.rpc.Rpc
import fleet.rpc.remoteApiDescriptor
@@ -34,7 +34,7 @@ interface ChangeRemoteApi : RemoteApi<Unit> {
companion object {
@JvmStatic
suspend fun getInstance(): ChangeRemoteApi {
return RemoteApiProviderService.resolve(remoteApiDescriptor<ChangeRemoteApi>())
return VimRemoteApiResolver.resolve(remoteApiDescriptor<ChangeRemoteApi>())
}
}
}
@@ -9,7 +9,7 @@
package com.maddyhome.idea.vim.group.comment
import com.intellij.openapi.editor.impl.EditorId
import com.intellij.platform.rpc.RemoteApiProviderService
import com.maddyhome.idea.vim.group.rpc.VimRemoteApiResolver
import fleet.rpc.RemoteApi
import fleet.rpc.Rpc
import fleet.rpc.remoteApiDescriptor
@@ -55,7 +55,7 @@ interface CommentaryRemoteApi : RemoteApi<Unit> {
companion object {
@JvmStatic
suspend fun getInstance(): CommentaryRemoteApi {
return RemoteApiProviderService.resolve(remoteApiDescriptor<CommentaryRemoteApi>())
return VimRemoteApiResolver.resolve(remoteApiDescriptor<CommentaryRemoteApi>())
}
}
}
@@ -11,7 +11,7 @@ package com.maddyhome.idea.vim.group.file
import com.intellij.ide.vfs.VirtualFileId
import com.intellij.openapi.editor.impl.EditorId
import com.intellij.platform.project.ProjectId
import com.intellij.platform.rpc.RemoteApiProviderService
import com.maddyhome.idea.vim.group.rpc.VimRemoteApiResolver
import fleet.rpc.RemoteApi
import fleet.rpc.Rpc
import fleet.rpc.remoteApiDescriptor
@@ -62,7 +62,7 @@ interface FileRemoteApi : RemoteApi<Unit> {
companion object {
@JvmStatic
suspend fun getInstance(): FileRemoteApi {
return RemoteApiProviderService.resolve(remoteApiDescriptor<FileRemoteApi>())
return VimRemoteApiResolver.resolve(remoteApiDescriptor<FileRemoteApi>())
}
}
}
@@ -9,7 +9,7 @@
package com.maddyhome.idea.vim.group.format
import com.intellij.openapi.editor.impl.EditorId
import com.intellij.platform.rpc.RemoteApiProviderService
import com.maddyhome.idea.vim.group.rpc.VimRemoteApiResolver
import fleet.rpc.RemoteApi
import fleet.rpc.Rpc
import fleet.rpc.remoteApiDescriptor
@@ -18,12 +18,22 @@ import org.jetbrains.annotations.ApiStatus
@Rpc
@ApiStatus.Internal
interface FormatRemoteApi : RemoteApi<Unit> {
suspend fun format(editorId: EditorId, startOffsets: List<Int>, endOffsets: List<Int>)
/**
* Auto-indents the lines covered by [startOffset]..[endOffset]. Several ranges mean several calls,
* which the caller must issue bottom-to-top so that one range doesn't shift the ranges above it.
*/
suspend fun format(editorId: EditorId, startOffset: Int, endOffset: Int)
/**
* The indent the language gives to the line at [offset], as the IDE works it out for a new line. Null if the
* language has no formatter, or if the indent of the line is not to be adjusted.
*/
suspend fun lineIndent(editorId: EditorId, offset: Int): String?
companion object {
@JvmStatic
suspend fun getInstance(): FormatRemoteApi {
return RemoteApiProviderService.resolve(remoteApiDescriptor<FormatRemoteApi>())
return VimRemoteApiResolver.resolve(remoteApiDescriptor<FormatRemoteApi>())
}
}
}
@@ -9,7 +9,7 @@
package com.maddyhome.idea.vim.group.jump
import com.intellij.platform.project.ProjectId
import com.intellij.platform.rpc.RemoteApiProviderService
import com.maddyhome.idea.vim.group.rpc.VimRemoteApiResolver
import fleet.rpc.RemoteApi
import fleet.rpc.Rpc
import fleet.rpc.remoteApiDescriptor
@@ -29,7 +29,7 @@ interface JumpRemoteApi : RemoteApi<Unit> {
companion object {
@JvmStatic
suspend fun getInstance(): JumpRemoteApi {
return RemoteApiProviderService.resolve(remoteApiDescriptor<JumpRemoteApi>())
return VimRemoteApiResolver.resolve(remoteApiDescriptor<JumpRemoteApi>())
}
}
}
@@ -8,7 +8,7 @@
package com.maddyhome.idea.vim.group.process
import com.intellij.platform.rpc.RemoteApiProviderService
import com.maddyhome.idea.vim.group.rpc.VimRemoteApiResolver
import fleet.rpc.RemoteApi
import fleet.rpc.Rpc
import fleet.rpc.remoteApiDescriptor
@@ -50,7 +50,7 @@ interface ProcessRemoteApi : RemoteApi<Unit> {
companion object {
@JvmStatic
suspend fun getInstance(): ProcessRemoteApi {
return RemoteApiProviderService.resolve(remoteApiDescriptor<ProcessRemoteApi>())
return VimRemoteApiResolver.resolve(remoteApiDescriptor<ProcessRemoteApi>())
}
}
}
@@ -0,0 +1,49 @@
/*
* 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.rpc
import com.intellij.openapi.extensions.ExtensionPointName
import com.intellij.platform.rpc.RemoteApiProviderService
import fleet.rpc.RemoteApi
import fleet.rpc.RemoteApiDescriptor
import org.jetbrains.annotations.ApiStatus
private val resolverEP = ExtensionPointName.create<VimRemoteApiResolver>("IdeaVIM.remoteApiResolver")
/**
* Resolves Fleet RPC remote APIs via [RemoteApiProviderService] without making this module depend on the
* `intellij.platform.rpc` module.
*
* That module doesn't exist in 2025.3 (the classes live in the core classloader), is embedded in the core
* classloader in 2026.1 and 2026.2, and is a standalone module since 2026.3. A required dependency on it from this
* embedded module would disable the whole plugin on 2025.3, so the dependency lives in the optional
* `IdeaVIM.ideavim-rpc` module, which registers the resolver whenever the module exists. Without it, we fall back
* to [CoreRemoteApiResolver], which works as long as [RemoteApiProviderService] is in the core classloader.
*/
@ApiStatus.Internal
interface VimRemoteApiResolver {
suspend fun <T : RemoteApi<Unit>> resolve(descriptor: RemoteApiDescriptor<T>): T
companion object {
suspend fun <T : RemoteApi<Unit>> resolve(descriptor: RemoteApiDescriptor<T>): T {
val resolver = resolverEP.extensionList.firstOrNull() ?: CoreRemoteApiResolver
return resolver.resolve(descriptor)
}
}
}
/**
* Same call as in `IdeaVIM.ideavim-rpc`, but [RemoteApiProviderService] is resolved through the plugin's own
* classloader, so it only works on IDEs where the class is in the core classloader (2026.2 and earlier).
*/
private object CoreRemoteApiResolver : VimRemoteApiResolver {
override suspend fun <T : RemoteApi<Unit>> resolve(descriptor: RemoteApiDescriptor<T>): T {
return RemoteApiProviderService.resolve(descriptor)
}
}
@@ -9,6 +9,13 @@
<idea-plugin>
<!-- Shared code loaded in ALL product modes (monolith, client, backend). -->
<!-- No dependency on intellij.platform.rpc here: it doesn't exist in 2025.3, and an unresolved dependency of this
embedded module disables the whole plugin. IdeaVIM.ideavim-rpc (optional) carries it instead. -->
<extensionPoints>
<extensionPoint interface="com.maddyhome.idea.vim.group.rpc.VimRemoteApiResolver" dynamic="true"
name="remoteApiResolver"/>
</extensionPoints>
<extensions defaultExtensionNs="com.intellij">
<!-- Services that must be available in ALL product modes (used by both frontend and backend).
Frontend-only services are registered in ideavim-frontend.xml.
@@ -20,6 +27,12 @@
<applicationService serviceImplementation="com.maddyhome.idea.vim.group.IjVimSystemInfoService"
serviceInterface="com.maddyhome.idea.vim.api.SystemInfoService"/>
<!-- Action ID completion in the ideavimrc file. Registered here, because in split mode the completion
runs on the backend, while in monolith everything runs in one process. -->
<completion.contributor language="any" order="first"
implementationClass="com.maddyhome.idea.vim.completion.IdeaVimRcCompletionContributor"/>
<typedHandler implementation="com.maddyhome.idea.vim.completion.IdeaVimRcTypedHandler"/>
</extensions>
@@ -280,6 +280,10 @@
<editorFactoryDocumentListener
implementation="com.maddyhome.idea.vim.listener.VimListenerManager$VimDocumentListener"/>
<!-- Pasting with the IDE paste action replaces text in Replace mode -->
<copyPastePostProcessor implementation="com.maddyhome.idea.vim.group.copy.ReplaceModePastePostProcessor"
order="last"/>
<!-- Registry keys -->
<registryKey defaultValue="true" description="Enable IdeaVim only in editor component and project tree"
key="ideavim.only.in.editor.component"/>
@@ -377,6 +381,15 @@
</aliases>
</vimExtension>
<vimExtension implementation="com.maddyhome.idea.vim.extension.visualmulti.VimVisualMultiExtension"
name="visual-multi">
<aliases>
<alias name="https://github.com/mg979/vim-visual-multi"/>
<alias name="mg979/vim-visual-multi"/>
<alias name="vim-visual-multi"/>
</aliases>
</vimExtension>
<vimExtension implementation="com.maddyhome.idea.vim.extension.textobjentire.VimTextObjEntireExtension"
name="textobj-entire">
<aliases>
@@ -492,6 +505,8 @@
</aliases>
</vimExtension>
<vimExtension implementation="com.maddyhome.idea.vim.extension.keylog.KeyLogExtension" name="keylog"/>
<vimExtension implementation="com.maddyhome.idea.vim.extension.yankring.YankRingExtension"
name="yankring">
<aliases>
@@ -524,6 +539,15 @@
</aliases>
</vimExtension>
<vimExtension implementation="com.maddyhome.idea.vim.extension.cleverf.CleverFExtension"
name="clever-f">
<aliases>
<alias name="https://github.com/rhysd/clever-f.vim"/>
<alias name="rhysd/clever-f.vim"/>
<alias name="clever-f.vim"/>
</aliases>
</vimExtension>
<vimExtension implementation="com.maddyhome.idea.vim.extension.camelcasemotion.CamelCaseMotion"
name="CamelCaseMotion">
<aliases>
+66
View File
@@ -0,0 +1,66 @@
/*
* 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.
*/
// Optional module carrying the dependency on intellij.platform.rpc, which IdeaVIM.ideavim-common can't declare
// (see VimRemoteApiResolver). The platform skips it on 2025.3, where the module doesn't exist.
plugins {
java
kotlin("jvm")
id("org.jetbrains.intellij.platform.module")
}
val kotlinVersion: String by project
val ideaType: String by project
val ideaVersion: String by project
val javaVersion: String by project
val ideaBranchNumber = ideaVersion.substringBefore('-').split('.').let { parts ->
val head = parts[0].toIntOrNull() ?: Int.MAX_VALUE // LATEST-EAP-SNAPSHOT
val isProductVersion = head > 1000 // 2026.2 rather than 262.8665.258
if (isProductVersion) (head % 100) * 10 + (parts.getOrNull(1)?.toIntOrNull() ?: 1) else head
}
// intellij.platform.rpc has a module descriptor since 2026.1; in 2025.3 its classes are in the core and
// product-info.json doesn't list it, which fails resolution.
val rpcModuleExists = ideaBranchNumber >= 261
repositories {
maven { url = uri("https://cache-redirector.jetbrains.com/repo.maven.apache.org/maven2") }
intellijPlatform {
defaultRepositories()
}
}
dependencies {
compileOnly(project(":ideavim-common"))
compileOnly("org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion")
intellijPlatform {
var useInstaller = "EAP-SNAPSHOT" !in ideaVersion
if (ideaType == "RD") {
useInstaller = false
}
create(ideaType, ideaVersion) { this.useInstaller = useInstaller }
if (rpcModuleExists) bundledModule("intellij.platform.rpc")
}
}
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(javaVersion))
}
}
kotlin {
jvmToolchain {
languageVersion.set(JavaLanguageVersion.of(javaVersion))
}
}
@@ -0,0 +1,20 @@
/*
* 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.rpcImpl
import com.intellij.platform.rpc.RemoteApiProviderService
import com.maddyhome.idea.vim.group.rpc.VimRemoteApiResolver
import fleet.rpc.RemoteApi
import fleet.rpc.RemoteApiDescriptor
class PlatformRemoteApiResolver : VimRemoteApiResolver {
override suspend fun <T : RemoteApi<Unit>> resolve(descriptor: RemoteApiDescriptor<T>): T {
return RemoteApiProviderService.resolve(descriptor)
}
}
@@ -0,0 +1,20 @@
<!--
~ 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.
-->
<!-- Provides RemoteApiProviderService to IdeaVIM.ideavim-common on IDEs where intellij.platform.rpc is a module
(2026.1+). loading="optional", so on 2025.3, where the module doesn't exist, this is a clean skip and
ideavim-common falls back to the core classloader. -->
<idea-plugin package="com.maddyhome.idea.vim.rpcImpl">
<dependencies>
<module name="intellij.platform.rpc"/>
<module name="IdeaVIM.ideavim-common"/>
</dependencies>
<extensions defaultExtensionNs="IdeaVIM">
<remoteApiResolver implementation="com.maddyhome.idea.vim.rpcImpl.PlatformRemoteApiResolver"/>
</extensions>
</idea-plugin>
@@ -0,0 +1,137 @@
import {describe, expect, it} from "vitest";
import {prepareReleaseChangelog, toBeReleasedSection} from "./prepareReleaseChangelog.js";
const preamble = `The Changelog
=============
`;
const older = `## 2.47.0, 2026-09-24
### Features:
* Older feature
`;
const masterContent = `${preamble}
## To Be Released
### Features:
* [VIM-1](https://youtrack.jetbrains.com/issue/VIM-1) Released feature
* [VIM-3](https://youtrack.jetbrains.com/issue/VIM-3) Feature landed after the release branch was cut
* \`:help\` entry without a ticket that the release branch already documents
* Entry without a ticket that only master documents
### Fixes:
* [VIM-2](https://youtrack.jetbrains.com/issue/VIM-2) Fix cherry-picked to the release branch
### Merged PRs:
* [2099](https://github.com/JetBrains/ideavim/pull/2099) by someone: Released PR
* [2100](https://github.com/JetBrains/ideavim/pull/2100) by someone: PR merged after the cut
* [2101](https://github.com/JetBrains/ideavim/pull/2101) by someone: PR merged after the cut without its number in the commit
* [2102](https://github.com/JetBrains/ideavim/pull/2102) by someone: Released PR without its number in the commit
### Changes:
* [VIM-4](https://youtrack.jetbrains.com/issue/VIM-4) Change released before the cut, documented on master after it
${older}`;
const releaseContent = `${preamble}
## To Be Released
### Features:
* [VIM-1](https://youtrack.jetbrains.com/issue/VIM-1) Released feature
* \`:help\` entry without a ticket that the release branch already documents
### Merged PRs:
* [2102](https://github.com/JetBrains/ideavim/pull/2102) by someone: Released PR without its number in the commit
${older}`;
const masterOnlyCommits = [
"Fix(VIM-3): landed after the cut",
"Fix(VIM-2): cherry-picked",
"Something for the next release (#2100)",
"Update changelog",
];
const releaseOnlyCommits = ["Fix(VIM-2): cherry-picked", "Preparation to 2.47.2 release"];
const releaseCommits = [
...releaseOnlyCommits,
"Fix(VIM-1): released",
"Released PR (#2099)",
"VIM-4 released before the cut",
"Old commit",
];
describe("prepareReleaseChangelog", () => {
it("keeps master's entries for the release branch and drops the ones that are only on master", () => {
const result = prepareReleaseChangelog({
masterContent,
releaseContent,
commits: {masterOnly: masterOnlyCommits, releaseOnly: releaseOnlyCommits, release: releaseCommits},
});
expect(result).toBe(`${preamble}
## To Be Released
### Features:
* [VIM-1](https://youtrack.jetbrains.com/issue/VIM-1) Released feature
* \`:help\` entry without a ticket that the release branch already documents
### Fixes:
* [VIM-2](https://youtrack.jetbrains.com/issue/VIM-2) Fix cherry-picked to the release branch
### Merged PRs:
* [2099](https://github.com/JetBrains/ideavim/pull/2099) by someone: Released PR
* [2102](https://github.com/JetBrains/ideavim/pull/2102) by someone: Released PR without its number in the commit
### Changes:
* [VIM-4](https://youtrack.jetbrains.com/issue/VIM-4) Change released before the cut, documented on master after it
${older}`);
});
it("uses master's changelog as is when the branches do not differ", () => {
const result = prepareReleaseChangelog({
masterContent,
releaseContent: `${preamble}\n${older}`,
commits: {masterOnly: [], releaseOnly: [], release: ["Old commit"]},
});
expect(result).toBe(masterContent);
});
it("leaves a changelog without To Be Released alone", () => {
const content = `${preamble}\n${older}`;
expect(prepareReleaseChangelog({
masterContent: content,
releaseContent: content,
commits: {masterOnly: ["Fix(VIM-9): whatever"], releaseOnly: [], release: []},
})).toBe(content);
});
it("drops an entry whose ticket is mentioned without the Fix() prefix", () => {
const result = prepareReleaseChangelog({
masterContent,
releaseContent,
commits: {
masterOnly: ["VIM-1 follow-up after the cut", ...masterOnlyCommits],
releaseOnly: releaseOnlyCommits,
release: releaseCommits,
},
});
expect(result).not.toContain("Released feature");
});
it("gives the To Be Released section alone", () => {
expect(toBeReleasedSection(`${preamble}
## To Be Released
### Fixes:
* A fix
${older}`)).toBe(`## To Be Released
### Fixes:
* A fix
`);
expect(toBeReleasedSection(`${preamble}\n${older}`)).toBe("");
});
});
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env npx tsx
/**
* Writes the CHANGES.md the release branch is released with.
*
* The changelog is maintained on master, and master may be ahead of the release branch (the EAP
* build is the only thing that moves the release branch to master). So the release gets master's
* CHANGES.md with the `## To Be Released` entries that are only on master taken out, as releaseContent.ts
* tells them apart:
* - an entry that links a VIM ticket or a PR the release branch does not contain is dropped, and one
* it contains is kept,
* - an entry the commits say nothing about (no ticket or PR, or a PR merged without its number in
* the commit subject) is kept only if the release branch's own CHANGES.md already has it.
* The dropped entries stay under `## To Be Released` on master (see syncChangelogToMaster.ts) and
* go out with the next release.
*
* Usage:
* npx tsx scripts-ts/src/prepareReleaseChangelog.ts <root-dir> <master-ref> [release-ref]
* On the release branch: rewrites its CHANGES.md from <master-ref>'s.
* npx tsx scripts-ts/src/prepareReleaseChangelog.ts --print <root-dir> <release-ref>
* On master: prints the `## To Be Released` section the release would get from the working tree's
* CHANGES.md. The Prepare What's New workflow describes exactly this on the page.
*
* Examples:
* npx tsx scripts-ts/src/prepareReleaseChangelog.ts .. origin/master
* npx tsx scripts-ts/src/prepareReleaseChangelog.ts --print . origin/release
*/
import {execFileSync} from "node:child_process";
import {readFileSync, writeFileSync} from "node:fs";
import {join} from "node:path";
import {TO_BE_RELEASED_HEADER} from "./promoteChangelog.js";
import {type BranchCommits, readBranchCommits, ReleaseContent} from "./releaseContent.js";
import {entryKey, entryKeys, findSection, isEntry, unreleasedEntries} from "./syncChangelogToMaster.js";
export interface PrepareArgs {
masterContent: string;
releaseContent: string;
commits: BranchCommits;
}
export function prepareReleaseChangelog(args: PrepareArgs): string {
const masterLines = args.masterContent.split("\n");
const toBeReleased = findSection(masterLines, (line) => line === TO_BE_RELEASED_HEADER);
if (!toBeReleased) return args.masterContent;
const content = new ReleaseContent(args.commits);
// Nothing landed on master after the cut, so everything in the changelog is released
if (content.isUpToDate()) return args.masterContent;
const releaseSection = findSection(args.releaseContent.split("\n"), (line) => line === TO_BE_RELEASED_HEADER);
const knownToRelease = releaseSection ? entryKeys(releaseSection.lines) : new Set<string>();
const dropped = new Set<string>();
for (const line of toBeReleased.lines.filter(isEntry)) {
const key = entryKey(line);
const verdict = content.verdict(key);
if (verdict === "not-released" || (verdict === "unknown" && !knownToRelease.has(key))) dropped.add(key);
}
const remaining = unreleasedEntries(toBeReleased.lines, dropped);
const section = remaining.some(isEntry) ? [TO_BE_RELEASED_HEADER, "", ...remaining] : [];
return [
...masterLines.slice(0, toBeReleased.start),
...section,
...masterLines.slice(toBeReleased.end),
].join("\n");
}
/** The `## To Be Released` section of [content], or an empty string if it has none. */
export function toBeReleasedSection(content: string): string {
const section = findSection(content.split("\n"), (line) => line === TO_BE_RELEASED_HEADER);
return section ? section.lines.join("\n").trimEnd() + "\n" : "";
}
function showFile(rootDir: string, ref: string, path: string): string {
return execFileSync("git", ["show", `${ref}:${path}`], {cwd: rootDir, encoding: "utf-8"});
}
const isMainModule = import.meta.url === `file://${process.argv[1]}`;
if (isMainModule) {
const args = process.argv.slice(2);
if (args[0] === "--print") {
const [, rootDir, releaseRef] = args;
if (!rootDir || !releaseRef) {
console.error("Usage: prepareReleaseChangelog.ts --print <root-dir> <release-ref>");
process.exit(1);
}
const prepared = prepareReleaseChangelog({
masterContent: readFileSync(join(rootDir, "CHANGES.md"), "utf-8"),
releaseContent: showFile(rootDir, releaseRef, "CHANGES.md"),
commits: readBranchCommits(rootDir, "HEAD", releaseRef),
});
const section = toBeReleasedSection(prepared);
process.stdout.write(section || `Nothing under ${TO_BE_RELEASED_HEADER} is on ${releaseRef}\n`);
} else {
const [rootDir, masterRef, releaseRef = "HEAD"] = args;
if (!rootDir || !masterRef) {
console.error("Usage: prepareReleaseChangelog.ts <root-dir> <master-ref> [release-ref]");
process.exit(1);
}
const changelogPath = join(rootDir, "CHANGES.md");
const commits = readBranchCommits(rootDir, masterRef, releaseRef);
console.log(`${commits.masterOnly.length} commits only on ${masterRef}, ${commits.releaseOnly.length} only on ${releaseRef}`);
const updated = prepareReleaseChangelog({
masterContent: showFile(rootDir, masterRef, "CHANGES.md"),
releaseContent: readFileSync(changelogPath, "utf-8"),
commits,
});
writeFileSync(changelogPath, updated);
console.log(`Wrote ${changelogPath} from ${masterRef} without the entries that are only on ${masterRef}`);
}
}
+19
View File
@@ -0,0 +1,19 @@
import {describe, expect, it} from "vitest";
import {selectReleasedTickets} from "./releaseActions.js";
import {ReleaseContent} from "./releaseContent.js";
describe("releaseActions", () => {
const content = new ReleaseContent({
masterOnly: ["VIM-2 landed after the cut", "VIM-3 cherry-picked", "Some refactoring"],
releaseOnly: ["VIM-3 cherry-picked", "Preparation to 2.48.0 release"],
release: ["VIM-3 cherry-picked", "VIM-1 released"],
});
it("marks only the Ready To Release tickets that the release branch contains", () => {
expect(selectReleasedTickets(["VIM-1", "VIM-2", "VIM-3"], content)).toEqual(["VIM-1", "VIM-3"]);
});
it("releases a ticket moved to Ready To Release by hand", () => {
expect(selectReleasedTickets(["VIM-4"], content)).toEqual(["VIM-4"]);
});
});
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env tsx
/**
* Marks the Ready To Release YouTrack tickets that the release contains as fixed in it.
*
* The release is built from the release branch, so a ticket whose change is only on master stays in
* Ready To Release for the next release. releaseContent.ts decides that, the same way it decides which
* changelog entries the release has.
*
* Usage:
* npx tsx scripts-ts/src/releaseActions.ts <version> <release-type> <root-dir> [master-ref] [release-ref]
*
* Example (after the release build pushed the release branch):
* npx tsx scripts-ts/src/releaseActions.ts 2.48.0 minor .. origin/master release
*/
import {readBranchCommits, ReleaseContent, ticketKey} from "./releaseContent.js";
import {
createReleaseVersion,
getTicketsByQuery,
getVersionIdByName,
setFixVersion,
setStatus
} from "./tools/youtrack.js";
export const READY_TO_RELEASE_QUERY = "#{Ready To Release}";
/**
* The tickets that are fixed in the release. A ticket no commit mentions was moved to Ready To Release
* by hand, and nothing says it is missing from the release, so it is released.
*/
export function selectReleasedTickets(readyToRelease: string[], content: ReleaseContent): string[] {
return readyToRelease.filter((ticket) => content.verdict(ticketKey(ticket)) !== "not-released");
}
export async function runReleaseActions(version: string, content: ReleaseContent): Promise<void> {
const readyToRelease = await getTicketsByQuery(READY_TO_RELEASE_QUERY);
console.log(`Tickets in Ready To Release: ${JSON.stringify(readyToRelease)}`);
const tickets = selectReleasedTickets(readyToRelease, content);
const skipped = readyToRelease.filter((ticket) => !tickets.includes(ticket));
if (skipped.length > 0) {
console.log(`Only on master, left in Ready To Release: ${JSON.stringify(skipped)}`);
}
if (tickets.length === 0) {
console.log("No tickets to update");
return;
}
for (const ticket of tickets) await setStatus(ticket, "Fixed");
if (await getVersionIdByName(version) === null) await createReleaseVersion(version);
for (const ticket of tickets) await setFixVersion(ticket, version);
}
const isMainModule = import.meta.url === `file://${process.argv[1]}`;
if (isMainModule) {
const [version, releaseType, rootDir, masterRef = "origin/master", releaseRef = "release"] = process.argv.slice(2);
if (!version || !releaseType || !rootDir) {
console.error("Usage: releaseActions.ts <version> <release-type> <root-dir> [master-ref] [release-ref]");
process.exit(1);
}
if (releaseType === "patch") {
console.log("Skipping release actions for patch release");
} else {
const content = new ReleaseContent(readBranchCommits(rootDir, masterRef, releaseRef));
runReleaseActions(version, content).catch((error) => {
console.error(`Error: ${error instanceof Error ? error.message : error}`);
process.exit(1);
});
}
}
+47
View File
@@ -0,0 +1,47 @@
import {describe, expect, it} from "vitest";
import {commitKeys, ReleaseContent} from "./releaseContent.js";
describe("commitKeys", () => {
it("reads tickets and PR numbers out of commit subjects", () => {
expect(commitKeys([
"Fix(VIM-1): a",
"VIM-2524 calculate indent after cc",
"Squashed PR (#2099)",
"Merge pull request #2100 from someone/branch",
"vim-7 lower case",
"VIM-8 and VIM-9 at once",
])).toEqual(new Set([
"ticket:VIM-1", "ticket:VIM-2524", "pr:2099", "pr:2100", "ticket:VIM-7", "ticket:VIM-8", "ticket:VIM-9",
]));
});
it("does not take a PR number from the middle of a subject", () => {
expect(commitKeys(["Revert the change from #2100"])).toEqual(new Set());
});
});
describe("ReleaseContent", () => {
const content = new ReleaseContent({
masterOnly: ["VIM-3 landed after the cut", "VIM-2 cherry-picked", "VIM-5 fixed again after the cut"],
releaseOnly: ["VIM-2 cherry-picked"],
release: ["VIM-2 cherry-picked", "VIM-1 released", "VIM-5 released before the cut"],
});
it("tells released, not released and unknown tickets apart", () => {
expect(content.verdict("ticket:VIM-1")).toBe("released");
expect(content.verdict("ticket:VIM-2")).toBe("released");
expect(content.verdict("ticket:VIM-3")).toBe("not-released");
expect(content.verdict("ticket:VIM-4")).toBe("unknown");
});
it("holds a ticket back while some of its commits are only on master", () => {
expect(content.verdict("ticket:VIM-5")).toBe("not-released");
});
it("is up to date only when master has no commits of its own", () => {
expect(content.isUpToDate()).toBe(false);
expect(new ReleaseContent({masterOnly: ["Some refactoring"], releaseOnly: [], release: []}).isUpToDate()).toBe(false);
expect(new ReleaseContent({masterOnly: [], releaseOnly: ["Preparation to 2.48.0 release"], release: []})
.isUpToDate()).toBe(true);
});
});
+88
View File
@@ -0,0 +1,88 @@
/**
* Tells which tickets and PRs the release branch contains.
*
* The release is built from the `release` branch, which only follows master when the EAP build resets
* it, so master can hold changes the release does not have. The changelog of the release
* (prepareReleaseChangelog.ts), its What's New page (written from that changelog by the Prepare What's
* New workflow) and the YouTrack tickets marked as fixed in it (releaseActions.ts) all decide what is
* released here, so they never disagree.
*
* A ticket or a PR is identified by the commits that mention it: any `VIM-123` in a commit subject, and
* `(#123)` or `Merge pull request #123` for a PR.
*/
import {execFileSync} from "node:child_process";
export interface BranchCommits {
/** Subjects of the commits on master but not on the release branch, `git log release..master`. */
masterOnly: string[];
/** Subjects of the commits on the release branch but not on master, `git log master..release`. */
releaseOnly: string[];
/** Subjects of all the commits on the release branch, `git log release`. */
release: string[];
}
/**
* - `released`: a commit on the release branch mentions it,
* - `not-released`: it is mentioned only by commits that landed on master after the release branch was
* cut, and none of them was cherry-picked to the release branch,
* - `unknown`: no commit mentions it, e.g. a changelog entry without a ticket or a ticket closed by hand.
*/
export type Verdict = "released" | "not-released" | "unknown";
export class ReleaseContent {
private readonly onlyOnMaster: Set<string>;
private readonly onRelease: Set<string>;
private readonly masterIsAhead: boolean;
constructor(commits: BranchCommits) {
this.masterIsAhead = commits.masterOnly.length > 0;
const cherryPicked = commitKeys(commits.releaseOnly);
this.onlyOnMaster = new Set([...commitKeys(commits.masterOnly)].filter((key) => !cherryPicked.has(key)));
this.onRelease = commitKeys(commits.release);
}
/**
* Whether nothing landed on master after the release branch was cut, so everything master has is
* released. Commits that mention no ticket count too: they can be changes without a ticket.
*/
isUpToDate(): boolean {
return !this.masterIsAhead;
}
/** [key] is `ticket:VIM-123` or `pr:123`, see [commitKeys]. */
verdict(key: string): Verdict {
// Checked first: a ticket fixed again on master was mentioned on the release branch before
if (this.onlyOnMaster.has(key)) return "not-released";
if (this.onRelease.has(key)) return "released";
return "unknown";
}
}
/** The tickets and PRs a list of commit subjects mentions, as `ticket:VIM-123` and `pr:123` keys. */
export function commitKeys(subjects: string[]): Set<string> {
const keys = new Set<string>();
for (const subject of subjects) {
for (const match of subject.matchAll(/\b(VIM-\d+)\b/gi)) keys.add(ticketKey(match[1]));
for (const match of subject.matchAll(/\(#(\d+)\)|^Merge pull request #(\d+)\b/g)) {
keys.add(`pr:${match[1] ?? match[2]}`);
}
}
return keys;
}
export function ticketKey(ticket: string): string {
return `ticket:${ticket.toUpperCase()}`;
}
export function readBranchCommits(rootDir: string, masterRef: string, releaseRef: string): BranchCommits {
const subjects = (...range: string[]) =>
execFileSync("git", ["log", "--format=%s", ...range], {cwd: rootDir, encoding: "utf-8"})
.split("\n")
.filter(Boolean);
return {
masterOnly: subjects(`${releaseRef}..${masterRef}`),
releaseOnly: subjects(`${masterRef}..${releaseRef}`),
release: subjects(releaseRef),
};
}
@@ -0,0 +1,156 @@
import {describe, expect, it} from "vitest";
import {syncChangelogToMaster} from "./syncChangelogToMaster.js";
const preamble = `The Changelog
=============
History of changes in IdeaVim for the IntelliJ platform.
`;
const older = `## 2.47.0, 2026-09-24
### Features:
* Older feature
`;
const releaseContent = `${preamble}
## 2.48.0, 2026-10-09
### Features:
* [VIM-1](https://youtrack.jetbrains.com/issue/VIM-1) Released feature
### Fixes:
* [VIM-2](https://youtrack.jetbrains.com/issue/VIM-2) Released fix
${older}`;
const masterContent = `${preamble}
## To Be Released
### Features:
* [VIM-1](https://youtrack.jetbrains.com/issue/VIM-1) Released feature
* [VIM-3](https://youtrack.jetbrains.com/issue/VIM-3) Feature landed after the release branch was cut
### Fixes:
* [VIM-2](https://youtrack.jetbrains.com/issue/VIM-2) Released fix
### Merged PRs:
* [2099](https://github.com/JetBrains/ideavim/pull/2099) by someone: PR landed after the cut
${older}`;
describe("syncChangelogToMaster", () => {
it("copies the released section and keeps only the unreleased entries under To Be Released", () => {
const result = syncChangelogToMaster({version: "2.48.0", masterContent, releaseContent});
expect(result).toBe(`${preamble}
## To Be Released
### Features:
* [VIM-3](https://youtrack.jetbrains.com/issue/VIM-3) Feature landed after the release branch was cut
### Merged PRs:
* [2099](https://github.com/JetBrains/ideavim/pull/2099) by someone: PR landed after the cut
## 2.48.0, 2026-10-09
### Features:
* [VIM-1](https://youtrack.jetbrains.com/issue/VIM-1) Released feature
### Fixes:
* [VIM-2](https://youtrack.jetbrains.com/issue/VIM-2) Released fix
${older}`);
});
it("drops To Be Released when everything in it was released", () => {
const master = `${preamble}
## To Be Released
### Features:
* [VIM-1](https://youtrack.jetbrains.com/issue/VIM-1) Released feature
### Fixes:
* [VIM-2](https://youtrack.jetbrains.com/issue/VIM-2) Released fix
${older}`;
const result = syncChangelogToMaster({version: "2.48.0", masterContent: master, releaseContent});
expect(result).toBe(releaseContent);
});
it("keeps the continuation lines of an unreleased entry", () => {
const master = `${preamble}
## To Be Released
### Features:
* [VIM-3](https://youtrack.jetbrains.com/issue/VIM-3) Multi-line entry
that continues on the next line
* [VIM-1](https://youtrack.jetbrains.com/issue/VIM-1) Released feature
${older}`;
const result = syncChangelogToMaster({version: "2.48.0", masterContent: master, releaseContent});
expect(result).toContain(`### Features:
* [VIM-3](https://youtrack.jetbrains.com/issue/VIM-3) Multi-line entry
that continues on the next line
## 2.48.0, 2026-10-09`);
expect(result).not.toContain("## To Be Released\n\n### Features:\n* [VIM-1]");
});
it("inserts the released section above the first version when master has no To Be Released", () => {
const master = `${preamble}
${older}`;
const result = syncChangelogToMaster({version: "2.48.0", masterContent: master, releaseContent});
expect(result).toBe(releaseContent);
});
it("leaves master alone for a patch release, which promotes nothing", () => {
const result = syncChangelogToMaster({version: "2.48.1", masterContent, releaseContent});
expect(result).toBe(masterContent);
});
it("treats a differently worded entry for the same ticket or PR as released", () => {
const master = `${preamble}
## To Be Released
### Features:
* [VIM-1](https://youtrack.jetbrains.com/issue/VIM-1) Same feature, worded by the master changelog run
### Merged PRs:
* [2099](https://github.com/JetBrains/ideavim/pull/2099) by someone: Same PR, other wording
* [2100](https://github.com/JetBrains/ideavim/pull/2100) by someone: Not released
${older}`;
const release = `${preamble}
## 2.48.0, 2026-10-09
### Features:
* [VIM-1](https://youtrack.jetbrains.com/issue/VIM-1) Released feature
### Merged PRs:
* [2099](https://github.com/JetBrains/ideavim/pull/2099) by someone: PR
${older}`;
const result = syncChangelogToMaster({version: "2.48.0", masterContent: master, releaseContent: release});
expect(result).toBe(`${preamble}
## To Be Released
### Merged PRs:
* [2100](https://github.com/JetBrains/ideavim/pull/2100) by someone: Not released
## 2.48.0, 2026-10-09
### Features:
* [VIM-1](https://youtrack.jetbrains.com/issue/VIM-1) Released feature
### Merged PRs:
* [2099](https://github.com/JetBrains/ideavim/pull/2099) by someone: PR
${older}`);
});
it("is idempotent", () => {
const once = syncChangelogToMaster({version: "2.48.0", masterContent, releaseContent});
const twice = syncChangelogToMaster({version: "2.48.0", masterContent: once, releaseContent});
expect(twice).toBe(once);
});
});
+172
View File
@@ -0,0 +1,172 @@
#!/usr/bin/env npx tsx
/**
* Brings the released section of CHANGES.md from the release branch over to master.
*
* The release branch only follows master when the EAP build resets it, so by the time a release
* is published master's `## To Be Released` section can hold entries the release does not contain.
* Promoting master's section wholesale would label those as released. Instead, the section that
* promoteChangelog.ts produced on the release branch is copied as is, and only the entries that
* are not in it stay under `## To Be Released` on master.
*
* Usage:
* npx tsx scripts-ts/src/syncChangelogToMaster.ts <version> <master CHANGES.md> <release CHANGES.md>
*
* Example:
* git show release:CHANGES.md > /tmp/release-CHANGES.md
* npx tsx scripts-ts/src/syncChangelogToMaster.ts 2.48.0 CHANGES.md /tmp/release-CHANGES.md
*/
import {readFileSync, writeFileSync} from "node:fs";
import {TO_BE_RELEASED_HEADER} from "./promoteChangelog.js";
export interface SyncArgs {
version: string;
masterContent: string;
releaseContent: string;
}
export interface Section {
/** Index of the header line. */
start: number;
/** Index of the next `## ` header, or the number of lines. */
end: number;
lines: string[];
}
export const isTopHeader = (line: string): boolean => line.startsWith("## ");
export const isSubHeader = (line: string): boolean => line.startsWith("### ");
export const isEntry = (line: string): boolean => /^[*-] /.test(line);
export const isVersionHeader = (version: string) => (line: string): boolean =>
line === `## ${version}` || line.startsWith(`## ${version},`);
export function syncChangelogToMaster(args: SyncArgs): string {
const releaseLines = args.releaseContent.split("\n");
const released = findSection(releaseLines, isVersionHeader(args.version));
// Nothing was promoted on the release branch (a patch release), so there is nothing to bring over.
if (!released) return args.masterContent;
const masterLines = args.masterContent.split("\n");
if (findSection(masterLines, isVersionHeader(args.version))) return args.masterContent;
const releasedSection = withSingleTrailingBlank(released.lines);
const toBeReleased = findSection(masterLines, (line) => line === TO_BE_RELEASED_HEADER);
if (!toBeReleased) {
const firstHeader = masterLines.findIndex(isTopHeader);
const insertAt = firstHeader === -1 ? masterLines.length : firstHeader;
return [
...masterLines.slice(0, insertAt),
...releasedSection,
...masterLines.slice(insertAt),
].join("\n");
}
const remaining = unreleasedEntries(toBeReleased.lines, entryKeys(released.lines));
const unreleasedSection = remaining.some(isEntry) ? [TO_BE_RELEASED_HEADER, "", ...remaining] : [];
return [
...masterLines.slice(0, toBeReleased.start),
...unreleasedSection,
...releasedSection,
...masterLines.slice(toBeReleased.end),
].join("\n");
}
export function findSection(lines: string[], isHeader: (line: string) => boolean): Section | null {
const start = lines.findIndex(isHeader);
if (start === -1) return null;
let end = start + 1;
while (end < lines.length && !isTopHeader(lines[end])) end++;
return {start, end, lines: lines.slice(start, end)};
}
export function entryKeys(sectionLines: string[]): Set<string> {
return new Set(sectionLines.filter(isEntry).map(entryKey));
}
/**
* What identifies an entry across the two branches. The changelog is maintained on both of them, so
* the same change can be worded differently; the ticket or the PR it links is stable, the text is not.
*/
export function entryKey(line: string): string {
const ticket = /\[(VIM-\d+)\]\(/.exec(line);
if (ticket) return `ticket:${ticket[1]}`;
const pr = /\[(\d+)\]\(https:\/\/github\.com\/[^)]*\/pull\/\1\)/.exec(line);
if (pr) return `pr:${pr[1]}`;
return `text:${line.trim()}`;
}
/**
* The body of the `## To Be Released` section without the entries that are in [released],
* and without the subsections that are left empty by that.
*/
export function unreleasedEntries(sectionLines: string[], released: Set<string>): string[] {
const body = sectionLines.slice(1);
const kept: string[] = [];
for (let i = 0; i < body.length;) {
if (!isEntry(body[i])) {
kept.push(body[i]);
i++;
continue;
}
// An entry is its bullet line plus the indented continuation lines after it
let next = i + 1;
while (next < body.length && body[next].trim() !== "" && !isEntry(body[next]) && !body[next].startsWith("#")) {
next++;
}
if (!released.has(entryKey(body[i]))) kept.push(...body.slice(i, next));
i = next;
}
const withoutEmptySubsections: string[] = [];
for (let i = 0; i < kept.length;) {
if (!isSubHeader(kept[i])) {
withoutEmptySubsections.push(kept[i]);
i++;
continue;
}
let next = i + 1;
while (next < kept.length && !isSubHeader(kept[next])) next++;
const subsection = kept.slice(i, next);
if (subsection.some(isEntry)) withoutEmptySubsections.push(...subsection);
i = next;
}
return withSingleTrailingBlank(collapseBlankLines(withoutEmptySubsections));
}
function collapseBlankLines(lines: string[]): string[] {
const result: string[] = [];
for (const line of lines) {
const blank = line.trim() === "";
if (blank && (result.length === 0 || result[result.length - 1].trim() === "")) continue;
result.push(line);
}
return result;
}
function withSingleTrailingBlank(lines: string[]): string[] {
const result = [...lines];
while (result.length > 0 && result[result.length - 1].trim() === "") result.pop();
result.push("");
return result;
}
const isMainModule = import.meta.url === `file://${process.argv[1]}`;
if (isMainModule) {
const [version, masterPath, releasePath] = process.argv.slice(2);
if (!version || !masterPath || !releasePath) {
console.error("Usage: syncChangelogToMaster.ts <version> <master CHANGES.md> <release CHANGES.md>");
process.exit(1);
}
const masterContent = readFileSync(masterPath, "utf-8");
const updated = syncChangelogToMaster({
version,
masterContent,
releaseContent: readFileSync(releasePath, "utf-8"),
});
if (updated === masterContent) {
console.log(`Changelog unchanged (no ## ${version} section on the release branch, or master already has it)`);
} else {
writeFileSync(masterPath, updated);
console.log(`Brought ## ${version} over to ${masterPath}`);
}
}
+4 -2
View File
@@ -295,12 +295,14 @@ export async function getVersionIdByName(name: string): Promise<string | null> {
const data = await response.json();
if (data.length === 0) {
// The query also matches other names that contain this one, such as its EAP versions
const version = data.find((v: { name: string }) => v.name === name);
if (!version) {
console.log(`Version "${name}" not found`);
return null;
}
const versionId = data[0].id;
const versionId = version.id;
console.log(`Found version "${name}" with ID: ${versionId}`);
return versionId;
}
+17 -12
View File
@@ -21,25 +21,24 @@ repositories {
}
dependencies {
compileOnly("org.jetbrains.kotlin:kotlin-stdlib:2.4.10")
compileOnly("org.jetbrains.kotlin:kotlin-stdlib:2.4.20")
testImplementation("org.junit.jupiter:junit-jupiter:6.1.3")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
implementation("io.ktor:ktor-client-core:3.5.2")
implementation("io.ktor:ktor-client-cio:3.5.2")
implementation("io.ktor:ktor-client-content-negotiation:3.5.2")
implementation("io.ktor:ktor-serialization-kotlinx-json:3.5.2")
implementation("io.ktor:ktor-client-auth:3.5.2")
implementation("io.ktor:ktor-client-core:3.6.0")
implementation("io.ktor:ktor-client-cio:3.6.0")
implementation("io.ktor:ktor-client-content-negotiation:3.6.0")
implementation("io.ktor:ktor-serialization-kotlinx-json:3.6.0")
implementation("io.ktor:ktor-client-auth:3.6.0")
implementation("org.eclipse.jgit:org.eclipse.jgit:6.6.0.202305301015-r")
// This is needed for jgit to connect to ssh
implementation("org.eclipse.jgit:org.eclipse.jgit.ssh.apache:7.7.1.202607240634-r")
implementation("org.eclipse.jgit:org.eclipse.jgit.ssh.apache:7.8.0.202609011348-r")
implementation("com.vdurmont:semver4j:3.1.0")
}
val releaseType: String? by project
val youtrackToken: String by project
kotlin {
compilerOptions {
@@ -82,12 +81,18 @@ tasks.register("calculateNewDevVersion", JavaExec::class) {
args = listOf("${rootProject.rootDir}")
}
tasks.register("releaseActions", JavaExec::class) {
tasks.register("generateHelp", JavaExec::class) {
group = "other"
mainClass.set("scripts.ReleaseActionsKt")
description = "Generates the help file that :help opens from the descriptions of the commands"
// The descriptions are collected by HelpProcessor while running KSP over the modules that declare commands
dependsOn(":vim-engine:kspKotlin", ":kspKotlin")
mainClass.set("scripts.help.GenerateHelpKt")
classpath = sourceSets["main"].runtimeClasspath
args = listOf(project.version.toString(), releaseType ?: "")
environment("YOUTRACK_TOKEN", youtrackToken)
args = listOf(
"${rootProject.rootDir}/src/main/resources/help/ideavim.txt",
"${rootProject.rootDir}/vim-engine/build/help/engine_help.json",
"${rootProject.rootDir}/build/help/frontend_help.json",
)
}
tasks.test {
@@ -0,0 +1,243 @@
/*
* 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 scripts.help
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.io.File
/**
* Renders IdeaVim's help file, the one `:help` opens, from the command descriptions collected by `HelpProcessor`.
*
* Arguments: the path of the help file to write, followed by the paths of the JSON files written by `HelpProcessor`
* (one per module).
*/
fun main(args: Array<String>) {
require(args.size >= 2) { "Usage: generateHelp <output file> <help json>..." }
val output = File(args[0])
val modules = args.drop(1).map { parseHelpData(File(it).readText()) }
output.parentFile.mkdirs()
output.writeText(renderHelp(modules))
println("Help file is written to $output")
}
data class ExCommandHelp(val names: List<String>, val description: String)
data class CommandHelp(val keys: List<String>, val modes: String, val lookup: Boolean, val description: String)
data class HelpData(val exCommands: List<ExCommandHelp>, val commands: List<CommandHelp>)
fun parseHelpData(json: String): HelpData {
val root = Json.parseToJsonElement(json).jsonObject
val exCommands = root.getValue("exCommands").jsonArray.map {
val obj = it.jsonObject
ExCommandHelp(obj.strings("names"), obj.string("description"))
}
val commands = root.getValue("commands").jsonArray.map {
val obj = it.jsonObject
CommandHelp(
obj.strings("keys"),
obj.string("modes"),
obj.getValue("lookup").jsonPrimitive.boolean,
obj.string("description"),
)
}
return HelpData(exCommands, commands)
}
private fun JsonObject.string(key: String) = getValue(key).jsonPrimitive.content
private fun JsonObject.strings(key: String) = getValue(key).jsonArray.map { it.jsonPrimitive.content }
/**
* A part of the help file. A command shows up in every section whose modes it is available in, except that Visual
* mode only lists the commands that aren't in Normal mode already, the same way Vim's `index.txt` does.
*/
enum class Section(val title: String, val tag: String, val tagPrefix: String) {
INSERT("Insert mode", "ideavim-insert", "i_"),
NORMAL("Normal and Operator-pending mode", "ideavim-normal", ""),
VISUAL("Visual and Select mode", "ideavim-visual", "v_"),
CMDLINE("Command-line editing", "ideavim-cmdline", "c_"),
EX("Ex commands", "ideavim-ex", ":"),
}
private class Entry(val display: String, val tags: List<String>, val description: String, val sortKey: String)
const val TEXT_WIDTH = 78
private const val DESCRIPTION_COLUMN = 24
fun renderHelp(modules: List<HelpData>): String {
val entries = Section.entries.associateWith { mutableListOf<Entry>() }
for (command in modules.flatMap { it.commands }) {
for (section in sectionsOf(command.modes)) {
val tags = command.keys.map { section.tagPrefix + keyToTag(it) }
entries.getValue(section).add(Entry(command.keys.joinToString(" "), tags, command.description, command.sortKey))
}
}
for (command in modules.flatMap { it.exCommands }) {
val tags = command.names.flatMap { exCommandTags(it) }
val display = command.names.joinToString(" ") { ":$it" }
entries.getValue(Section.EX).add(Entry(display, tags, command.description, display.lowercase()))
}
return buildString {
appendHeader()
// A tag must lead to one place, so when two commands claim it, the first one keeps it. That happens for the
// `lookup` handlers, which share their keys with the command they stand in for while a completion popup is open.
val usedTags = mutableSetOf<String>()
Section.entries.forEachIndexed { index, section ->
append("=".repeat(TEXT_WIDTH)).append('\n')
appendLine(alignRight("${index + 1}. ${section.title}", "*${section.tag}*"))
append('\n')
// Different handlers of the same keys, e.g. for different modes, can share the description
val sectionEntries = entries.getValue(section).distinctBy { it.display to it.description }
for (entry in sectionEntries.sortedWith(compareBy({ it.sortKey }, { it.display }))) {
appendEntry(entry, entry.tags.distinct().filter { usedTags.add(it) })
}
}
append("=".repeat(TEXT_WIDTH)).append('\n')
append('\n')
append("This file is generated from the IdeaVim sources by \"./gradlew generateHelp\".\n")
append("Do not edit it by hand, edit the description of the command instead.\n")
append('\n')
append(" vim:tw=78:ts=8:noet:ft=help:norl:\n")
}
}
private val CommandHelp.sortKey: String
// Keep a lookup handler right after the command it shares its keys with
get() = keys.first().lowercase() + if (lookup) "\u0001" else ""
private fun sectionsOf(modes: String): List<Section> = buildList {
if ('I' in modes) add(Section.INSERT)
if ('N' in modes || 'O' in modes) add(Section.NORMAL)
else if ('X' in modes || 'S' in modes) add(Section.VISUAL)
if ('C' in modes) add(Section.CMDLINE)
}
private fun StringBuilder.appendHeader() {
appendLine(alignRight("*ideavim.txt*", "*ideavim*"))
append('\n')
append(" ".repeat((TEXT_WIDTH - 16) / 2)).append("IDEAVIM COMMANDS\n")
append('\n')
append(wrap("This file lists the commands that IdeaVim implements. Type \":help\" followed by a subject to jump to it, for example:", 0))
append('\n')
append(" :help x Normal mode command \"x\"\n")
append(" :help v_d Visual mode command \"d\"\n")
append(" :help i_CTRL-W Insert mode command CTRL-W\n")
append(" :help c_CTRL-R Command-line editing command CTRL-R\n")
append(" :help :s Ex command \":s\"\n")
append('\n')
append("Contents:\n")
Section.entries.forEachIndexed { index, section ->
append(alignRight("${index + 1}. ${section.title}", "|${section.tag}|"))
append('\n')
}
append('\n')
}
private fun StringBuilder.appendEntry(entry: Entry, tags: List<String>) {
for (tagLine in tagLines(tags)) {
append(" ".repeat(TEXT_WIDTH - tagLine.length)).append(tagLine).append('\n')
}
val description = wrap(entry.description, DESCRIPTION_COLUMN).trimStart()
if (entry.display.length < DESCRIPTION_COLUMN - 1) {
append(entry.display.padEnd(DESCRIPTION_COLUMN)).append(description)
} else {
append(wrap(entry.display, 0))
append(" ".repeat(DESCRIPTION_COLUMN)).append(description)
}
append('\n')
}
/** Splits the tags into right-aligned lines that fit into the text width */
private fun tagLines(tags: List<String>): List<String> {
val lines = mutableListOf<String>()
var line = ""
for (tag in tags.map { "*$it*" }) {
line = when {
line.isEmpty() -> tag
line.length + 1 + tag.length <= TEXT_WIDTH -> "$line $tag"
else -> {
lines.add(line)
tag
}
}
}
if (line.isNotEmpty()) lines.add(line)
return lines
}
private fun alignRight(left: String, right: String): String =
left + " ".repeat((TEXT_WIDTH - left.length - right.length).coerceAtLeast(1)) + right
/** Wraps [text] to the text width, indenting every line by [indent] spaces. The result ends with a line break. */
private fun wrap(text: String, indent: Int): String = buildString {
var line = StringBuilder()
for (word in text.split(Regex("\\s+")).filter { it.isNotEmpty() }) {
if (line.isNotEmpty() && indent + line.length + 1 + word.length > TEXT_WIDTH) {
append(" ".repeat(indent)).append(line).append('\n')
line = StringBuilder()
}
if (line.isNotEmpty()) line.append(' ')
line.append(word)
}
if (line.isNotEmpty()) append(" ".repeat(indent)).append(line).append('\n')
}
/**
* The tags of an Ex command declared as `h[elp]` are `:h` and `:help`, the shortest abbreviation and the full name.
*/
fun exCommandTags(name: String): List<String> {
val abbreviation = name.substringBefore('[')
val fullName = name.replace("[", "").replace("]", "")
return listOf(abbreviation, fullName).distinct().map { ":" + escapeTagChars(it) }
}
/**
* Converts keys in IdeaVim's notation to a tag in Vim's: `<C-W>h` becomes `CTRL-W_h` and `g*` becomes `gstar`.
*
* Mirrors `HelpCommand.normalizeTopic`, which converts what the user types after `:help` the same way.
*/
fun keyToTag(keys: String): String {
val parts = mutableListOf<String>()
var i = 0
while (i < keys.length) {
val close = if (keys[i] == '<') keys.indexOf('>', i + 1) else -1
if (close > i + 1) {
parts.add(keys.substring(i, close + 1))
i = close + 1
} else {
parts.add(keys[i].toString())
i++
}
}
return buildString {
var previousIsCtrl = false
for (part in parts) {
val ctrl = ctrlKey(part)
if (isNotEmpty() && (ctrl != null || previousIsCtrl)) append('_')
append(ctrl ?: escapeTagChars(part))
previousIsCtrl = ctrl != null
}
}
}
/** `<C-W>` is `CTRL-W` in Vim's help, `<C-Left>` stays as it is */
private fun ctrlKey(part: String): String? {
if (part.length != 5 || !part.startsWith("<C-", ignoreCase = true) || !part.endsWith('>')) return null
return "CTRL-" + part[3].uppercaseChar()
}
/** The characters that can't be a part of a tag in Vim's help, `*` and `|` delimit tags and links */
private fun escapeTagChars(text: String): String = text.replace("*", "star").replace("|", "bar")
@@ -1,45 +0,0 @@
/*
* Copyright 2003-2025 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package scripts
import kotlinx.coroutines.runBlocking
fun main(args: Array<String>) {
val version = args.getOrNull(0) ?: error("Version not provided")
val releaseType = args.getOrNull(1)
performReleaseActions(version, releaseType)
}
fun performReleaseActions(version: String, releaseType: String?) = runBlocking {
if (releaseType == "patch") {
println("Skipping release actions for patch release")
return@runBlocking
}
val tickets = getYoutrackTicketsByQuery("#{Ready To Release}")
if (tickets.isNotEmpty()) {
println("Updating statuses for tickets: $tickets")
setYoutrackStatus(tickets, "Fixed")
println("Checking if version $version exists...")
val versionId = getVersionIdByName(version)
if (versionId == null) {
addReleaseToYoutrack(version)
} else {
println("Version $version already exists in YouTrack. Version id: $versionId")
}
setYoutrackFixVersion(tickets, version)
} else {
println("No tickets to update statuses")
}
}
@@ -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 scripts.help
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class GenerateHelpTest {
@Test
fun `plain keys are the tag`() {
assertEquals("dd", keyToTag("dd"))
assertEquals("<Left>", keyToTag("<Left>"))
assertEquals("<C-Left>", keyToTag("<C-Left>"))
}
@Test
fun `control keys use Vim's notation`() {
assertEquals("CTRL-W", keyToTag("<C-W>"))
assertEquals("CTRL-W", keyToTag("<c-w>"))
assertEquals("CTRL-W_h", keyToTag("<C-W>h"))
assertEquals("CTRL-W_CTRL-H", keyToTag("<C-W><C-H>"))
assertEquals("g_CTRL-G", keyToTag("g<C-G>"))
assertEquals("CTRL-\\_CTRL-N", keyToTag("<C-\\><C-N>"))
}
@Test
fun `characters that delimit tags are spelled out`() {
assertEquals("star", keyToTag("*"))
assertEquals("gstar", keyToTag("g*"))
assertEquals("bar", keyToTag("|"))
}
@Test
fun `ex command tags are the abbreviation and the full name`() {
assertEquals(listOf(":h", ":help"), exCommandTags("h[elp]"))
assertEquals(listOf(":IdeaPlug"), exCommandTags("IdeaPlug"))
assertEquals(listOf(":&"), exCommandTags("&"))
}
@Test
fun `commands are placed into the sections of their modes`() {
val help = renderHelp(
listOf(
HelpData(
exCommands = listOf(ExCommandHelp(listOf("h[elp]"), "Open the help.")),
commands = listOf(
CommandHelp(listOf("w"), "NXO", lookup = false, description = "Move a word forward."),
CommandHelp(listOf("d"), "X", lookup = false, description = "Delete the selection."),
CommandHelp(listOf("<C-W>"), "IC", lookup = false, description = "Delete a word before the cursor."),
),
)
)
)
assertTrue(help.contains("*:h* *:help*\n:h[elp] Open the help.\n"), help)
assertTrue(help.contains("*w*\nw Move a word forward.\n"), help)
assertTrue(help.contains("*v_d*\nd Delete the selection.\n"), help)
assertTrue(help.contains("*i_CTRL-W*\n"), help)
assertTrue(help.contains("*c_CTRL-W*\n"), help)
// A command that is in Normal mode isn't repeated in the Visual mode section
assertTrue(!help.contains("*v_w*"), help)
}
@Test
fun `a tag claimed twice leads to the first command`() {
val help = renderHelp(
listOf(
HelpData(
exCommands = emptyList(),
commands = listOf(
CommandHelp(listOf("<C-Y>"), "I", lookup = true, description = "Accept the completion."),
CommandHelp(listOf("<C-Y>"), "I", lookup = false, description = "Insert the character above."),
),
)
)
)
assertEquals(1, Regex("""\*i_CTRL-Y\*""").findAll(help).count(), help)
assertTrue(help.indexOf("Insert the character above.") < help.indexOf("Accept the completion."), help)
assertTrue(help.contains("*i_CTRL-Y*\n<C-Y> Insert the character above."), help)
}
@Test
fun `long descriptions are wrapped`() {
val description = "word ".repeat(40).trim() + "."
val help = renderHelp(
listOf(HelpData(emptyList(), listOf(CommandHelp(listOf("x"), "N", lookup = false, description = description))))
)
assertTrue(help.lines().all { it.length <= TEXT_WIDTH }, help)
assertTrue(help.contains("\n word word"), help)
}
@Test
fun `parses the output of HelpProcessor`() {
val json = """
{
"exCommands": [ { "names": ["h[elp]"], "description": "Open the help.", "className": "HelpCommand" } ],
"commands": [
{ "keys": ["x"], "modes": "N", "lookup": false, "description": "Delete.", "className": "DeleteAction" }
]
}
""".trimIndent()
assertEquals(
HelpData(
listOf(ExCommandHelp(listOf("h[elp]"), "Open the help.")),
listOf(CommandHelp(listOf("x"), "N", lookup = false, description = "Delete.")),
),
parseHelpData(json),
)
}
}
+7
View File
@@ -7,6 +7,11 @@
*/
pluginManagement {
val kspVersion: String by settings
plugins {
id("com.google.devtools.ksp") version kspVersion
}
repositories {
maven {
url = uri("https://cache-redirector.jetbrains.com/repo.maven.apache.org/maven2")
@@ -48,5 +53,7 @@ include("ideavim-acejump")
include("ideavim-rider")
include("ideavim-clion-nova")
include("ideavim-terminal")
include("ideavim-rpc")
include("tests:ui-rd-tests")
include("tests:split-mode-tests")
include("tests:large-file-tests")
@@ -16,6 +16,8 @@ import com.intellij.openapi.editor.EditorFactory;
import com.intellij.openapi.editor.actionSystem.TypedAction;
import com.intellij.openapi.editor.actionSystem.TypedActionHandler;
import com.intellij.openapi.editor.event.*;
import com.intellij.openapi.editor.ex.EditorEx;
import com.intellij.openapi.editor.ex.FoldingListener;
import com.intellij.openapi.util.Disposer;
import com.maddyhome.idea.vim.helper.HandlerInjector;
import org.jetbrains.annotations.NotNull;
@@ -24,6 +26,7 @@ import org.jetbrains.annotations.Nullable;
import javax.swing.*;
import java.awt.*;
import java.awt.event.MouseListener;
import java.awt.event.MouseWheelListener;
/**
* @author vlan
@@ -89,6 +92,18 @@ public class EventFacade {
editor.getCaretModel().addCaretListener(listener, disposable);
}
public void addVisibleAreaListener(@NotNull Editor editor,
@NotNull VisibleAreaListener listener,
@NotNull Disposable disposable) {
editor.getScrollingModel().addVisibleAreaListener(listener, disposable);
}
public void addFoldingListener(@NotNull Editor editor,
@NotNull FoldingListener listener,
@NotNull Disposable disposable) {
((EditorEx)editor).getFoldingModel().addListener(listener, disposable);
}
public void addEditorMouseListener(@NotNull Editor editor,
@NotNull EditorMouseListener listener,
@NotNull Disposable disposable) {
@@ -102,6 +117,13 @@ public class EventFacade {
Disposer.register(disposable, () -> component.removeMouseListener(mouseListener));
}
public void addComponentMouseWheelListener(@NotNull Component component,
@NotNull MouseWheelListener mouseWheelListener,
@NotNull Disposable disposable) {
component.addMouseWheelListener(mouseWheelListener);
Disposer.register(disposable, () -> component.removeMouseWheelListener(mouseWheelListener));
}
public void addEditorMouseMotionListener(@NotNull Editor editor,
@NotNull EditorMouseMotionListener listener,
@NotNull Disposable disposable) {
@@ -8,17 +8,13 @@
package com.maddyhome.idea.vim
import com.intellij.ide.BrowserUtil
import com.intellij.ide.plugins.IdeaPluginDescriptor
import com.intellij.ide.plugins.InstalledPluginsState
import com.intellij.ide.plugins.PluginStateListener
import com.intellij.ide.plugins.PluginStateManager
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.application.EDT
import com.intellij.openapi.project.Project
import com.intellij.openapi.startup.ProjectActivity
import com.intellij.openapi.updateSettings.impl.UpdateSettings
import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.group.FrontendActionIdsSync
import com.maddyhome.idea.vim.newapi.IjVimEnabler
import com.maddyhome.idea.vim.ui.JoinEap
import com.maddyhome.idea.vim.ui.JoinEap.EAP_LINK
@@ -53,20 +49,6 @@ internal class PluginStartup : ProjectActivity/*, LightEditCompatible*/ {
(injector.enabler as IjVimEnabler).ideOpened()
// Uninstall survey. Should be registered once for all projects
PluginStateManager.addStateListener(object : PluginStateListener {
override fun install(p0: IdeaPluginDescriptor) {/*Nothing*/
}
override fun uninstall(descriptor: IdeaPluginDescriptor) {
val pluginId = VimPlugin.getPluginId()
// This event is called for both uninstall and update. There is no proper way to distinguish these two events.
// In order not to show the form for the update, we check if the new version is available. If so,
// this may be an update (and may not), and we don't show the form.
if (descriptor.pluginId == pluginId && !InstalledPluginsState.getInstance().hasNewerVersion(pluginId)) {
BrowserUtil.open("https://jb.gg/z6c7db")
}
}
})
FrontendActionIdsSync.sendToBackend()
}
}
@@ -19,7 +19,6 @@ import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.helper.inInsertMode
import com.maddyhome.idea.vim.helper.isIdeaVimDisabledHere
import com.maddyhome.idea.vim.key.KeyHandlerKeeper
import com.maddyhome.idea.vim.key.KeySource
import com.maddyhome.idea.vim.listener.ModelessSelection
import com.maddyhome.idea.vim.newapi.vim
import java.awt.event.KeyAdapter
@@ -86,7 +85,7 @@ class VimTypedActionHandler(origHandler: TypedActionHandler) : TypedActionHandle
val modifiers = if (charTyped == ' ' && VimKeyListener.isSpaceShift) KeyEvent.SHIFT_DOWN_MASK else 0
val keyStroke = KeyStroke.getKeyStroke(charTyped, modifiers)
val startTime = if (traceTime) System.currentTimeMillis() else null
handler.handleKey(editor.vim, keyStroke, KeySource.TYPED, context.vim, handler.keyHandlerState)
handler.handleUserKey(editor.vim, keyStroke, context.vim)
if (startTime != null) {
val duration = System.currentTimeMillis() - startTime
LOG.info("VimTypedAction '$charTyped': $duration ms")
@@ -27,15 +27,18 @@ import com.maddyhome.idea.vim.KeyHandler
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.group.IjOptions
import com.maddyhome.idea.vim.helper.ActionEventKeyStrokeExtractor
import com.maddyhome.idea.vim.impl.state.VimStateMachineImpl
import com.maddyhome.idea.vim.key.KeySource
import com.maddyhome.idea.vim.key.MappingInfo
import com.maddyhome.idea.vim.key.MappingOwner
import com.maddyhome.idea.vim.key.ToActionMappingInfo
import com.maddyhome.idea.vim.key.ToKeysMappingInfo
import com.maddyhome.idea.vim.newapi.globalIjOptions
import com.maddyhome.idea.vim.newapi.vim
import com.maddyhome.idea.vim.options.GlobalOptionChangeListener
import com.maddyhome.idea.vim.state.mode.Mode
import org.jetbrains.annotations.VisibleForTesting
import java.awt.Component
import java.awt.KeyboardFocusManager
import java.awt.event.KeyEvent
@@ -64,6 +67,7 @@ class OutsideEditorKeyDispatcher : DumbAwareAction() {
private fun isEnabled(e: AnActionEvent): Boolean {
if (VimPlugin.isNotEnabled()) return false
if (!injector.globalIjOptions().ideaoutsideeditor) return false
if (e.getData(PlatformDataKeys.EDITOR) != null) return false
if (e.getData(PlatformDataKeys.SPEED_SEARCH_TEXT) != null) return false
if (e.getData(PlatformDataKeys.IS_MODAL_CONTEXT) == true) return false
@@ -82,15 +86,14 @@ class OutsideEditorKeyDispatcher : DumbAwareAction() {
val keyHandler = KeyHandler.getInstance()
val fallbackWindow = injector.fallbackWindow
if (keyStroke.isEscape) {
injector.listenersNotifier.notifyKeyTyped(fallbackWindow, keyStroke)
resetToNormal()
return
}
try {
forceNormalModeSilently()
keyHandler.withoutRecording {
keyHandler.handleKey(
fallbackWindow, keyStroke, KeySource.TYPED, e.dataContext.vim, keyHandler.keyHandlerState
)
keyHandler.handleUserKey(fallbackWindow, keyStroke, e.dataContext.vim)
}
} catch (ex: ProcessCanceledException) {
throw ex
@@ -113,6 +116,7 @@ class OutsideEditorKeyDispatcher : DumbAwareAction() {
}
private fun register(component: JComponent) {
if (!injector.globalIjOptions().ideaoutsideeditor) return
// The shortcut set is rebuilt on every registration, so `:map`/`:unmap` executed since the last focus change are
// picked up. Escape is always included so that a pending sequence can be cancelled.
val keys = userMappingKeys().flatten().toMutableSet()
@@ -139,18 +143,26 @@ class OutsideEditorKeyDispatcher : DumbAwareAction() {
injector.keyGroup.getKeyMapping(MappingMode.NORMAL).getAll(emptyList())
.filter { it.mappingInfo.owner.isUserDefined && it.mappingInfo.isActionsOnly }.map { it.getPath() }
private val focusListener = PropertyChangeListener { evt ->
private fun onFocusOwnerChanged(newFocusOwner: Component?) {
unregister()
val newFocusOwner = evt.newValue as? JComponent ?: return@PropertyChangeListener
if (shouldHandle(newFocusOwner)) register(newFocusOwner)
if (newFocusOwner is JComponent && shouldHandle(newFocusOwner)) register(newFocusOwner)
}
fun installFocusListener() {
@VisibleForTesting
internal val focusListener = PropertyChangeListener { evt -> onFocusOwnerChanged(evt.newValue as? Component) }
private val optionListener = GlobalOptionChangeListener {
onFocusOwnerChanged(KeyboardFocusManager.getCurrentKeyboardFocusManager().focusOwner)
}
fun installListeners() {
KeyboardFocusManager.getCurrentKeyboardFocusManager().addPropertyChangeListener("focusOwner", focusListener)
injector.optionGroup.addGlobalOptionChangeListener(IjOptions.ideaoutsideeditor, optionListener)
}
fun removeFocusListener() {
fun removeListeners() {
KeyboardFocusManager.getCurrentKeyboardFocusManager().removePropertyChangeListener("focusOwner", focusListener)
injector.optionGroup.removeGlobalOptionChangeListener(IjOptions.ideaoutsideeditor, optionListener)
unregister()
}
@@ -23,7 +23,11 @@ import com.maddyhome.idea.vim.handler.VimActionHandler
* Typically, an IDE does not need to redraw the screen - it's editor handles scrolling, etc. However, it can be
* necessary to clear the status line.
*/
@CommandOrMotion(keys = ["<C-L>"], modes = [Mode.NORMAL])
@CommandOrMotion(
keys = ["<C-L>"],
modes = [Mode.NORMAL],
description = "Clear and redraw the screen, also clearing the status line."
)
internal class RedrawAction : VimActionHandler.SingleExecution() {
override val type: Command.Type = Command.Type.OTHER_READONLY
@@ -47,7 +47,6 @@ import com.maddyhome.idea.vim.helper.isIdeaVimDisabledHere
import com.maddyhome.idea.vim.helper.isPrimaryEditor
import com.maddyhome.idea.vim.helper.updateCaretsVisualAttributes
import com.maddyhome.idea.vim.impl.state.toMappingMode
import com.maddyhome.idea.vim.key.KeySource
import com.maddyhome.idea.vim.key.ShortcutOwner
import com.maddyhome.idea.vim.key.ShortcutOwnerInfo
import com.maddyhome.idea.vim.listener.AceJumpService
@@ -86,10 +85,13 @@ class VimShortcutKeyAction : AnAction(), DumbAware/*, LightEditCompatible*/ {
if (editor != null && keyStroke != null) {
// In the Python console, Enter/Up/Down should drive the console's own actions (execute / history) in every Vim
// mode, with the console's native context-aware behaviour.
if (handlePythonConsoleKey(editor, keyStroke, e)) return
if (handlePythonConsoleKey(editor, keyStroke, e)) {
injector.listenersNotifier.notifyKeyTyped(editor.vim, keyStroke)
return
}
val owner = VimPlugin.getKey().savedShortcutConflicts[keyStroke]
if ((owner as? ShortcutOwnerInfo.AllModes)?.owner == ShortcutOwner.UNDEFINED) {
if ((owner as? ShortcutOwnerInfo.AllModes)?.owner == ShortcutOwner.UNDEFINED && keyStroke !in VIM_DEFAULT_EDITOR_KEYS) {
VimPlugin.getNotifications(editor.project).notifyAboutShortcutConflict(keyStroke)
}
// Should we use HelperKt.getTopLevelEditor(editor) here, as we did in former EditorKeyHandler?
@@ -98,7 +100,7 @@ class VimShortcutKeyAction : AnAction(), DumbAware/*, LightEditCompatible*/ {
// Vim removes a modeless selection as soon as a command is typed
ModelessSelection.clearIfOwned(editor)
val keyHandler = KeyHandler.getInstance()
keyHandler.handleKey(editor.vim, keyStroke, KeySource.TYPED, e.dataContext.vim, keyHandler.keyHandlerState)
keyHandler.handleUserKey(editor.vim, keyStroke, e.dataContext.vim)
if (start != null) {
val duration = System.currentTimeMillis() - start
LOG.info("VimShortcut execution '$keyStroke': $duration ms")
@@ -222,6 +224,12 @@ class VimShortcutKeyAction : AnAction(), DumbAware/*, LightEditCompatible*/ {
}
if (keyCode == KeyEvent.VK_ESCAPE) {
// Escape is Vim's by default, but the user can explicitly hand it over to the IDE, e.g. `sethandler <Esc> n:ide`
if (keyStroke in VIM_DEFAULT_EDITOR_KEYS &&
VimPlugin.getKey().savedShortcutConflicts[keyStroke]?.forEditor(editor.vim) == ShortcutOwner.IDE
) {
return ActionEnableStatus.no("Owner of Esc is IDE", LogLevel.DEBUG)
}
return if (isEnabledForEscape(editor)) {
ActionEnableStatus.yes("Is enabled for Esc", LogLevel.INFO)
} else {
@@ -284,13 +292,17 @@ class VimShortcutKeyAction : AnAction(), DumbAware/*, LightEditCompatible*/ {
}
private fun isEnabledForEscape(editor: Editor): Boolean {
val ideaVimSupportDialog =
injector.globalIjOptions().ideavimsupport.contains(IjOptionConstants.ideavimsupport_dialog)
return editor.isPrimaryEditor() ||
EditorHelper.isFileEditor(editor) && !editor.vim.mode.inNormalMode ||
// The Python console has full Vim support, so Escape must leave Insert/Visual mode instead of defocusing it.
EditorHelper.isPythonConsole(editor) && !editor.vim.mode.inNormalMode ||
ideaVimSupportDialog && !editor.vim.mode.inNormalMode
if (editor.isPrimaryEditor()) return true
// Outside the main editing area, Escape is Vim's only while there is a mode to leave. In Normal mode we hand it
// back to the platform, which closes the popup or defocuses the tool window hosting the editor.
return !editor.vim.mode.inNormalMode && escapeIsHandledByVim(editor)
}
private fun escapeIsHandledByVim(editor: Editor): Boolean {
return EditorHelper.isFileEditor(editor)
|| EditorHelper.isPythonConsole(editor)
|| EditorHelper.isRunConsole(editor)
|| injector.globalIjOptions().ideavimsupport.contains(IjOptionConstants.ideavimsupport_dialog)
}
private fun isShortcutConflict(keyStroke: KeyStroke): Boolean {
@@ -339,15 +351,10 @@ class VimShortcutKeyAction : AnAction(), DumbAware/*, LightEditCompatible*/ {
@JvmField
val VIM_ONLY_EDITOR_KEYS: Set<KeyStroke> =
ImmutableSet.builder<KeyStroke>().addAll(getKeyStrokes(KeyEvent.VK_ENTER, 0))
.addAll(getKeyStrokes(KeyEvent.VK_ESCAPE, 0))
ImmutableSet.builder<KeyStroke>()
.addAll(getKeyStrokes(KeyEvent.VK_BACK_SPACE, 0, InputEvent.CTRL_DOWN_MASK))
.addAll(getKeyStrokes(KeyEvent.VK_INSERT, 0))
.addAll(getKeyStrokes(KeyEvent.VK_DELETE, 0, InputEvent.CTRL_DOWN_MASK))
.addAll(getKeyStrokes(KeyEvent.VK_UP, 0))
.addAll(getKeyStrokes(KeyEvent.VK_DOWN, 0))
.addAll(getKeyStrokes(KeyEvent.VK_LEFT, 0))
.addAll(getKeyStrokes(KeyEvent.VK_RIGHT, 0))
.addAll(
getKeyStrokes(
KeyEvent.VK_HOME,
@@ -374,6 +381,22 @@ class VimShortcutKeyAction : AnAction(), DumbAware/*, LightEditCompatible*/ {
),
).build()
/**
* Keys that are handled by Vim unless the user explicitly assigns a handler to that particular key, either with
* `sethandler <key> ...` or in the shortcut conflicts settings. They are pressed constantly and every keymap binds
* them to editor actions, so we don't notify about their conflict and handlers set for all shortcuts at once
* (`sethandler a:ide`, "Toggle all unset Handlers") skip them.
*/
@JvmField
val VIM_DEFAULT_EDITOR_KEYS: Set<KeyStroke> =
ImmutableSet.builder<KeyStroke>().addAll(getKeyStrokes(KeyEvent.VK_ENTER, 0))
.addAll(getKeyStrokes(KeyEvent.VK_ESCAPE, 0))
.addAll(getKeyStrokes(KeyEvent.VK_UP, 0))
.addAll(getKeyStrokes(KeyEvent.VK_DOWN, 0))
.addAll(getKeyStrokes(KeyEvent.VK_LEFT, 0))
.addAll(getKeyStrokes(KeyEvent.VK_RIGHT, 0))
.build()
private const val ACTION_ID = "VimShortcutKeyAction"
private val LOG = logger<VimShortcutKeyAction>()
@@ -73,7 +73,11 @@ private fun doOperatorAction(
}
}
@CommandOrMotion(keys = ["g@"], modes = [Mode.NORMAL])
@CommandOrMotion(
keys = ["g@"],
modes = [Mode.NORMAL],
description = "Call the function set with 'operatorfunc' on the text moved over by {motion}."
)
internal class OperatorAction : VimActionHandler.SingleExecution() {
override val type: Command.Type = Command.Type.OTHER_SELF_SYNCHRONIZED
@@ -123,7 +127,11 @@ internal class OperatorAction : VimActionHandler.SingleExecution() {
}
}
@CommandOrMotion(keys = ["g@"], modes = [Mode.VISUAL])
@CommandOrMotion(
keys = ["g@"],
modes = [Mode.VISUAL],
description = "Call the function set with 'operatorfunc' on the highlighted text."
)
internal class VisualOperatorAction : VisualOperatorActionHandler.ForEachCaret() {
override val type: Command.Type = Command.Type.OTHER_SELF_SYNCHRONIZED
@@ -1,5 +1,5 @@
/*
* Copyright 2003-2023 The IdeaVim authors
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
@@ -10,17 +10,31 @@ package com.maddyhome.idea.vim.action.change
import com.intellij.openapi.command.CommandProcessor
import com.intellij.vim.annotations.CommandOrMotion
import com.intellij.vim.annotations.Mode
import com.maddyhome.idea.vim.KeyHandler
import com.maddyhome.idea.vim.VimPlugin
import com.maddyhome.idea.vim.api.ExecutionContext
import com.maddyhome.idea.vim.api.VimEditor
import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.command.Command
import com.maddyhome.idea.vim.command.OperatorArguments
import com.maddyhome.idea.vim.extension.ExtensionHandler
import com.maddyhome.idea.vim.group.withVimUndoGroup
import com.maddyhome.idea.vim.handler.VimActionHandler
import com.maddyhome.idea.vim.key.KeySource
import com.maddyhome.idea.vim.newapi.ij
@CommandOrMotion(keys = ["."], modes = [Mode.NORMAL])
/**
* `.` - repeat the last change.
*
* The last change is remembered as the keys typed for it (see [VimRedoBuffer]), so repeating it means feeding those
* keys back through the key handler, the way `@` replays a macro. Extensions that register a repeat handler are the
* exception - they are Kotlin callbacks rather than keys, and are still invoked directly.
*/
@CommandOrMotion(
keys = ["."],
modes = [Mode.NORMAL],
description = "Repeat the last change, with [count] replacing the original count."
)
internal class RepeatChangeAction : VimActionHandler.SingleExecution() {
override val type: Command.Type = Command.Type.OTHER_WRITABLE
@@ -30,65 +44,86 @@ internal class RepeatChangeAction : VimActionHandler.SingleExecution() {
cmd: Command,
operatorArguments: OperatorArguments,
): Boolean {
val state = injector.vimState
var lastCommand = VimRepeater.lastChangeCommand
val extensionHandler = Extension.lastExtensionHandler.takeIf { VimRepeater.repeatHandler }
if (extensionHandler == null && VimRedoBuffer.isEmpty) return false
if (lastCommand == null && Extension.lastExtensionHandler == null) return false
// Save state
val save = state.executingCommand
val lastFTCmd = injector.motion.lastFTCmd
val lastFTChar = injector.motion.lastFTChar
val reg = injector.registerGroup.currentRegister
val lastHandler = Extension.lastExtensionHandler
val repeatHandler = VimRepeater.repeatHandler
state.isDotRepeatInProgress = true
// Group the entire dot replay into one undo step on the JBC backend so that
// `s`/`c`-style commands (which delete *and* insert) revert with a single
// `u`. Without this the backend records each atomic edit (delete, insert,
// caret) as a separate undo entry because speculative undo disables the
// platform's command grouping.
preservingRepeatState {
// One undo step for the whole repeat. The JBC backend otherwise records each atomic edit of a `s`/`c`-style
// command separately, because speculative undo disables the platform's command grouping
withVimUndoGroup(editor, "Vim Dot Repeat") {
// A fancy 'redo-register' feature
// VIM-2643, :h redo-register
if (VimRepeater.lastChangeRegister in '1'..'8') {
VimRepeater.lastChangeRegister = VimRepeater.lastChangeRegister.inc()
if (extensionHandler != null) {
runExtensionHandler(extensionHandler, editor, context, operatorArguments)
} else {
replayKeys(editor, context, cmd.rawCount)
}
injector.registerGroup.selectRegister(VimRepeater.lastChangeRegister)
if (repeatHandler && lastHandler != null) {
val processor = CommandProcessor.getInstance()
processor.executeCommand(
editor.ij.project,
{ lastHandler.execute(editor, context, operatorArguments) },
"Vim " + lastHandler.javaClass.simpleName,
null,
)
} else if (!repeatHandler && lastCommand != null) {
if (cmd.rawCount > 0) {
lastCommand = lastCommand.copy(rawCount = cmd.rawCount)
}
state.executingCommand = lastCommand
val arguments = operatorArguments.copy(count0 = lastCommand.rawCount)
injector.actionExecutor.executeVimAction(editor, lastCommand.action, context, arguments)
VimRepeater.saveLastChange(lastCommand)
}
}
state.isDotRepeatInProgress = false
// Restore state
if (save != null) state.executingCommand = save
VimPlugin.getMotion().setLastFTCmd(lastFTCmd, lastFTChar)
if (lastHandler != null) Extension.lastExtensionHandler = lastHandler
VimRepeater.repeatHandler = repeatHandler
Extension.reset()
VimPlugin.getRegister().selectRegister(reg)
return true
}
/**
* Feeds the keys of the last change back through the key handler.
*
* They are pulled off the key stack one at a time, as [com.maddyhome.idea.vim.key.MappingInfo] does for the
* right-hand side of a mapping, and fed as typed keys: what was recorded is what the user typed, so a change made
* through a mapping runs the mapping again.
*/
private fun replayKeys(editor: VimEditor, context: ExecutionContext, rawCount: Int) {
val keys = VimRedoBuffer.keysForReplay(rawCount)
VimRedoBuffer.replaying {
val keyHandler = KeyHandler.getInstance()
keyHandler.keyStack.addKeys(keys)
try {
while (keyHandler.keyStack.hasStroke()) {
val key = keyHandler.keyStack.feedStroke()
keyHandler.handleKey(editor, key, KeySource.TYPED, context, keyHandler.keyHandlerState)
}
} finally {
keyHandler.keyStack.removeFirst()
}
}
}
private fun runExtensionHandler(
handler: ExtensionHandler,
editor: VimEditor,
context: ExecutionContext,
operatorArguments: OperatorArguments,
) {
val state = injector.vimState
state.isDotRepeatInProgress = true
try {
CommandProcessor.getInstance().executeCommand(
editor.ij.project,
{ handler.execute(editor, context, operatorArguments) },
"Vim " + handler.javaClass.simpleName,
null,
)
} finally {
state.isDotRepeatInProgress = false
}
}
/**
* Runs [block] and puts back the state the replay would otherwise leave changed: `.` does not become the target of
* `;`, does not change the register the next command uses, and stays repeatable itself.
*/
private inline fun preservingRepeatState(block: () -> Unit) {
val state = injector.vimState
val executingCommand = state.executingCommand
val lastFTCmd = injector.motion.lastFTCmd
val lastFTChar = injector.motion.lastFTChar
val register = injector.registerGroup.currentRegister
val extensionHandler = Extension.lastExtensionHandler
val repeatHandler = VimRepeater.repeatHandler
block()
if (executingCommand != null) state.executingCommand = executingCommand
VimPlugin.getMotion().setLastFTCmd(lastFTCmd, lastFTChar)
if (extensionHandler != null) Extension.lastExtensionHandler = extensionHandler
VimRepeater.repeatHandler = repeatHandler
Extension.reset()
VimPlugin.getRegister().selectRegister(register)
}
}
@@ -19,7 +19,11 @@ import com.maddyhome.idea.vim.command.OperatorArguments
import com.maddyhome.idea.vim.handler.ChangeEditorActionHandler
import com.maddyhome.idea.vim.newapi.ijOptions
@CommandOrMotion(keys = ["gJ"], modes = [Mode.NORMAL])
@CommandOrMotion(
keys = ["gJ"],
modes = [Mode.NORMAL],
description = "Join [count] lines (at least two) without inserting or removing any spaces."
)
class DeleteJoinLinesAction : ChangeEditorActionHandler.ConditionalSingleExecution() {
override val type: Command.Type = Command.Type.DELETE
override fun runAsMulticaret(
@@ -18,7 +18,11 @@ import com.maddyhome.idea.vim.command.OperatorArguments
import com.maddyhome.idea.vim.handler.ChangeEditorActionHandler
import com.maddyhome.idea.vim.newapi.ijOptions
@CommandOrMotion(keys = ["J"], modes = [Mode.NORMAL])
@CommandOrMotion(
keys = ["J"],
modes = [Mode.NORMAL],
description = "Join [count] lines (at least two), replacing each line break and leading white space with a space."
)
class DeleteJoinLinesSpacesAction : ChangeEditorActionHandler.SingleExecution() {
override val type: Command.Type = Command.Type.DELETE
@@ -16,13 +16,18 @@ import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.command.Command
import com.maddyhome.idea.vim.command.OperatorArguments
import com.maddyhome.idea.vim.group.visual.VimSelection
import com.maddyhome.idea.vim.group.visual.toJoinRange
import com.maddyhome.idea.vim.handler.VisualOperatorActionHandler
import com.maddyhome.idea.vim.newapi.ijOptions
/**
* @author vlan
*/
@CommandOrMotion(keys = ["gJ"], modes = [Mode.VISUAL])
@CommandOrMotion(
keys = ["gJ"],
modes = [Mode.VISUAL],
description = "Join the highlighted lines without inserting or removing any spaces."
)
class DeleteJoinVisualLinesAction : VisualOperatorActionHandler.SingleExecution() {
override val type: Command.Type = Command.Type.DELETE
@@ -46,7 +51,7 @@ class DeleteJoinVisualLinesAction : VisualOperatorActionHandler.SingleExecution(
editor,
context,
caret,
range.toVimTextRange(true).normalize(),
range.toJoinRange(),
false,
operatorArguments,
)
@@ -16,13 +16,18 @@ import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.command.Command
import com.maddyhome.idea.vim.command.OperatorArguments
import com.maddyhome.idea.vim.group.visual.VimSelection
import com.maddyhome.idea.vim.group.visual.toJoinRange
import com.maddyhome.idea.vim.handler.VisualOperatorActionHandler
import com.maddyhome.idea.vim.newapi.ijOptions
/**
* @author vlan
*/
@CommandOrMotion(keys = ["J"], modes = [Mode.VISUAL])
@CommandOrMotion(
keys = ["J"],
modes = [Mode.VISUAL],
description = "Join the highlighted lines, replacing each line break and leading white space with a space."
)
class DeleteJoinVisualLinesSpacesAction : VisualOperatorActionHandler.SingleExecution() {
override val type: Command.Type = Command.Type.DELETE
@@ -46,7 +51,7 @@ class DeleteJoinVisualLinesSpacesAction : VisualOperatorActionHandler.SingleExec
editor,
context,
caret,
range.toVimTextRange(true).normalize(),
range.toJoinRange(),
true,
operatorArguments,
)
@@ -26,12 +26,20 @@ import com.maddyhome.idea.vim.undo.VimKeyBasedUndoService
import com.maddyhome.idea.vim.undo.VimTimestampBasedUndoService
import java.util.*
@CommandOrMotion(keys = ["<Del>"], modes = [Mode.INSERT])
@CommandOrMotion(
keys = ["<Del>"],
modes = [Mode.INSERT],
description = "In Insert mode, delete the character under the cursor using the IDE's Delete action."
)
internal class VimEditorDelete : IdeActionHandler(IdeActions.ACTION_EDITOR_DELETE) {
override val type: Command.Type = Command.Type.DELETE
}
@CommandOrMotion(keys = ["<Down>", "<kDown>"], modes = [Mode.INSERT])
@CommandOrMotion(
keys = ["<Down>", "<kDown>"],
modes = [Mode.INSERT],
description = "In Insert mode, move the cursor one line down using the IDE's caret action, starting a new undo step."
)
internal class VimEditorDown : IdeActionHandler(IdeActions.ACTION_EDITOR_MOVE_CARET_DOWN) {
override val type: Command.Type = Command.Type.MOTION
override val flags: EnumSet<CommandFlags> = enumSetOf(CommandFlags.FLAG_CLEAR_STROKES)
@@ -75,13 +83,21 @@ internal class VimEditorDown : IdeActionHandler(IdeActions.ACTION_EDITOR_MOVE_CA
* Also, by inserting `Tab` with our action, we will correctly update the scroll position to keep the caret visible,
* applying `'scrolloff'` and `'sidescrolloff'`.
*/
@CommandOrMotion(keys = ["<Tab>", "<C-I>"], modes = [Mode.INSERT])
@CommandOrMotion(
keys = ["<Tab>", "<C-I>"],
modes = [Mode.INSERT],
description = "In Insert mode, insert a tab or the equivalent spaces using the IDE's Tab action."
)
internal class VimEditorTab : IdeActionHandler(IdeActions.ACTION_EDITOR_TAB) {
override val type: Command.Type = Command.Type.INSERT
override val flags: EnumSet<CommandFlags> = enumSetOf(CommandFlags.FLAG_SAVE_STROKE)
}
@CommandOrMotion(keys = ["<Up>", "<kUp>"], modes = [Mode.INSERT])
@CommandOrMotion(
keys = ["<Up>", "<kUp>"],
modes = [Mode.INSERT],
description = "In Insert mode, move the cursor one line up using the IDE's caret action, starting a new undo step."
)
internal class VimEditorUp : IdeActionHandler(IdeActions.ACTION_EDITOR_MOVE_CARET_UP) {
override val type: Command.Type = Command.Type.MOTION
override val flags: EnumSet<CommandFlags> = enumSetOf(CommandFlags.FLAG_CLEAR_STROKES)
@@ -104,7 +120,11 @@ internal class VimEditorUp : IdeActionHandler(IdeActions.ACTION_EDITOR_MOVE_CARE
}
}
@CommandOrMotion(keys = ["K"], modes = [Mode.NORMAL])
@CommandOrMotion(
keys = ["K"],
modes = [Mode.NORMAL],
description = "Show documentation for the symbol under the cursor using the IDE's Quick Documentation action."
)
internal class VimQuickJavaDoc : VimActionHandler.SingleExecution() {
override val type: Command.Type = Command.Type.OTHER_READONLY
@@ -19,7 +19,7 @@ import com.maddyhome.idea.vim.handler.VimActionHandler
import com.maddyhome.idea.vim.newapi.ij
import com.maddyhome.idea.vim.newapi.vim
@CommandOrMotion(keys = ["z@"], modes = [Mode.NORMAL])
@CommandOrMotion(keys = ["z@"], modes = [Mode.NORMAL], description = "Execute macro in all open files.")
class PlaybackRegisterInOpenFilesAction : VimActionHandler.SingleExecution() {
override val type: Command.Type = Command.Type.OTHER_SELF_SYNCHRONIZED
@@ -51,7 +51,7 @@ class PlaybackRegisterInOpenFilesAction : VimActionHandler.SingleExecution() {
val vimEditor = editor.vim
vimEditor.mode = com.maddyhome.idea.vim.state.mode.Mode.NORMAL()
KeyHandler.Companion.getInstance().reset(vimEditor)
KeyHandler.getInstance().reset(vimEditor)
val startMarkAction = StartMarkAction.start(editor, project, commandName)
playbackRegisterAction.execute(vimEditor, context, cmd, operatorArguments)
@@ -19,6 +19,8 @@ class IndentConfig private constructor(indentOptions: IndentOptions) : VimIndent
private val tabSize = indentOptions.TAB_SIZE
private val isUseTabs = indentOptions.USE_TAB_CHARACTER
override val keepIndentsOnEmptyLines = indentOptions.KEEP_INDENTS_ON_EMPTY_LINES
override fun getIndentSize(depth: Int): Int = indentSize * depth
override fun createIndentByDepth(depth: Int): String = createIndentBySize(getIndentSize(depth))
@@ -49,9 +49,23 @@ open class ShortcutDispatcher<T>(
})
protected val trie = KeyStrokeTrie<T>(name)
private val shortcutSet: ShortcutSet
private var shortcutSet: ShortcutSet = CustomShortcutSet.EMPTY
protected val keyStrokes: MutableList<KeyStroke> = mutableListOf()
init {
setMappings(data)
}
/**
* Replaces all the mappings of this dispatcher.
*
* The new keys start to reach the dispatcher once [register] is called again. The shortcut set belongs to the action,
* so a single [register] call updates the keys for every component this dispatcher is registered on.
*/
protected fun setMappings(data: Map<List<KeyStroke>, T>) {
keyStrokes.clear()
trie.clear()
val keys: MutableList<KeyStroke> = mutableListOf()
for ((k, v) in data) {
keys.addAll(k)
@@ -61,8 +75,6 @@ open class ShortcutDispatcher<T>(
shortcutSet = CustomShortcutSet(*shortcuts.toTypedArray())
}
protected val keyStrokes: MutableList<KeyStroke> = mutableListOf()
final override fun actionPerformed(e: AnActionEvent) {
var keyStroke = getKeyStroke(e) ?: return
// Omit the modifier (shift) from keyStroke
@@ -0,0 +1,53 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package com.maddyhome.idea.vim.extension.cleverf
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.markup.EffectType
import com.intellij.openapi.editor.markup.HighlighterLayer
import com.intellij.openapi.editor.markup.HighlighterTargetArea
import com.intellij.openapi.editor.markup.RangeHighlighter
import com.intellij.openapi.editor.markup.TextAttributes
import com.intellij.ui.JBColor
import java.awt.Font
/**
* Highlights the characters the current search can jump to, like clever-f's `g:clever_f_mark_char`.
* Only one set of candidates is shown at a time.
*/
internal class CandidateHighlighter {
private var editor: Editor? = null
private val highlighters = mutableListOf<RangeHighlighter>()
fun show(editor: Editor, offsets: List<Int>) {
clear()
this.editor = editor
offsets.mapTo(highlighters) { offset ->
editor.markupModel.addRangeHighlighter(
offset,
offset + 1,
HighlighterLayer.SELECTION,
CANDIDATE_ATTRIBUTES,
HighlighterTargetArea.EXACT_RANGE,
)
}
}
fun clear() {
val markupModel = editor?.takeUnless { it.isDisposed }?.markupModel
if (markupModel != null) highlighters.forEach(markupModel::removeHighlighter)
highlighters.clear()
editor = null
}
private companion object {
/** clever-f's `CleverFDefaultLabel`: red, bold and underlined text */
val CANDIDATE_ATTRIBUTES = TextAttributes(JBColor.RED, null, JBColor.RED, EffectType.LINE_UNDERSCORE, Font.BOLD)
}
}
@@ -0,0 +1,24 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package com.maddyhome.idea.vim.extension.cleverf
/**
* Decides whether a character in the buffer matches the searched [char], honouring `g:clever_f_ignore_case`,
* `g:clever_f_smart_case` and `g:clever_f_chars_match_any_signs`.
*/
internal class CharMatcher(private val char: Char) {
private val matchesAnySign = char in CleverFSettings.charsMatchAnySigns
private val ignoreCase = (CleverFSettings.smartCase && char.isLowerCase()) || CleverFSettings.ignoreCase
fun matches(c: Char): Boolean = if (matchesAnySign) c in SIGNS else c.equals(char, ignoreCase)
private companion object {
const val SIGNS = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"
}
}
@@ -0,0 +1,46 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package com.maddyhome.idea.vim.extension.cleverf
import com.maddyhome.idea.vim.api.VimEditor
import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.extension.VimExtensionFacade.inputKeyStroke
import com.maddyhome.idea.vim.newapi.ij
import java.awt.event.KeyEvent
import javax.swing.KeyStroke
/**
* Reads the character to search for. A key from `g:clever_f_repeat_last_char_inputs` (`<CR>` by default) stands for
* the last searched character.
*/
internal class CharReader(private val session: CleverFSession) {
/** Returns the character to search for, or null if the input was cancelled or there is nothing to repeat */
fun read(editor: VimEditor): Char? {
val key = inputKeyStroke(editor.ij)
return when {
isEscape(key) -> null
isRepeatLastCharInput(key) -> session.lastChar ?: null.also { injector.messages.indicateError() }
else -> key.keyChar.takeUnless { it == KeyEvent.CHAR_UNDEFINED }
}
}
private fun isRepeatLastCharInput(key: KeyStroke): Boolean {
val inputs = CleverFSettings.repeatLastCharInputs ?: return isEnter(key)
return inputs.any { input -> if (input == "\r" || input == "\n") isEnter(key) else isSameKey(input, key) }
}
private fun isSameKey(input: String, key: KeyStroke): Boolean =
injector.parser.parseKeys(input).singleOrNull() == key || input == key.keyChar.toString()
private fun isEscape(key: KeyStroke): Boolean =
key.keyCode == KeyEvent.VK_ESCAPE || key.keyChar == KeyEvent.VK_ESCAPE.toChar()
private fun isEnter(key: KeyStroke): Boolean =
key.keyCode == KeyEvent.VK_ENTER || key.keyChar == '\n' || key.keyChar == '\r'
}
@@ -0,0 +1,73 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package com.maddyhome.idea.vim.extension.cleverf
import com.maddyhome.idea.vim.api.VimEditor
import com.maddyhome.idea.vim.api.getLineEndForOffset
import com.maddyhome.idea.vim.api.getLineStartForOffset
/**
* A single invocation of the search of [state], with [map] being the effective `f`/`F`/`t`/`T` motion.
*/
internal class CharSearch(
val state: SearchState,
map: Char,
char: Char,
private val isFirstMove: Boolean,
) {
private val matcher = CharMatcher(char)
private val isForward = map.isLowerCase()
private val isTill = map.equals('t', ignoreCase = true)
private val step = if (isForward) 1 else -1
/** In operator-pending mode `f` and `t` include the target character, `F` and `T` don't */
val isInclusive: Boolean = isForward
/** Returns the offset [count] matches away from [offset], or null if there are fewer matches */
fun findTarget(editor: VimEditor, offset: Int, count: Int): Int? {
val text = editor.text()
val bounds = searchBounds(editor, offset)
var position = offset
for (move in 0 until count) {
val match = findMatch(text, searchStart(position, move), bounds) ?: return null
position = targetOf(match)
}
return position
}
/** Returns the offsets on [line] the search can jump to */
fun candidatesOnLine(editor: VimEditor, line: Int): List<Int> {
val text = editor.text()
val lineRange = editor.getLineStartOffset(line) until editor.getLineEndOffset(line)
return lineRange.filter { matcher.matches(text[it]) }.map(::targetOf).filter { it in lineRange }
}
/**
* On the first move `t`/`T` accept a match right next to the cursor. On repeats they must skip it, otherwise the
* cursor would get stuck in front of the same character.
*/
private fun searchStart(position: Int, move: Int): Int {
val skipsAdjacentMatch = isTill && !(isFirstMove && move == 0)
return if (skipsAdjacentMatch) position + step else position
}
private fun findMatch(text: CharSequence, start: Int, bounds: IntRange): Int? {
val candidates = if (isForward) (start + 1)..bounds.last else (start - 1) downTo bounds.first
return candidates.firstOrNull { matcher.matches(text[it]) }
}
private fun targetOf(match: Int): Int = if (isTill) match - step else match
private fun searchBounds(editor: VimEditor, offset: Int): IntRange =
if (CleverFSettings.acrossNoLine) {
editor.getLineStartForOffset(offset) until editor.getLineEndForOffset(offset)
} else {
0 until editor.text().length
}
}
@@ -0,0 +1,71 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package com.maddyhome.idea.vim.extension.cleverf
import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.command.MappingMode
import com.maddyhome.idea.vim.extension.ExtensionHandler
import com.maddyhome.idea.vim.extension.VimExtension
import com.maddyhome.idea.vim.extension.VimExtensionFacade.putExtensionHandlerMapping
import com.maddyhome.idea.vim.extension.VimExtensionFacade.putKeyMappingIfMissing
/**
* Port of clever-f.vim (https://github.com/rhysd/clever-f.vim).
*
* Extends `f`, `F`, `t` and `T` so that pressing the same key again repeats the last search, making `;` and `,`
* unnecessary. A search is repeated only if the cursor is still where the previous search left it; any other cursor
* movement, text change or entering Insert mode starts a new search. Unlike the builtin motions, searches cross line
* boundaries by default. The characters the search can jump to on the cursor line are highlighted.
*/
internal class CleverFExtension : VimExtension {
private val session = CleverFSession()
private val finalizer = SearchFinalizer(session)
override fun getName(): String = "clever-f"
override fun init() {
finalizer.attach()
registerMappings(SearchExecutor(session))
}
override fun dispose() {
finalizer.detach()
session.reset()
super.dispose()
}
private fun registerMappings(executor: SearchExecutor) {
for (map in FIND_KEYS) {
val plugName = "<Plug>(clever-f-$map)"
mapPlug(plugName, FindHandler(map, session, executor))
if (!CleverFSettings.notOverwritesStandardMappings) mapKeyToPlug(map.toString(), plugName)
}
mapPlug("<Plug>(clever-f-repeat-forward)", RepeatHandler(back = false, session, executor))
mapPlug("<Plug>(clever-f-repeat-back)", RepeatHandler(back = true, session, executor))
mapPlug("<Plug>(clever-f-reset)", ResetHandler(session))
}
private fun mapPlug(plugName: String, handler: ExtensionHandler) {
putExtensionHandlerMapping(MappingMode.NXO, injector.parser.parseKeys(plugName), owner, handler, false)
}
private fun mapKeyToPlug(key: String, plugName: String) {
putKeyMappingIfMissing(
MappingMode.NXO,
injector.parser.parseKeys(key),
owner,
injector.parser.parseKeys(plugName),
true,
)
}
private companion object {
val FIND_KEYS = listOf('f', 'F', 't', 'T')
}
}
@@ -0,0 +1,39 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package com.maddyhome.idea.vim.extension.cleverf
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.command.Argument
import com.maddyhome.idea.vim.command.MotionType
import com.maddyhome.idea.vim.command.OperatorArguments
import com.maddyhome.idea.vim.handler.Motion
import com.maddyhome.idea.vim.handler.MotionActionHandler
import com.maddyhome.idea.vim.handler.toMotionOrError
/** The motion of a [CharSearch] in operator-pending mode, e.g. `dfa` */
internal class CleverFMotionAction(
private val search: CharSearch,
private val session: CleverFSession,
) : MotionActionHandler.ForEachCaret() {
override val motionType: MotionType = if (search.isInclusive) MotionType.INCLUSIVE else MotionType.EXCLUSIVE
override fun getOffset(
editor: VimEditor,
caret: ImmutableVimCaret,
context: ExecutionContext,
argument: Argument?,
operatorArguments: OperatorArguments,
): Motion {
val target = search.findTarget(editor, caret.offset, operatorArguments.count1) ?: return Motion.Error
if (caret.isPrimary) session.recordJump(search.state, editor, target)
return target.toMotionOrError()
}
}
@@ -0,0 +1,59 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package com.maddyhome.idea.vim.extension.cleverf
import com.maddyhome.idea.vim.api.VimEditor
import com.maddyhome.idea.vim.state.mode.Mode
import java.util.*
/**
* Holds the searches of one clever-f instance. Like clever-f's `s:previous_*[mode]` dictionaries, each mode has its
* own search, so a search in Normal mode is not repeated in Visual mode and vice versa.
*/
internal class CleverFSession {
private val states = EnumMap<ModeKind, SearchState>(ModeKind::class.java)
val highlighter = CandidateHighlighter()
/** The search that moved the cursor last. Any cursor movement away from its position ends the searches. */
var activeState: SearchState? = null
private set
/** The last searched character in any mode, used by `g:clever_f_repeat_last_char_inputs` */
var lastChar: Char? = null
private set
fun stateFor(mode: Mode): SearchState? = states[ModeKind.of(mode)]
fun startSearch(editor: VimEditor, map: Char, char: Char): SearchState {
val state = SearchState(map, char, editor)
states[ModeKind.of(editor.mode)] = state
activeState = state
lastChar = char
return state
}
fun recordJump(state: SearchState, editor: VimEditor, offset: Int) {
state.recordJump(editor, offset)
activeState = state
}
/** Ends all searches, so the next `f`/`F`/`t`/`T` asks for a new character */
fun endSearches() {
states.clear()
activeState = null
highlighter.clear()
}
/** Ends all searches and forgets the last searched character */
fun reset() {
endSearches()
lastChar = null
}
}
@@ -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 com.maddyhome.idea.vim.extension.cleverf
import com.maddyhome.idea.vim.VimPlugin
import com.maddyhome.idea.vim.vimscript.model.datatypes.VimDataType
import com.maddyhome.idea.vim.vimscript.model.datatypes.VimList
/**
* The `g:clever_f_*` variables. They are read on every use, so changing them takes effect immediately, except for
* [notOverwritesStandardMappings] which only matters when the extension is initialised.
*/
internal object CleverFSettings {
val acrossNoLine: Boolean get() = flag("clever_f_across_no_line")
val ignoreCase: Boolean get() = flag("clever_f_ignore_case")
val smartCase: Boolean get() = flag("clever_f_smart_case")
val fixKeyDirection: Boolean get() = flag("clever_f_fix_key_direction")
val markChar: Boolean get() = flag("clever_f_mark_char", default = true)
val notOverwritesStandardMappings: Boolean get() = flag("clever_f_not_overwrites_standard_mappings")
val timeoutMs: Int get() = variable("clever_f_timeout_ms")?.toVimNumber()?.value ?: 0
val charsMatchAnySigns: String get() = variable("clever_f_chars_match_any_signs")?.toVimString()?.value ?: ""
/** The keys which repeat the last searched character, or null to use the default `<CR>` */
val repeatLastCharInputs: List<String>?
get() = (variable("clever_f_repeat_last_char_inputs") as? VimList)?.values?.map { it.toVimString().value }
private fun flag(name: String, default: Boolean = false): Boolean =
variable(name)?.toVimNumber()?.booleanValue ?: default
private fun variable(name: String): VimDataType? = VimPlugin.getVariableService().getGlobalVariableValue(name)
}
@@ -0,0 +1,44 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package com.maddyhome.idea.vim.extension.cleverf
import com.maddyhome.idea.vim.api.ExecutionContext
import com.maddyhome.idea.vim.api.VimEditor
import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.command.OperatorArguments
import com.maddyhome.idea.vim.extension.ExtensionHandler
/**
* Handles `<Plug>(clever-f-{map})`. Repeats the search of the current mode if the cursor is still where it left it,
* in the direction implied by [map]. Otherwise asks for a character and starts a new search.
*/
internal class FindHandler(
private val map: Char,
private val session: CleverFSession,
private val executor: SearchExecutor,
) : ExtensionHandler {
private val charReader = CharReader(session)
override fun execute(editor: VimEditor, context: ExecutionContext, operatorArguments: OperatorArguments) {
val search = repeatedSearch(editor) ?: newSearch(editor) ?: return
executor.execute(editor, search, operatorArguments.count1)
}
/** While a macro runs, clever-f always asks for a character, so a recorded `fa` replays as `fa` */
private fun repeatedSearch(editor: VimEditor): CharSearch? {
if (injector.macro.isExecutingMacro) return null
val state = session.stateFor(editor.mode)?.takeIf { it.canRepeat(editor) } ?: return null
return state.repeatSearch(back = map.isUpperCase())
}
private fun newSearch(editor: VimEditor): CharSearch? {
val char = charReader.read(editor) ?: return null
return session.startSearch(editor, map, char).firstSearch()
}
}
@@ -0,0 +1,26 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package com.maddyhome.idea.vim.extension.cleverf
import com.maddyhome.idea.vim.state.mode.Mode
/** The modes clever-f keeps a separate search for */
internal enum class ModeKind {
NORMAL,
VISUAL,
OP_PENDING;
companion object {
fun of(mode: Mode): ModeKind = when (mode) {
is Mode.OP_PENDING -> OP_PENDING
is Mode.VISUAL, is Mode.SELECT -> VISUAL
else -> NORMAL
}
}
}
@@ -0,0 +1,26 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package com.maddyhome.idea.vim.extension.cleverf
import com.maddyhome.idea.vim.api.ExecutionContext
import com.maddyhome.idea.vim.api.VimEditor
import com.maddyhome.idea.vim.command.OperatorArguments
import com.maddyhome.idea.vim.extension.ExtensionHandler
/** Handles `<Plug>(clever-f-repeat-forward)` and `<Plug>(clever-f-repeat-back)` */
internal class RepeatHandler(
private val back: Boolean,
private val session: CleverFSession,
private val executor: SearchExecutor,
) : ExtensionHandler {
override fun execute(editor: VimEditor, context: ExecutionContext, operatorArguments: OperatorArguments) {
val state = session.stateFor(editor.mode) ?: return
executor.execute(editor, state.repeatSearch(back), operatorArguments.count1)
}
}
@@ -0,0 +1,21 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package com.maddyhome.idea.vim.extension.cleverf
import com.maddyhome.idea.vim.api.ExecutionContext
import com.maddyhome.idea.vim.api.VimEditor
import com.maddyhome.idea.vim.command.OperatorArguments
import com.maddyhome.idea.vim.extension.ExtensionHandler
/** Handles `<Plug>(clever-f-reset)` */
internal class ResetHandler(private val session: CleverFSession) : ExtensionHandler {
override fun execute(editor: VimEditor, context: ExecutionContext, operatorArguments: OperatorArguments) {
session.reset()
}
}
@@ -0,0 +1,48 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package com.maddyhome.idea.vim.extension.cleverf
import com.maddyhome.idea.vim.KeyHandler
import com.maddyhome.idea.vim.api.VimEditor
import com.maddyhome.idea.vim.newapi.ij
import com.maddyhome.idea.vim.state.mode.Mode
/**
* Runs a [CharSearch]. In operator-pending mode the search is handed to the command builder as a motion, so the
* operator gets the right inclusive/exclusive range. Otherwise the carets are moved directly.
*/
internal class SearchExecutor(private val session: CleverFSession) {
fun execute(editor: VimEditor, search: CharSearch, count: Int) {
val commandBuilder = KeyHandler.getInstance().keyHandlerState.commandBuilder
if (editor.mode is Mode.OP_PENDING) {
commandBuilder.addAction(CleverFMotionAction(search, session))
return
}
// The search completes the command, so the count must not leak into the next one. In operator-pending mode the
// count is still needed by the motion action, and the command builder resets it once the command is done.
commandBuilder.resetCount()
moveCarets(editor, search, count)
highlightCandidates(editor, search)
}
private fun moveCarets(editor: VimEditor, search: CharSearch, count: Int) {
for (caret in editor.sortedCarets()) {
val target = search.findTarget(editor, caret.offset, count) ?: continue
if (caret.isPrimary) session.recordJump(search.state, editor, target)
caret.moveToOffset(target)
}
}
private fun highlightCandidates(editor: VimEditor, search: CharSearch) {
if (!CleverFSettings.markChar) return
val line = editor.primaryCaret().getLine()
session.highlighter.show(editor.ij, search.candidatesOnLine(editor, line))
}
}

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