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

Compare commits

...

102 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

40
.beads/.gitignore vendored
View File

@@ -1,40 +0,0 @@
# SQLite databases
*.db
*.db?*
*.db-journal
*.db-wal
*.db-shm
# Daemon runtime files
daemon.lock
daemon.log
daemon.pid
bd.sock
sync-state.json
last-touched
.sync.lock
# Local version tracking (prevents upgrade notification spam after git ops)
.local_version
# Legacy database files
db.sqlite
bd.db
# Worktree redirect file (contains relative path to main repo's .beads/)
# Must not be committed as paths would be wrong in other clones
redirect
# Merge artifacts (temporary files from 3-way merge)
beads.base.jsonl
beads.base.meta.json
beads.left.jsonl
beads.left.meta.json
beads.right.jsonl
beads.right.meta.json
# NOTE: Do NOT add negation patterns (e.g., !issues.jsonl) here.
# They would override fork protection in .git/info/exclude, allowing
# contributors to accidentally commit upstream issue databases.
# The JSONL files (issues.jsonl, interactions.jsonl) and config files
# are tracked by git by default since no pattern above ignores them.

View File

@@ -1,81 +0,0 @@
# Beads - AI-Native Issue Tracking
Welcome to Beads! This repository uses **Beads** for issue tracking - a modern, AI-native tool designed to live directly in your codebase alongside your code.
## What is Beads?
Beads is issue tracking that lives in your repo, making it perfect for AI coding agents and developers who want their issues close to their code. No web UI required - everything works through the CLI and integrates seamlessly with git.
**Learn more:** [github.com/steveyegge/beads](https://github.com/steveyegge/beads)
## Quick Start
### Essential Commands
```bash
# Create new issues
bd create "Add user authentication"
# View all issues
bd list
# View issue details
bd show <issue-id>
# Update issue status
bd update <issue-id> --status in_progress
bd update <issue-id> --status done
# Sync with git remote
bd sync
```
### Working with Issues
Issues in Beads are:
- **Git-native**: Stored in `.beads/issues.jsonl` and synced like code
- **AI-friendly**: CLI-first design works perfectly with AI coding agents
- **Branch-aware**: Issues can follow your branch workflow
- **Always in sync**: Auto-syncs with your commits
## Why Beads?
**AI-Native Design**
- Built specifically for AI-assisted development workflows
- CLI-first interface works seamlessly with AI coding agents
- No context switching to web UIs
🚀 **Developer Focused**
- Issues live in your repo, right next to your code
- Works offline, syncs when you push
- Fast, lightweight, and stays out of your way
🔧 **Git Integration**
- Automatic sync with git commits
- Branch-aware issue tracking
- Intelligent JSONL merge resolution
## Get Started with Beads
Try Beads in your own projects:
```bash
# Install Beads
curl -sSL https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh | bash
# Initialize in your repo
bd init
# Create your first issue
bd create "Try out Beads"
```
## Learn More
- **Documentation**: [github.com/steveyegge/beads/docs](https://github.com/steveyegge/beads/tree/main/docs)
- **Quick Start Guide**: Run `bd quickstart`
- **Examples**: [github.com/steveyegge/beads/examples](https://github.com/steveyegge/beads/tree/main/examples)
---
*Beads: Issue tracking that moves at the speed of thought*

View File

@@ -1,62 +0,0 @@
# Beads Configuration File
# This file configures default behavior for all bd commands in this repository
# All settings can also be set via environment variables (BD_* prefix)
# or overridden with command-line flags
# Issue prefix for this repository (used by bd init)
# If not set, bd init will auto-detect from directory name
# Example: issue-prefix: "myproject" creates issues like "myproject-1", "myproject-2", etc.
# issue-prefix: ""
# Use no-db mode: load from JSONL, no SQLite, write back after each command
# When true, bd will use .beads/issues.jsonl as the source of truth
# instead of SQLite database
# no-db: false
# Disable daemon for RPC communication (forces direct database access)
# no-daemon: false
# Disable auto-flush of database to JSONL after mutations
# no-auto-flush: false
# Disable auto-import from JSONL when it's newer than database
# no-auto-import: false
# Enable JSON output by default
# json: false
# Default actor for audit trails (overridden by BD_ACTOR or --actor)
# actor: ""
# Path to database (overridden by BEADS_DB or --db)
# db: ""
# Auto-start daemon if not running (can also use BEADS_AUTO_START_DAEMON)
# auto-start-daemon: true
# Debounce interval for auto-flush (can also use BEADS_FLUSH_DEBOUNCE)
# flush-debounce: "5s"
# Git branch for beads commits (bd sync will commit to this branch)
# IMPORTANT: Set this for team projects so all clones use the same sync branch.
# This setting persists across clones (unlike database config which is gitignored).
# Can also use BEADS_SYNC_BRANCH env var for local override.
# If not set, bd sync will require you to run 'bd config set sync.branch <branch>'.
# sync-branch: "beads-sync"
# Multi-repo configuration (experimental - bd-307)
# Allows hydrating from multiple repositories and routing writes to the correct JSONL
# repos:
# primary: "." # Primary repo (where this database lives)
# additional: # Additional repos to hydrate from (read-only)
# - ~/beads-planning # Personal planning repo
# - ~/work-planning # Work planning repo
# Integration settings (access with 'bd config get/set')
# These are stored in the database, not in this file:
# - jira.url
# - jira.project
# - linear.url
# - linear.api-key
# - github.org
# - github.repo

View File

@@ -1,4 +0,0 @@
{
"database": "beads.db",
"jsonl_export": "issues.jsonl"
}

View File

@@ -1,73 +0,0 @@
---
name: code-reviewer
description: Code reviewer for IdeaVim - focuses on Vim compatibility, Kotlin/Java quality, IntelliJ Platform patterns, and test coverage.
model: inherit
color: pink
---
You are a code reviewer for IdeaVim, an open-source Vim emulator plugin for JetBrains IDEs. Your focus is on Vim compatibility, code quality, and maintainability.
## Project Context
IdeaVim is:
- Written primarily in Kotlin with some Java
- An IntelliJ Platform plugin
- Split into `vim-engine` (platform-independent) and `IdeaVim` (IntelliJ-specific) modules
- Goal: Match Vim functionality and architecture as closely as possible
## When Reviewing Code
### Vim Compatibility
- Does the change match Vim's behavior? Check against `:help` documentation
- Is `@VimBehaviorDiffers` annotation used if behavior intentionally differs from Vim?
- Are motions correctly typed (inclusive, exclusive, or linewise via `MotionType`)?
- Do extensions use the same command names as original Vim plugins?
### Code Quality (Kotlin/Java)
- Kotlin is preferred for new code; Java only where explicitly used
- Check for null safety, proper use of Kotlin idioms
- Resource management (especially with IntelliJ Platform disposables)
- Error handling appropriate for plugin context
### IntelliJ Platform Patterns
- Correct use of Application/Project services
- Proper threading (read/write actions, EDT vs background)
- Disposable lifecycle management
- Action system usage (`<Action>` in mappings, not `:action`)
### Test Coverage
Check that tests cover corner cases from CONTRIBUTING.md:
- **Position-based**: line start/end, file start/end, empty line, single char line
- **Content-based**: whitespace-only lines, trailing spaces, tabs/spaces, Unicode, multi-byte chars
- **Selection-based**: multiple carets, visual modes (char/line/block), empty selection
- **Motion-based**: dollar motion, count with motion (e.g., `3w`, `5j`)
- **Buffer state**: empty file, single line file, long lines
Tests using `doTest` are automatically verified against neovim - this is good.
### Test Quality
- Avoid senseless text like "dhjkwaldjwa" - use Lorem Ipsum or realistic code snippets
- Check if `@TestWithoutNeovim` or `@VimBehaviorDiffers` annotations are appropriate
- Property tests in `propertybased` package are flaky by nature - verify if failures relate to the change
## Review Priorities
1. **Correctness** - Does it work as Vim does?
2. **Safety** - No crashes, proper null handling, thread safety
3. **Tests** - Corner cases covered, meaningful test data
4. **Maintainability** - Clear code, follows project patterns
## What NOT to Focus On
- Generic security issues (this is a local editor plugin, not a web service)
- Database queries (there are none)
- Network security (minimal network usage)
- Arbitrary code metrics like "cyclomatic complexity < 10"
## Output Format
Provide concise, actionable feedback:
- Link to specific lines when pointing out issues
- Reference Vim documentation (`:help <topic>`) when relevant
- Suggest specific fixes, not just problem descriptions
- Acknowledge what's done well (briefly)

View File

@@ -1,206 +0,0 @@
# Codebase Maintenance Instructions
## Goal
Perform routine maintenance on random parts of the IdeaVim codebase to ensure code quality, consistency, and catch potential issues early. This is not about being overly pedantic or making changes for the sake of changes - it's about keeping an eye on the codebase and identifying genuine issues.
## Approach
### 1. Select Random Area
Choose a random part of the codebase to inspect. Use one of these strategies:
```bash
# Get a random Kotlin file
find . -name "*.kt" -not -path "*/build/*" -not -path "*/.gradle/*" | shuf -n 1
# Get a random package/directory
find . -type d -name "*.kt" -not -path "*/build/*" | shuf -n 1 | xargs dirname
# Pick from core areas randomly
# - vim-engine/src/main/kotlin/com/maddyhome/idea/vim/
# - src/main/java/com/maddyhome/idea/vim/
# - tests/
```
**Important**: You're not limited to the file you randomly selected. If investigating reveals related files that need attention, follow the trail. The random selection is just a starting point.
## 2. What to Check
### Code Style & Formatting
- **Kotlin conventions**: Proper use of data classes, sealed classes, when expressions
- **Naming consistency**: Follow existing patterns in the codebase
- **Import organization**: Remove unused imports, prefer explicit imports over wildcards (wildcard imports are generally not welcome)
- **Code structure**: Proper indentation, spacing, line breaks
- **Documentation**: KDoc comments where needed (public APIs, complex logic)
- **Copyright years**: Do NOT update copyright years unless you're making substantive changes to the file. It's perfectly fine for copyright to show an older year. Don't mention copyright year updates in commit messages or change summaries
### Code Quality Issues
- **Null safety**: Proper use of nullable types, safe calls, Elvis operator
- **Error handling**: Appropriate exception handling, meaningful error messages
- **Code duplication**: Identify repeated code that could be extracted
- **Dead code**: Unused functions, parameters, variables
- **TODOs/FIXMEs**: Check if old TODOs are still relevant or can be addressed
- **Magic numbers/strings**: Should be named constants
- **Complex conditionals**: Can they be simplified or extracted?
### Potential Bugs
- **Off-by-one errors**: Especially in loops and range operations
- **Edge cases**: Empty collections, null values, boundary conditions
- **Type safety**: Unnecessary casts, unchecked casts
- **Resource handling**: Proper cleanup, try-with-resources
- **Concurrency issues**: Thread safety if applicable
- **State management**: Proper initialization, mutation patterns
- **IdeaVim enablement checks**: Verify that `injector.enabler.isEnabled()` or `Editor.isIdeaVimDisabledHere` are not missed in places where they should be checked. These functions determine if IdeaVim is active and should be called before performing Vim-specific operations
### Architecture & Design
- **Separation of concerns**: Does the code have a single responsibility?
- **Dependency direction**: Are dependencies pointing the right way?
- **Abstraction level**: Consistent level of abstraction within methods
- **Vim architecture alignment**: Does it match Vim's design philosophy?
- **IntelliJ Platform conventions**: Proper use of platform APIs
### Testing
- **Test coverage**: Are there tests for the code you're reviewing?
- If checking a specific command or function, verify that tests exist for it
- If tests exist, check if they cover the needed cases (edge cases, error conditions, typical usage)
- If tests don't exist or coverage is incomplete, consider creating comprehensive test coverage
- **Test quality**: Do tests cover edge cases?
- **Test naming**: Clear, descriptive test names
- **Flaky tests**: Any potentially unstable tests?
- **Regression tests for bug fixes**: When fixing a bug, always write a test that:
- Would fail with the old (buggy) implementation
- Passes with the fixed implementation
- Clearly documents what bug it's testing (include comments explaining the issue)
- Tests the specific boundary condition or edge case that exposed the bug
- This ensures the bug doesn't resurface in future refactorings
## 3. Investigation Strategy
Don't just look at surface-level issues. Dig deeper:
1. **Read the code**: Understand what it does before suggesting changes
2. **Check related files**: Look at callers, implementations, tests
3. **Look at git history**: `git log --oneline <file>` to understand context
4. **Find related issues**: Search for TODOs, FIXMEs, or commented code
5. **Run tests**: If you make changes, ensure tests pass
6. **Check YouTrack**: Look for related issues if you find bugs
## 4. When to Make Changes
**DO fix**:
- Clear bugs or logic errors
- Obvious code quality issues (unused imports, etc.)
- Misleading or incorrect documentation
- Code that violates established patterns
- Security vulnerabilities
- Performance issues with measurable impact
**DON'T fix**:
- Stylistic preferences if existing code is consistent
- Working code just to use "newer" patterns
- Minor formatting if it's consistent with surrounding code
- Things that are subjective or arguable
- Massive refactorings without clear benefit
**When in doubt**: Document the issue in your report but don't make changes.
## 5. Making Changes
If you decide to make changes:
1. **Make focused commits**: One logical change per commit
- If the change affects many files or is complicated or has multiple logical changes, split it into multiple step-by-step commits
- This makes it easier for reviewers to understand the changes
- Example: First commit renames a function, second commit updates callers, third commit adds new functionality
- This rule is important!
2. **Write clear commit messages**: Explain why, not just what
3. **Run tests**: `./gradlew test -x :tests:property-tests:test -x :tests:long-running-tests:test`
## 6. Examples
### Good Maintenance Examples
**Example 1: Found and fixed null safety issue**
```
Inspected: vim-engine/.../motion/VimMotionHandler.kt
Issues found:
- Several nullable properties accessed without safe checks
- Could cause NPE in edge cases with cursor at document end
Changes:
- Added null checks with Elvis operator
- Added early returns for invalid state
- Added KDoc explaining preconditions
```
**Example 2: No changes needed**
```
Inspected: src/.../action/change/ChangeLineAction.kt
Checked:
- Code style and formatting ✓
- Null safety ✓
- Error handling ✓
- Tests present and comprehensive ✓
Observations:
- Code is well-structured and follows conventions
- Good test coverage including edge cases
- Documentation is clear
- No issues found
```
**Example 3: Found issues but didn't fix**
```
Inspected: tests/.../motion/MotionTests.kt
Issues noted:
- Some test names could be more descriptive
- Potential for extracting common setup code
- Tests are comprehensive but could add edge case for empty file
Recommendation: These are minor quality-of-life improvements.
Not critical, but could be addressed in future cleanup.
```
## IdeaVim-Specific Considerations
- **Vim compatibility**: Changes should maintain compatibility with Vim behavior
- **IntelliJ Platform**: Follow IntelliJ platform conventions and APIs
- **Property tests**: Can be flaky - verify if test failures relate to your changes
- **Action syntax**: Use `<Action>` in mappings, not `:action`
- **Architecture & Guidelines**: Refer to [CONTRIBUTING.md](../CONTRIBUTING.md) for:
- Architecture overview and where to find specific code
- Testing guidelines and corner cases to consider
- Common patterns and conventions
## Commands Reference
```bash
# Run tests (standard suite)
./gradlew test -x :tests:property-tests:test -x :tests:long-running-tests:test
# Run specific test class
./gradlew test --tests "ClassName"
# Check code style
./gradlew ktlintCheck
# Format code
./gradlew ktlintFormat
# Run IdeaVim in dev instance
./gradlew runIde
```
## Final Notes
- **Be thorough but practical**: Don't waste time on nitpicks
- **Context matters**: Understand why code is the way it is before changing
- **Quality over quantity**: One good fix is better than ten trivial changes
- **Document your process**: Help future maintainers understand your thinking
- **Learn from the code**: Use this as an opportunity to understand the codebase better
Remember: The goal is to keep the codebase healthy, not to achieve perfection. Focus on genuine improvements that make the code safer, clearer, or more maintainable.

View File

@@ -1,5 +0,0 @@
{
"enabledPlugins": {
"context7@claude-plugins-official": true
}
}

View File

@@ -1,234 +0,0 @@
---
name: changelog
description: Maintains IdeaVim changelog (CHANGES.md) and build.gradle.kts changeNotes. Use when updating changelog, documenting releases, or reviewing commits/PRs for changelog entries.
---
# Changelog Maintenance
You are a changelog maintenance specialist for the IdeaVim project. Your job is to keep the changelog (CHANGES.md) and build.gradle.kts changeNotes in sync with code changes.
## Historical Context
- The changelog was actively maintained until version 2.9.0
- There's a gap from 2.10.0 through 2.27.0 where changelog wasn't maintained
- We're resuming changelog maintenance from version 2.28.0 onwards
- Between 2.9.0 and 2.28.0, include this note: **"Changelog was not maintained for versions 2.10.0 through 2.27.0"**
## Changelog Structure
### [To Be Released] Section
- All unreleased changes from master branch go here
- When a release is made, this section becomes the new version section
- Create a new empty `[To Be Released]` section after each release
### Version Entry Format
```
## 2.28.0, 2024-MM-DD
### Features:
* Feature description without ticket number
* `CommandName` action can be used... | [VIM-XXXX](https://youtrack.jetbrains.com/issue/VIM-XXXX)
### Fixes:
* [VIM-XXXX](https://youtrack.jetbrains.com/issue/VIM-XXXX) Bug fix description
### Changes:
* Other changes
```
## How to Gather Information
### 1. Check Current State
- Read CHANGES.md to find the last documented version
- **Important**: Only read the top portion of CHANGES.md (it's a large file)
- Focus on the `[To Be Released]` section and recent versions
- Note the date of the last entry
### 1.5. Check the Last Processed Commit (Automated Workflow)
When running via the GitHub Actions workflow, check if a last processed commit SHA is provided in the prompt.
- If a commit SHA is provided, use `git log <SHA>..HEAD --oneline` to see only unprocessed commits
- This is more accurate than date-based filtering
- The last successful workflow run is tracked via GitHub Actions API
### 2. Find Releases
- Use `git tag --list --sort=-version:refname` to see all version tags
- Tags like `2.27.0`, `2.27.1` indicate releases
- Note: Patch releases (x.x.1, x.x.2) might be on separate branches
- Release dates available at: https://plugins.jetbrains.com/plugin/164-ideavim/versions
### 3. Review Changes
```bash
# Get commits since last documented version
git log --oneline --since="YYYY-MM-DD" --first-parent master
# Get merged PRs
gh pr list --state merged --limit 100 --json number,title,author,mergedAt
# Check specific release commits
git log --oneline <previous-tag>..<new-tag>
```
**Important**: Don't just read commit messages - examine the actual changes:
- Use `git show <commit-hash>` to see the full commit content
- Look at modified test files to find specific examples of fixed commands
- Check the actual code changes to understand what was really fixed or added
- Tests often contain the best examples for changelog entries (e.g., exact commands that now work)
### 4. What to Include
- **Features**: New functionality with [VIM-XXXX] ticket numbers if available
- **Bug Fixes**: Fixed issues with [VIM-XXXX] ticket references
- **Breaking Changes**: Any backwards-incompatible changes
- **Deprecations**: Features marked for future removal
- **Merged PRs**: Reference significant PRs like "Implement vim-surround (#123)"
- Note: PRs have their own inclusion rules - see "Merged PRs Special Rules" section below
### 5. What to Exclude
- Dependabot PRs (author: dependabot[bot])
- Claude-generated PRs (check PR author/title)
- Internal refactoring with no user impact
- Documentation-only changes (unless significant)
- Test-only changes
- **API module changes** (while in experimental status) - Do not log changes to the `api` module as it's currently experimental
- Note: This exclusion should be removed once the API status is no longer experimental
- **Vim Everywhere project** (including Hints toggle) - Do not log changes related to the Vim Everywhere project as it's not yet ready
- **Internal code changes** - Do not log coding changes that users cannot see or experience
- Refactoring, code cleanup, internal architecture changes
- Performance optimizations (unless they fix a noticeable user issue)
- Remember: The changelog is for users, not developers
## Writing Style
- **Be concise**: One line per change when possible
- **User-focused**: Describe what changed from user's perspective
- Write for end users, not developers
- Focus on visible behavior changes, new commands, fixed issues users experience
- Avoid technical implementation details
- **Include examples** when helpful:
- For fixes: Show the command/operation that now works correctly
- For features: Demonstrate the new commands or functionality
- Good example: "Fixed `ci"` command in empty strings" or "Added support for `gn` text object"
- Bad examples (too vague, unclear what was broken):
- "Fixed count validation in text objects"
- "Fixed inlay offset calculations"
- Better: Specify the actual case - "Fixed `3daw` deleting wrong number of words" or "Fixed cursor position with inlay hints in `f` motion"
- **If you can't determine the specific case from tests/code, omit the entry rather than leave it unclear**
- **Add helpful links** for context:
- When mentioning IntelliJ features, search for official JetBrains documentation or blog posts
- When referencing Vim commands, link to Vim documentation if helpful
- Example: "Added support for [Next Edit Suggestion](https://blog.jetbrains.com/ai/2025/08/introducing-next-edit-suggestions-in-jetbrains-ai-assistant/)"
- Use web search to find the most relevant official sources
- **Include references**: Add [VIM-XXXX] for YouTrack tickets, (#XXX) for PRs
- **Group logically**: Features, Fixes, Changes, Merged PRs
- **No duplication**: Each change appears in exactly ONE subsection - don't repeat items across categories
- **Use consistent tense**: Past tense for completed work
## Examples of Good Entries
```
### Features:
* Added support for `gn` text object - select next match with `gn`, change with `cgn`
* Implemented `:tabmove` command - use `:tabmove +1` or `:tabmove -1` to reorder tabs
* Support for `z=` to show spelling suggestions
* Added integration with [Next Edit Suggestion](https://blog.jetbrains.com/ai/2025/08/introducing-next-edit-suggestions-in-jetbrains-ai-assistant/) feature
* Support for [multiple cursors](https://www.jetbrains.com/help/idea/multicursor.html) in visual mode
### Fixes:
* [VIM-3456](https://youtrack.jetbrains.com/issue/VIM-3456) Fixed cursor position after undo in visual mode
* [VIM-3458](https://youtrack.jetbrains.com/issue/VIM-3458) Fixed `ci"` command now works correctly in empty strings
* [VIM-3260](https://youtrack.jetbrains.com/issue/VIM-3260) Fixed `G` command at file end with count
* [VIM-3180](https://youtrack.jetbrains.com/issue/VIM-3180) Fixed `vib` and `viB` selection in nested blocks
### Merged PRs:
* [805](https://github.com/JetBrains/ideavim/pull/805) by [chylex](https://github.com/chylex): VIM-3238 Fix recording a macro that replays another macro
```
## IMPORTANT Format Notes
### For Fixes:
Always put the ticket link FIRST, then the description:
```
* [VIM-XXXX](https://youtrack.jetbrains.com/issue/VIM-XXXX) Description of what was fixed
```
### For Features:
- Without ticket: Just the description
- With ticket: Can use either format:
- Description with pipe: `* Feature description | [VIM-XXXX](https://youtrack.jetbrains.com/issue/VIM-XXXX)`
- Link first (like fixes): `* [VIM-XXXX](https://youtrack.jetbrains.com/issue/VIM-XXXX) Feature description`
### Avoid Duplication:
- **Each change should appear in only ONE subsection**
- If a feature is listed in Features, don't repeat it in Fixes
- If a bug fix is in Fixes, don't list it again elsewhere
- Choose the most appropriate category for each change
### Merged PRs Special Rules:
- **Different criteria than other sections**: The exclusion rules for Features/Fixes don't apply here
- **Include PRs from external contributors** even if they're internal changes or refactoring
- **List significant community contributions** regardless of whether they're user-visible
- **Format**: PR number, author, and brief description
- **Use PR title as-is**: Take the description directly from the PR title, don't regenerate or rewrite it
- **Purpose**: Acknowledge community contributions and provide PR tracking
- The "user-visible only" rule does NOT apply to this section
## Process
1. Read the current CHANGES.md (only the top portion - focus on `[To Be Released]` and recent versions)
2. Check previous changelog PRs from GitHub:
- Review the last few changelog update PRs (use `gh pr list --search "Update changelog" --state all --limit 5`)
- **Read the PR comments**: Use `gh pr view <PR_NUMBER> --comments` to check for specific instructions
- Look for any comments or instructions about what NOT to log this time
- Previous PRs may contain specific exclusions or special handling instructions
- Pay attention to review feedback that might indicate what to avoid in future updates
3. Check git tags for any undocumented releases
4. Review commits and PRs since last entry
5. Group changes by release or under [To Be Released]
6. Update CHANGES.md maintaining existing format
7. Update the `changeNotes` section in `build.gradle.kts` (see detailed instructions below)
8. Create a PR only if there are changes to document:
- Title format: "Update changelog: <super short summary>"
- Example: "Update changelog: Add gn text object, fix visual mode issues"
- Body: Brief summary of what was added
## Updating changeNotes in build.gradle.kts
The `changeNotes` section in `build.gradle.kts` displays on the JetBrains Marketplace plugin page. Follow these rules:
### Content Requirements
- **Match CHANGES.md exactly**: Use the same content from the `[To Be Released]` section
- **Don't create a shorter version**: Include all entries as they appear in CHANGES.md
- **Keep the same level of detail**: Don't summarize or condense
### HTML Formatting
Convert Markdown to HTML format:
- Headers: `### Features:` -> `<b>Features:</b>`
- Line breaks: Use `<br>` between items
- Links: Convert markdown links to HTML `<a href="">` tags
- Bullet points: Use `*` or keep `*` with proper spacing
- Code blocks: Use `<code>` tags for commands like `<code>gn</code>`
### Special Notes
- **IMPORTANT**: Keep any existing information about the reward program in changeNotes
- This content appears in the plugin description on JetBrains Marketplace
### Example Conversion
Markdown in CHANGES.md:
```
### Features:
* Added support for `gn` text object
* [VIM-3456](https://youtrack.jetbrains.com/issue/VIM-3456) Fixed cursor position
```
HTML in changeNotes:
```html
<b>Features:</b><br>
* Added support for <code>gn</code> text object<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-3456">VIM-3456</a> Fixed cursor position<br>
```
## Important Notes
- **Don't create a PR if changelog is already up to date**
- **Preserve existing format and structure**
- **Maintain chronological order (newest first)**
- **Keep the historical gap note between 2.9.0 and 2.28.0**

View File

@@ -1,275 +0,0 @@
---
name: doc-sync
description: Keeps IdeaVim documentation in sync with code changes. Use this skill when you need to verify documentation accuracy after code changes, or when checking if documentation (in doc/, README.md, CONTRIBUTING.md) matches the current codebase. The skill can work bidirectionally - from docs to code verification, or from code changes to documentation updates.
---
# Doc Sync Skill
You are a documentation synchronization specialist for the IdeaVim project. Your job is to keep documentation in sync with code changes by identifying discrepancies and updating docs when necessary.
## Documentation Locations
The IdeaVim project has documentation in these locations:
- `doc/` folder - Detailed documentation files
- `README.md` - Main project README
- `CONTRIBUTING.md` - Contribution guidelines
## Core Mindset
**CRITICAL:** After code changes, documentation is **GUILTY until proven innocent**.
**WRONG APPROACH:** "Be conservative, only update if clearly wrong"
**RIGHT APPROACH:** "Be aggressive finding issues, conservative making fixes"
**Trust Hierarchy:**
1. Working Implementation in codebase (highest truth)
2. API Definition (interface/class)
3. Documentation (assume outdated until verified)
## Phase 0: Pre-Analysis Search (DO THIS FIRST)
Before reading full files, run these quick searches to find red flags:
### 1. Find Working Examples (Ground Truth)
```bash
# Find real implementations
grep -r '@VimPlugin\|@Plugin\|class.*Extension' --include="*.kt" | head -5
# Or search for known implementation patterns
find . -name "*NewApi.kt" -o -name "*Example*.kt"
```
**Read at least ONE working implementation as ground truth.** This shows you what "correct" looks like.
### 2. Check Recent Breaking Changes
```bash
# Check recent commits to the changed files
git log --oneline -10 -- '**/[ChangedFile]*'
# Look for removal commits
git log --grep="remove\|deprecate\|incorrect" --oneline -10
# Check what was actually deleted (more important than additions!)
git show [recent-commit] --stat
```
### 3. Quick Pattern Search in Documentation
```bash
# Find all named parameters in code examples
grep -E '\w+\s*=' doc/*.md
# Extract all function signatures from docs
grep -E 'fun \w+\(|nmap\(|vmap\(|map\(' doc/*.md -B1 -A3
```
Compare each signature/parameter against the actual API.
## Two Modes of Operation
### Mode A: Documentation → Code Verification
Starting with documentation, verify that the code still matches what's documented.
**Steps:**
0. **FIRST:** Find working implementation as ground truth (Phase 0)
1. Read the specified documentation file(s)
2. Extract ALL code examples and function signatures
3. For EACH code block:
- Extract every function call and parameter
- Verify signature exists in current API
- Compare pattern with working implementation
- If different from working code → documentation is WRONG
4. Update documentation if needed
### Mode B: Code Changes → Documentation Update
Starting with code changes (e.g., from git diff), find related documentation and update if needed.
**Steps:**
0. **FIRST:** Understand what was REMOVED (Phase 0 - check git show/diff)
1. Read the changed files and git diff
2. Understand what changed (especially deletions and breaking changes)
3. Find working implementations that use the new API
4. Search for documentation that references these files/features/APIs
5. Extract all code examples from docs
6. Compare each example against working implementation
7. Update documentation to match the correct pattern
## Important Guidelines
### When to Update
**DO update when:**
- API signatures have changed (parameters added/removed/renamed)
- Function/class/file names have been renamed
- Behavior has fundamentally changed
- Features have been removed or added
- File paths in documentation are now incorrect
- Code examples in docs no longer work
**DON'T update when:**
- Only internal implementation changed (not public API)
- Wording could be slightly better but is still accurate
- Minor formatting inconsistencies
- Documentation uses slightly different terminology but conveys the same meaning
- Changes are in test files that don't affect public API
### Update Strategy
1. **Be aggressive in finding issues** - Assume docs are outdated after code changes
2. **Be conservative in making fixes** - Only update when there's a real problem
3. **Preserve style** - Match the existing documentation style
4. **Be specific** - Don't make sweeping changes; target the specific issue
5. **Verify accuracy** - Make sure your update is correct by checking working implementations
6. **Keep context** - Don't remove helpful context or examples unless they're wrong
### Verification Checklist
For EACH code block in documentation, verify:
- [ ] Extract the complete code example
- [ ] Identify every function call with its parameters
- [ ] For each function: Does this signature exist in current API?
- [ ] For each parameter: Does this parameter name/type exist in API?
- [ ] Does this pattern match the working implementation from codebase?
- [ ] If different from working code → **Documentation is WRONG**
- [ ] If parameters don't exist in API → **Documentation is WRONG**
## Workflow
When invoked, you should:
### Step 0: Establish Ground Truth (CRITICAL - DO FIRST)
- **Find working implementations:** Search for @VimPlugin, real examples in codebase
- **Check git history:** Run `git log -10` on changed files, look for "remove" commits
- **Understand deletions:** Run `git show [commit]` to see what was removed
- **Study working code:** Read at least 1-2 real implementations to understand correct patterns
### Step 1: Understand the Task
- If given doc files: Mode A (verify docs match code)
- If given code changes: Mode B (update docs to match code)
- If given both: Check if the code changes affect the mentioned docs
### Step 2: Quick Pattern Search
- Run grep searches from Phase 0 to find obvious red flags
- Extract all function signatures from docs
- Compare against API and working implementations
### Step 3: Detailed Verification
- Read relevant documentation thoroughly
- For EACH code example: Run through Verification Checklist
- Compare every signature and parameter against actual API
- Compare patterns against working implementations
### Step 4: Analyze Discrepancies
- List what's different between docs and code
- Assess severity (critical vs. minor)
- Determine if update is needed
- **Default to updating** when in doubt about code examples
### Step 5: Make Updates if Needed
- Edit documentation files with precise changes
- Explain what was changed and why
- Verify the update matches working implementation
### Step 6: Report Findings
- Summarize what was checked
- List any discrepancies found
- Describe what was updated (if anything)
- Note anything that might need human review
## Example Usage
### Example 1: Check specific documentation
```
User: "Check if doc/ideavim-mappings.md is in sync with the code"
You should:
0. FIRST: Find working implementation (grep for @VimPlugin or similar)
1. Read at least one working example to establish ground truth
2. Read doc/ideavim-mappings.md
3. Extract ALL code examples and function signatures
4. For EACH signature: verify it exists in API and matches working code
5. Compare patterns with working implementation
6. Update docs if any discrepancies found
```
### Example 2: Code changes → docs
```
User: "I changed MappingScope.kt, check if docs need updating"
You should:
0. FIRST: Check git log and recent commits for MappingScope
1. Run: git log --oneline -10 -- '**/MappingScope*'
2. Check for removal commits: git log --grep="remove" --oneline -5
3. If recent commits removed code: git show [commit] to see what was deleted
4. Find working implementation that uses MappingScope correctly
5. Read MappingScope.kt to understand current API
6. Search docs for references to MappingScope, mapping functions, etc.
7. Extract all code examples from docs
8. Compare each example against working implementation
9. Update docs to match the correct pattern
```
### Example 3: Comprehensive check
```
User: "Check if all documentation in doc/ folder is up to date"
You should:
0. FIRST: Find working implementations as ground truth
1. Check recent git history for breaking changes
2. List files in doc/ folder
3. For each doc file:
- Quick grep for function signatures and parameters
- Compare against API and working implementations
- Identify obvious issues
4. For files with issues: run full Mode A verification
5. Update any that need it
```
## Output Format
Always provide a clear report:
```
## Documentation Sync Report
### Files Checked
- [doc file 1]
- [doc file 2]
- [code file 1]
- [code file 2]
### Discrepancies Found
1. **[Doc file]: [Issue description]**
- Current docs say: [quote]
- Actual code: [description]
- Severity: [Critical/Minor]
- Action: [Updated/No action needed]
### Updates Made
- [File]: [Description of change]
### Notes
- [Any observations or recommendations]
```
## Tools Available
You have access to:
- **Read**: Read any file in the project
- **Edit**: Update documentation files
- **Glob**: Find files by pattern
- **Grep**: Search for text in files
- **Bash**: Run git commands to see recent changes
## Key Lessons Learned
**Most Important Insights:**
1. **Start with working code, not documentation.** The working implementation is your ground truth. Documentation is assumed outdated until proven otherwise.
2. **Deletions matter more than additions.** When code changes, what was REMOVED is more important than what was added. Removed functions/parameters will break documentation examples.
3. **Verify every parameter name.** Don't just check if the function exists - check if parameter names in examples actually exist in the function signature. Named parameters in docs that don't exist in code are a critical bug.
4. **Compare patterns, not just signatures.** A function might exist, but if the documentation shows a different usage pattern than the working implementation, the docs are wrong.
5. **Git history tells the story.** Recent commits with "remove", "deprecate", or "incorrect" in the message are red flags that documentation is likely outdated.
Remember: **Be aggressive in finding issues, conservative in making fixes.** Your goal is to ensure every code example in documentation actually works, not to improve writing style.

View File

@@ -1,207 +0,0 @@
---
name: extensions-api-migration
description: Migrates IdeaVim extensions from the old VimExtensionFacade API to the new @VimPlugin annotation-based API. Use when converting existing extensions to use the new API patterns.
---
# Extensions API Migration
You are an IdeaVim extensions migration specialist. Your job is to help migrate existing IdeaVim extensions from the old API (VimExtensionFacade) to the new API (@VimPlugin annotation).
## Key Locations
- **New API module**: `api/` folder - contains the new plugin API
- Old API: `VimExtensionFacade` in vim-engine
- Extensions location: `src/main/java/com/maddyhome/idea/vim/extension/`
## How to Use the New API
### Getting Access to the API
To get access to the new API, call the `api()` function from `com.maddyhome.idea.vim.extension.api`:
```kotlin
val api = api()
```
Obtain the API at the start of the `init()` method - this is the entry point for all further work.
### Registering Text Objects
Use `api.textObjects { }` to register text objects:
```kotlin
// From VimIndentObject.kt
override fun init() {
val api = api()
api.textObjects {
register("ai") { _ -> findIndentRange(includeAbove = true, includeBelow = false) }
register("aI") { _ -> findIndentRange(includeAbove = true, includeBelow = true) }
register("ii") { _ -> findIndentRange(includeAbove = false, includeBelow = false) }
}
}
```
### Registering Mappings
Use `api.mappings { }` to register mappings:
```kotlin
// From ParagraphMotion.kt
override fun init() {
val api = api()
api.mappings {
nmapPluginAction("}", "<Plug>(ParagraphNextMotion)", keepDefaultMapping = true) {
moveParagraph(1)
}
nmapPluginAction("{", "<Plug>(ParagraphPrevMotion)", keepDefaultMapping = true) {
moveParagraph(-1)
}
xmapPluginAction("}", "<Plug>(ParagraphNextMotion)", keepDefaultMapping = true) {
moveParagraph(1)
}
// ... operator-pending mode mappings with omapPluginAction
}
}
```
### Defining Helper Functions
The lambdas in text object and mapping registrations typically call helper functions. Define these functions with `VimApi` as a receiver - this makes the API available inside:
```kotlin
// From VimIndentObject.kt
private fun VimApi.findIndentRange(includeAbove: Boolean, includeBelow: Boolean): TextObjectRange? {
val charSequence = editor { read { text } }
val caretOffset = editor { read { withPrimaryCaret { offset } } }
// ... implementation using API
}
// From ParagraphMotion.kt
internal fun VimApi.moveParagraph(direction: Int) {
val count = getVariable<Int>("v:count1") ?: 1
editor {
change {
forEachCaret {
val newOffset = getNextParagraphBoundOffset(actualCount, includeWhitespaceLines = true)
if (newOffset != null) {
updateCaret(offset = newOffset)
}
}
}
}
}
```
### API Features
<!-- Fill in additional API features here -->
## How to Migrate Existing Extensions
### What Stays the Same
- The extension **still inherits VimExtensionFacade** - this does not change
- The extension **still registers in the XML file** - this does not change
### Migration Steps
#### Step 1: Ensure Test Coverage
Before starting migration, make sure tests exist for the extension:
- Tests should work and have good coverage
- If there aren't enough tests, create more tests first
- Verify tests pass on the existing version of the plugin
#### Step 2: Migrate in Small Steps
- Don't try to handle everything in one run
- Run tests on the plugin (just the single test class to speed up things) after making smaller changes
- This ensures consistency and makes it easier to identify issues
- **Do a separate commit for each small sensible change or migration** unless explicitly told not to
#### Step 3: Migrate Handlers One by One
If the extension has multiple handlers, migrate them one at a time rather than all at once.
#### Step 4: Handler Migration Process
For each handler, follow this approach:
1. **Inject the API**: Add `val api = api()` as the first line inside the `execute` function
2. **Extract to extension function**: Extract the content of the execute function into a separate function outside the `ExtensionHandler` class. The new function should:
- Have `VimApi` as a receiver
- Use the api that was obtained before
- Keep the extraction as-is (no changes to logic yet)
3. **Verify tests pass**: Run tests to ensure the extraction didn't break anything
4. **Migrate function content**: Now start migrating the content of the extracted function to use the new API
5. **Verify tests pass again**: Run tests after each significant change
6. **Update registration**: Finally, change the registration of shortcuts from the existing approach to `api.mappings { }` where you call the newly created function
#### Example Migration Flow
```kotlin
// BEFORE: Old style handler
class MyHandler : ExtensionHandler {
override fun execute(editor: VimEditor, context: ExecutionContext, operatorArguments: OperatorArguments) {
// ... implementation
}
}
// STEP 1: Inject API
class MyHandler : ExtensionHandler {
override fun execute(editor: VimEditor, context: ExecutionContext, operatorArguments: OperatorArguments) {
val api = api()
// ... implementation
}
}
// STEP 2: Extract to extension function (as-is)
class MyHandler : ExtensionHandler {
override fun execute(editor: VimEditor, context: ExecutionContext, operatorArguments: OperatorArguments) {
val api = api()
api.doMyAction(/* pass needed params */)
}
}
private fun VimApi.doMyAction(/* params */) {
// ... same implementation, moved here
}
// STEP 3-5: Migrate content to new API inside doMyAction()
// STEP 6: Update registration to use api.mappings { }
override fun init() {
val api = api()
api.mappings {
nmapPluginAction("key", "<Plug>(MyAction)") {
doMyAction()
}
}
}
// Now MyHandler class can be removed
```
#### Handling Complicated Plugins
For more complicated plugins, additional steps may be required.
For example, there might be a separate large class that performs calculations. However, this class may not be usable as-is because it takes a `Document` - a class that is no longer directly available through the new API.
In this case, perform a **pre-refactoring step**: update this class to remove the `Document` dependency before starting the main migration. For instance, change it to accept `CharSequence` instead, which is available via the new API.
#### Final Verification: Check for Old API Usage
After migration, verify that no old API is used by checking imports for `com.maddyhome`.
**Allowed imports** (these are still required):
- `com.maddyhome.idea.vim.extension.VimExtension`
- `com.maddyhome.idea.vim.extension.api`
Any other `com.maddyhome` imports indicate incomplete migration.

View File

@@ -1,47 +0,0 @@
---
name: git-workflow
description: IdeaVim git workflow conventions covering commits, branches, PRs, and CI. Use when creating commits, managing branches, creating pull requests, reviewing git history, or any git-related activity in the IdeaVim project.
---
# Git Workflow
## Branching
- **Master** is the trunk and MUST always be in a "ready to release" state
- Use **feature branches** for development work
- Naming: `VIM-XXXX/short-description` (e.g., `VIM-3948/editor`)
- Rebase to master frequently to avoid large conflicts
- Small, isolated changes (bug fixes, minor tweaks) MAY go directly to master
- Unfinished changes MAY be committed to master only if they do NOT break functionality
- Use **rebase** for integration, not merge commits (linear history)
## Commits
**Standard format:**
```
VIM-XXXX Description of the change
```
- Start with the YouTrack ticket ID when the change relates to a ticket
- Example: `VIM-3948 Traverse vertical panes in ConfigurableEditor`
**Auto-closing format** (moves YouTrack ticket to "Ready To Release"):
```
fix(VIM-XXXX): Description of the fix
```
**Content rules:**
- Each commit MUST contain a single, focused, meaningful change
- MUST NOT include unrelated changes (formatting, unrelated refactoring)
- Include appropriate tests with behavioral changes
## Pull Requests
- PRs target `master`
- CI runs standard tests automatically (`./gradlew test -x :tests:property-tests:test -x :tests:long-running-tests:test`)
- PRs from external contributors are listed in the changelog under "Merged PRs"
## Issue Tracking
- Use **YouTrack** (not GitHub Issues) - tickets are `VIM-XXXX`
- URL: https://youtrack.jetbrains.com/issues/VIM

View File

@@ -1,125 +0,0 @@
---
name: issues-deduplication
description: Handles deduplication of YouTrack issues. Use when cleaning up duplicate issues, consolidating related bug reports, or organizing issue tracker.
---
# Issues Deduplication
You are an issue tracker specialist for the IdeaVim project. Your job is to identify and properly handle duplicate issues in YouTrack.
## Core Principles
### 1. Choosing Which Issue to Keep Open
**Default rule**: The older issue is typically kept open, and newer issues are marked as duplicates.
**Exception - Activity trumps age**: If a newer issue has significantly more engagement (comments, votes, watchers), keep the newer one open and mark the older one as duplicate. Consider:
- Number of comments
- Number of votes/thumbs-up
- Number of watchers
- Quality of discussion and information
### 2. Never Duplicate Issues with Customer Tags
**IMPORTANT**: Do not mark an issue as duplicate if it has a customer-related tag:
- Tags like `Customer:XXX`
- Company name tags like `Uber`, `Google`, `Meta`, etc.
- Any tag indicating a specific customer reported or is affected by the issue
These issues need individual tracking for customer relationship purposes.
### 3. Closed Issue Warning
**CRITICAL**: Be very careful about duplicating into a closed issue!
Before marking issues as duplicates of a closed issue, verify:
- Is the closed issue actually fixed?
- Does the fix apply to all the duplicate reports?
- Are the newer reports potentially about a regression or different manifestation?
**If the problem is still occurring** (based on recent reports), do NOT duplicate into a closed issue. Instead:
- Reopen the closed issue, OR
- Keep one of the open issues as the primary and duplicate into that
Duplicating active issues into a wrongly-closed issue will mark all related issues as "resolved" and lose track of an unresolved problem.
### 4. Consolidate to a Single Issue
When multiple issues are duplicates of each other (e.g., issues 1, 2, 3, 4, 5):
- **DO**: Mark 2, 3, 4, 5 as duplicates of 1 (star topology)
- **DON'T**: Create chains like 2→1, 3→2, 4→3, 5→4
This makes it easier to track all related reports from a single issue.
### 5. Preserve Unique Information
Before marking an issue as duplicate:
1. Review the issue for unique information not present in the target issue
2. If valuable info exists (reproduction steps, logs, environment details, workarounds):
- Add a comment to the target issue summarizing the unique info
- Or update the target issue's description if the info is significant
3. Then mark as duplicate
## Process
### Step 1: Gather Issue Details
For each candidate issue, collect:
- Issue ID and summary
- Creation date
- Number of comments
- Number of votes
- Tags (especially customer tags)
- Current state (Open, Closed, etc.)
- Key details from description
### Step 2: Group Duplicates
Identify which issues are truly duplicates vs. related-but-different issues.
### Step 3: Select Primary Issue
Based on the rules above, select which issue should be the primary (kept open).
### Step 4: Check for Unique Information
Review each duplicate for information not in the primary issue.
### Step 5: Transfer Information
Add comments or update the primary issue with any unique valuable information.
### Step 6: Mark Duplicates
Use YouTrack to link issues as duplicates:
- Add "duplicates" link from duplicate → primary
- Update the issue state to "Duplicate"
### Step 7: Leave a Courteous Comment
After marking an issue as duplicate, leave a comment on the duplicated issue to:
- Inform the reporter about the merge
- Direct them to the primary issue for updates
- Thank them for their contribution
Example comment:
> This issue has been merged into VIM-XXXX for easier tracking. Please follow that issue for updates. Thank you for your contribution!
This maintains good relationships with reporters and ensures they stay informed.
## YouTrack Operations
### Link as Duplicate
Use `mcp__YouTrack__link_issues` with:
- `issueId`: The duplicate issue
- `targetIssueId`: The primary issue to duplicate into
- `linkName`: "duplicates"
### Add Comment
Use `mcp__YouTrack__add_issue_comment` to transfer unique information.
### Update Issue
Use `mcp__YouTrack__update_issue` to update description if needed.
## Example Decision Matrix
| Scenario | Action |
|----------------------------------------------------------------------------------|------------------------------------------------------------|
| Old issue (2022), new issue (2024) with same problem, similar activity | Duplicate new → old |
| Old issue (2022) with 2 comments, new issue (2024) with 15 comments and 10 votes | Duplicate old → new |
| Issue has `Customer:Acme` tag | Never mark as duplicate |
| Old issue closed as "Fixed", new reports say problem still exists | Keep new issue open, investigate if regression |
| 5 issues about same bug | Pick best one as primary, duplicate all 4 others → primary |

View File

@@ -1,279 +0,0 @@
---
name: tests-maintenance
description: Maintains IdeaVim test suite quality. Reviews disabled tests, ensures Neovim annotations are documented, and improves test readability. Use for periodic test maintenance.
---
# Tests Maintenance Skill
You are a test maintenance specialist for the IdeaVim project. Your job is to keep the test suite healthy by reviewing test quality, checking disabled tests, and ensuring proper documentation of test exclusions.
## Scope
**DO:**
- Review test quality and readability
- Check if disabled tests can be re-enabled
- Ensure Neovim test exclusions are well-documented
- Improve test content (replace meaningless strings)
**DON'T:**
- Fix bugs in source code
- Implement new features
- Make changes to production code
## Change Granularity (Important for CI/GitHub Actions)
**One logical change per run.** This ensures granular, reviewable Pull Requests.
**Rules:**
1. **One test per run**: Focus on a single test file or test method
2. **One logical change per test**: Don't combine unrelated fixes in the same PR
3. **Group only if identical**: Multiple `@TestWithoutNeovim` annotations can be updated together ONLY if they:
- Have the same skip reason
- Require the same fix (e.g., all need the same description added)
- Are part of the same logical issue
**Examples:**
**Good** (pick ONE of these per PR):
- Update one `DIFFERENT``IDEAVIM_API_USED` with description
- Add descriptions to 3 tests that all use `SCROLL` reason (same fix pattern)
- Re-enable one `@Disabled` test that now passes
**Bad** (too many changes):
- Update `DIFFERENT` to `SCROLL` in one test AND `PLUGIN` in another (different reasons)
- Fix test content AND update annotations in the same PR
- Re-enable multiple unrelated disabled tests
**Why this matters:**
- Each PR can be reviewed independently
- Easy to revert if something breaks
- Clear git history of what changed and why
## How to Select Tests
Each run should focus on a small subset. Use one of these strategies:
```bash
# Get a random test file
find . -path "*/test/*" -name "*Test*.kt" -not -path "*/build/*" | shuf -n 1
# Or focus on specific areas:
# - src/test/java/org/jetbrains/plugins/ideavim/action/
# - src/test/java/org/jetbrains/plugins/ideavim/ex/
# - src/test/java/org/jetbrains/plugins/ideavim/extension/
# - tests/java-tests/src/test/kotlin/
```
## What to Check
### 1. Disabled Tests (@Disabled)
Find disabled tests and check if they can be re-enabled:
```bash
# Find all @Disabled tests
grep -rn "@Disabled" --include="*.kt" src/test tests/
```
For each disabled test:
1. **Try running it**: `./gradlew test --tests "ClassName.testMethod"`
2. **If it passes**: Investigate what changed, re-enable with explanation
3. **If it fails**: Ensure reason is documented in @Disabled annotation
4. **If obsolete**: Remove tests for features that no longer exist
### 2. Neovim Test Exclusions (@TestWithoutNeovim)
Tests excluded from Neovim verification must have clear documentation.
```bash
# Find TestWithoutNeovim usages
grep -rn "@TestWithoutNeovim" --include="*.kt" src/test tests/
# Find those without description (needs fixing)
grep -rn "@TestWithoutNeovim(SkipNeovimReason\.[A-Z_]*)" --include="*.kt" src/test
```
#### SkipNeovimReason Categories
| Reason | When to Use |
|--------|-------------|
| `SEE_DESCRIPTION` | Case-specific difference that doesn't fit other categories (description required) |
| `PLUGIN` | IdeaVim extension-specific behavior (surround, commentary, etc.) |
| `INLAYS` | Test involves IntelliJ inlays (not present in Vim) |
| `OPTION` | IdeaVim-specific option behavior |
| `UNCLEAR` | **DEPRECATED** - Investigate and use a more specific reason |
| `NON_ASCII` | Non-ASCII character handling differs |
| `MAPPING` | Mapping-specific test |
| `SELECT_MODE` | Vim's select mode |
| `VISUAL_BLOCK_MODE` | Visual block mode edge cases |
| `DIFFERENT` | **DEPRECATED** - Use a more specific reason instead |
| `NOT_VIM_TESTING` | Test doesn't verify Vim behavior (IDE integration, etc.) |
| `SHOW_CMD` | :showcmd related differences |
| `SCROLL` | Scrolling behavior (viewport differs) |
| `TEMPLATES` | IntelliJ live templates |
| `EDITOR_MODIFICATION` | Editor-specific modifications |
| `CMD` | Command-line mode differences |
| `ACTION_COMMAND` | `:action` command (IDE-specific) |
| `FOLDING` | Code folding (IDE feature) |
| `TABS` | Tab/window management differences |
| `PLUGIN_ERROR` | Plugin execution error handling |
| `VIM_SCRIPT` | VimScript implementation differences |
| `GUARDED_BLOCKS` | IDE guarded/read-only blocks |
| `CTRL_CODES` | Control code handling |
| `BUG_IN_NEOVIM` | Known Neovim bug (not IdeaVim issue) |
| `PSI` | IntelliJ PSI/code intelligence features |
| `IDEAVIM_API_USED` | Test uses IdeaVim API that prevents Neovim state sync |
| `IDEAVIM_WORKS_INTENTIONALLY_DIFFERENT` | IdeaVim intentionally deviates from Neovim for better UX or IntelliJ integration |
| `INTELLIJ_PLATFORM_INHERITED_DIFFERENCE` | Behavior difference inherited from IntelliJ Platform constraints |
**Requirements:**
- Add `description` parameter for non-obvious cases
- Check if the reason is still valid
- Consider if test could be split: part that works with Neovim, part that doesn't
**Special requirement for `IDEAVIM_WORKS_INTENTIONALLY_DIFFERENT`:**
- **ONLY use when you find clear evidence** of intentional deviation:
- Explicit commit messages explaining the intentional difference
- Code comments documenting why IdeaVim deviates from Vim/Neovim
- Absolutely obvious cases (e.g., IntelliJ-specific features not in Neovim)
- **DO NOT use based on guesswork or assumptions**
- If uncertain, use `DIFFERENT` or `UNCLEAR` instead and investigate git history/comments
- The `description` parameter is **mandatory** and must explain what exactly differs and why
**Special requirement for `INTELLIJ_PLATFORM_INHERITED_DIFFERENCE`:**
- Use when behavior difference is due to IntelliJ Platform's underlying implementation
- Common cases include:
- Empty buffer handling (Platform editors can be empty, Neovim buffers always have a newline)
- Position/offset calculations for newline characters
- Line/column indexing differences
- The `description` parameter is **mandatory** and must explain:
- What Platform behavior causes the difference
- How it manifests in the test
- Evidence can be found in Platform API documentation, IdeaVim code comments, or obvious Platform limitations
**Special requirement for `SEE_DESCRIPTION`:**
- Use as a last resort when the difference doesn't fit any standard category
- The `description` parameter is **mandatory** and must provide a clear, specific explanation
- Use sparingly - if multiple tests share similar reasons, consider creating a new dedicated reason
- Always check existing reasons first before using this catch-all
**Handling `DIFFERENT` and `UNCLEAR` (DEPRECATED):**
Both `DIFFERENT` and `UNCLEAR` reasons are deprecated because they're too vague. When you encounter a test with either of these reasons, follow this process:
1. **First, try removing the annotation and running with Neovim:**
```bash
# Comment out or remove @TestWithoutNeovim, then run:
./gradlew test -Dnvim --tests "ClassName.testMethodName"
```
**IMPORTANT:** Verify the output contains `NEOVIM TESTING ENABLED` to confirm Neovim testing is active.
If this message is not present, the test ran without Neovim verification.
2. **If the test passes with Neovim:**
- The annotation is outdated and should be removed
- IdeaVim and Neovim now behave identically for this case
3. **If the test fails with Neovim:**
- Analyze the failure to understand WHY the behavior differs
- Replace `DIFFERENT` with a more specific reason:
- `IDEAVIM_API_USED` - if test uses VimPlugin.* or injector.* APIs directly
- `IDEAVIM_WORKS_INTENTIONALLY_DIFFERENT` - if IdeaVim intentionally deviates (need evidence)
- `INTELLIJ_PLATFORM_INHERITED_DIFFERENCE` - if difference comes from Platform constraints
- `SEE_DESCRIPTION` - for unique cases that don't fit other categories (description required)
- Or another appropriate reason from the table above
- Always add a `description` parameter explaining the specific difference
### 3. Test Quality & Readability
**Meaningful test content**: Avoid senseless text. Look for:
```bash
grep -rn "asdf\|qwerty\|xxxxx\|aaaaa\|dhjkw" --include="*.kt" src/test tests/
```
Replace with:
- Actual code snippets relevant to the test
- Lorem Ipsum template from CONTRIBUTING.md
- Realistic text demonstrating the feature
**Test naming**: Names should explain what's being tested.
### 4. @VimBehaviorDiffers Annotation
Tests marked with this document intentional differences from Vim:
```kotlin
@VimBehaviorDiffers(
originalVimAfter = "expected vim result",
description = "why IdeaVim differs",
shouldBeFixed = true/false
)
```
Check:
- Is the difference still valid?
- If `shouldBeFixed = true`, is there a YouTrack issue?
- Can behavior now be aligned with Vim?
## Making Changes
### When to Change
**DO fix:**
- Unclear or missing test descriptions
- Senseless test content
- Disabled tests that now pass
- Incorrect `@TestWithoutNeovim` reasons
- Missing `description` on annotations
**DON'T:**
- Fix source code bugs
- Implement missing features
- Major refactoring without clear benefit
### Commit Messages
```
tests: Re-enable DeleteMotionTest after fix in #1234
The test was disabled due to a caret positioning bug that was
fixed in commit abc123. Verified the test passes consistently.
```
```
tests: Improve test content readability in ChangeActionTest
Replace meaningless "asdfgh" strings with realistic code snippets
that better demonstrate the change operation behavior.
```
```
tests: Document @TestWithoutNeovim reasons in ScrollTest
Added description parameter to clarify why scroll tests
are excluded from Neovim verification (viewport behavior differs).
```
## Commands Reference
```bash
# Run specific test
./gradlew test --tests "ClassName.testMethod"
# Run all tests in a class
./gradlew test --tests "ClassName"
# Run tests with Neovim verification (look for "NEOVIM TESTING ENABLED" in output)
./gradlew test -Dnvim --tests "ClassName"
# Standard test suite (excludes property and long-running)
./gradlew test -x :tests:property-tests:test -x :tests:long-running-tests:test
```
## Output
When run via workflow, if changes are made, create a PR with:
- **Title**: "Tests maintenance: <brief description>"
- **Body**: What was checked, issues found, changes made
If no changes needed, report what was checked and that everything is fine.

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
* text=auto eol=lf

View File

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

4
.gitignore vendored
View File

@@ -26,6 +26,10 @@
.teamcity/target .teamcity/target
.teamcity/*.iml .teamcity/*.iml
# Generated by gradle task "generateGrammarSource"
vim-engine/src/main/java/com/maddyhome/idea/vim/parser/generated
vim-engine/src/main/java/com/maddyhome/idea/vim/regexp/parser/generated
# Created by github automation # Created by github automation
settings.xml settings.xml

1
.idea/gradle.xml generated
View File

@@ -5,7 +5,6 @@
<option name="linkedExternalProjectsSettings"> <option name="linkedExternalProjectsSettings">
<GradleProjectSettings> <GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$" /> <option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#JAVA_HOME" />
<option name="modules"> <option name="modules">
<set> <set>
<option value="$PROJECT_DIR$" /> <option value="$PROJECT_DIR$" />

2
.idea/misc.xml generated
View File

@@ -18,5 +18,5 @@
</list> </list>
</option> </option>
</component> </component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="corretto-21" project-jdk-type="JavaSDK" /> <component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK" />
</project> </project>

View File

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

View File

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

View File

@@ -1,39 +0,0 @@
# CLAUDE.md
Guidance for Claude Code when working with IdeaVim.
## Quick Reference
Essential commands:
- `./gradlew runIde` - Start dev IntelliJ with IdeaVim
- `./gradlew test -x :tests:property-tests:test -x :tests:long-running-tests:test` - Run standard tests
Avoid running all tests, this takes too long. It's preferred to run specific test.
When running gradle tasks, use `--console=plain` for cleaner output without progress bars.
See CONTRIBUTING.md for architecture details and a complete command list.
## IdeaVim-Specific Notes
- Property tests can be flaky - verify if failures relate to your changes
- Use `<Action>` in mappings, not `:action`
- Config file: `~/.ideavimrc` (XDG supported)
- Goal: Match Vim functionality and architecture
## Issue Tracking
This project uses **YouTrack** for issue tracking, NOT GitHub Issues.
- Tickets are prefixed with `VIM-` (e.g., VIM-1234)
- YouTrack URL: https://youtrack.jetbrains.com/issues/VIM
- `gh issue` commands will NOT work
## Additional Documentation
- Changelog maintenance: Handled by the `changelog` skill (auto-detected when updating changelog)
## Active Technologies
- Kotlin (JVM 21) + IntelliJ Platform SDK, IdeaVim vim-engine (001-api-layer)
## Recent Changes
- 001-api-layer: Added Kotlin (JVM 21) + IntelliJ Platform SDK, IdeaVim vim-engine

View File

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

View File

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

View File

@@ -27,11 +27,11 @@ buildscript {
classpath("org.eclipse.jgit:org.eclipse.jgit.ssh.apache:7.6.0.202603022253-r") classpath("org.eclipse.jgit:org.eclipse.jgit.ssh.apache:7.6.0.202603022253-r")
classpath("org.kohsuke:github-api:1.305") classpath("org.kohsuke:github-api:1.305")
classpath("io.ktor:ktor-client-core:3.4.2") classpath("io.ktor:ktor-client-core:3.4.3")
classpath("io.ktor:ktor-client-cio:3.4.2") classpath("io.ktor:ktor-client-cio:3.4.3")
classpath("io.ktor:ktor-client-auth:3.4.2") classpath("io.ktor:ktor-client-auth:3.4.3")
classpath("io.ktor:ktor-client-content-negotiation:3.4.2") classpath("io.ktor:ktor-client-content-negotiation:3.4.3")
classpath("io.ktor:ktor-serialization-kotlinx-json:3.4.2") classpath("io.ktor:ktor-serialization-kotlinx-json:3.4.3")
// This comes from the changelog plugin // This comes from the changelog plugin
// classpath("org.jetbrains:markdown:0.3.1") // classpath("org.jetbrains:markdown:0.3.1")
@@ -113,6 +113,8 @@ dependencies {
testFramework(TestFrameworkType.Platform) testFramework(TestFrameworkType.Platform)
testFramework(TestFrameworkType.JUnit5) testFramework(TestFrameworkType.JUnit5)
compatiblePlugin("com.intellij.classic.ui")
pluginModule(runtimeOnly(project(":modules:ideavim-common"))) pluginModule(runtimeOnly(project(":modules:ideavim-common")))
pluginModule(runtimeOnly(project(":modules:ideavim-frontend"))) pluginModule(runtimeOnly(project(":modules:ideavim-frontend")))
pluginModule(runtimeOnly(project(":modules:ideavim-backend"))) pluginModule(runtimeOnly(project(":modules:ideavim-backend")))
@@ -208,6 +210,7 @@ tasks {
// a custom task (see below) // a custom task (see below)
runIde { runIde {
systemProperty("octopus.handler", System.getProperty("octopus.handler") ?: true) systemProperty("octopus.handler", System.getProperty("octopus.handler") ?: true)
systemProperty("idea.trust.all.projects", "true")
} }
// Uncomment to run the plugin in a custom IDE, rather than the IDE specified as a compile target in dependencies // Uncomment to run the plugin in a custom IDE, rather than the IDE specified as a compile target in dependencies
@@ -225,7 +228,7 @@ tasks {
val runPycharm by intellijPlatformTesting.runIde.registering { val runPycharm by intellijPlatformTesting.runIde.registering {
type = IntelliJPlatformType.PyCharmProfessional type = IntelliJPlatformType.PyCharmProfessional
version = "2025.3.2" version = "2026.1"
task { task {
systemProperty("octopus.handler", System.getProperty("octopus.handler") ?: true) systemProperty("octopus.handler", System.getProperty("octopus.handler") ?: true)
} }
@@ -241,13 +244,14 @@ tasks {
val runClion by intellijPlatformTesting.runIde.registering { val runClion by intellijPlatformTesting.runIde.registering {
type = IntelliJPlatformType.CLion type = IntelliJPlatformType.CLion
version = "2025.3.2" version = "2026.1"
task { task {
systemProperty("octopus.handler", System.getProperty("octopus.handler") ?: true) systemProperty("octopus.handler", System.getProperty("octopus.handler") ?: true)
} }
} }
val runIdeForUiTests by intellijPlatformTesting.runIde.registering { val runIdeForUiTests by intellijPlatformTesting.runIde.registering {
version = "2026.1"
task { task {
jvmArgumentProviders += CommandLineArgumentProvider { jvmArgumentProviders += CommandLineArgumentProvider {
listOf( listOf(
@@ -310,7 +314,7 @@ tasks {
} }
val runPycharmSplitMode by intellijPlatformTesting.runIde.registering { val runPycharmSplitMode by intellijPlatformTesting.runIde.registering {
type = IntelliJPlatformType.PyCharmProfessional type = IntelliJPlatformType.PyCharmProfessional
version = "2025.3.2" version = "2026.1"
splitMode = true splitMode = true
splitModeTarget = SplitModeAware.SplitModeTarget.BOTH splitModeTarget = SplitModeAware.SplitModeTarget.BOTH
@@ -359,6 +363,45 @@ tasks {
} }
} }
val runPycharmSplitModeDebugFrontend by intellijPlatformTesting.runIde.registering {
type = IntelliJPlatformType.PyCharmProfessional
version = "2026.1"
splitMode = true
splitModeTarget = SplitModeAware.SplitModeTarget.BOTH
plugins {
plugin("AceJump", "3.8.22")
plugin("org.jetbrains.IdeaVim-EasyMotion", "1.16")
}
prepareSandboxTask {
val sandboxDir = project.layout.buildDirectory.dir("idea-sandbox").map { it.asFile }
doLast {
val debugLine = "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5006"
val vmoptions = sandboxDir.get().walkTopDown()
.filter { it.name == "jetbrains_client64.vmoptions" && it.path.contains("runPycharmSplitModeDebugFrontend") }
.firstOrNull()
?: sandboxDir.get().walkTopDown()
.filter { it.name == "jetbrains_client64.vmoptions" }
.firstOrNull()
if (vmoptions != null) {
val content = vmoptions.readText()
if (debugLine !in content) {
vmoptions.appendText("\n$debugLine\n")
logger.lifecycle("Patched frontend vmoptions with JDWP debug agent: ${vmoptions.absolutePath}")
}
logger.lifecycle("Connect a Remote JVM Debug configuration to localhost:5006")
} else {
logger.warn(
"Could not find jetbrains_client64.vmoptions in sandbox. " +
"Run `./gradlew runPycharmSplitMode` once first to populate the sandbox, then use this task."
)
}
}
}
}
val testIdeSplitMode by intellijPlatformTesting.testIde.registering { val testIdeSplitMode by intellijPlatformTesting.testIde.registering {
splitMode = true splitMode = true
splitModeTarget = SplitModeAware.SplitModeTarget.BOTH splitModeTarget = SplitModeAware.SplitModeTarget.BOTH
@@ -385,11 +428,6 @@ tasks {
} }
}) })
} }
buildPlugin {
dependsOn(sourcesJar)
from(sourcesJar) { into("lib/src") }
}
} }
java { java {
@@ -436,6 +474,8 @@ intellijPlatform {
changeNotes.set( changeNotes.set(
""" """
<b>Features:</b><br> <b>Features:</b><br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-1693">VIM-1693</a> Added <code>:autocmd</code> command - run Ex commands on editor events such as <code>BufRead</code>, <code>BufWrite</code>, <code>BufEnter</code>, <code>BufLeave</code>, <code>InsertEnter</code>, <code>InsertLeave</code>, <code>WinEnter</code>, <code>WinLeave</code>, <code>FocusGained</code>, <code>FocusLost</code>, and <code>FileType</code>; supports <code>augroup</code> and file pattern matching (e.g., <code>autocmd BufWritePre *.py echo "saving python"</code>)<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-268">VIM-268</a> Added file name completion in ex commands - press <code>&lt;Tab&gt;</code>/<code>&lt;S-Tab&gt;</code> to cycle through file matches in <code>:edit</code>, <code>:split</code>, <code>:vsplit</code>, <code>:write</code>, <code>:read</code>, <code>:source</code>, and <code>:find</code> commands; use arrow keys to navigate the completion panel<br>
* New VimScript functions: <code>add()</code>, <code>call()</code>, <code>extend()</code>, <code>extendnew()</code>, <code>filter()</code>, <code>flatten()</code>, <code>flattennew()</code>, <code>foreach()</code>, <code>has_key()</code>, <code>indexof()</code>, <code>insert()</code>, <code>items()</code>, <code>keys()</code>, <code>map()</code>, <code>mapnew()</code>, <code>reduce()</code>, <code>remove()</code>, <code>slice()</code>, <code>sort()</code>, <code>uniq()</code>, <code>values()</code><br> * New VimScript functions: <code>add()</code>, <code>call()</code>, <code>extend()</code>, <code>extendnew()</code>, <code>filter()</code>, <code>flatten()</code>, <code>flattennew()</code>, <code>foreach()</code>, <code>has_key()</code>, <code>indexof()</code>, <code>insert()</code>, <code>items()</code>, <code>keys()</code>, <code>map()</code>, <code>mapnew()</code>, <code>reduce()</code>, <code>remove()</code>, <code>slice()</code>, <code>sort()</code>, <code>uniq()</code>, <code>values()</code><br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-1595">VIM-1595</a> Added support for <code>:read</code> command - insert file content below current line (e.g., <code>:read file.txt</code>, <code>0read file.txt</code>)<br> * <a href="https://youtrack.jetbrains.com/issue/VIM-1595">VIM-1595</a> Added support for <code>:read</code> command - insert file content below current line (e.g., <code>:read file.txt</code>, <code>0read file.txt</code>)<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-1595">VIM-1595</a> Added support for <code>:read!</code> command - insert shell command output below current line (e.g., <code>:read! echo "hello"</code>)<br> * <a href="https://youtrack.jetbrains.com/issue/VIM-1595">VIM-1595</a> Added support for <code>:read!</code> command - insert shell command output below current line (e.g., <code>:read! echo "hello"</code>)<br>
@@ -444,8 +484,13 @@ intellijPlatform {
* <a href="https://youtrack.jetbrains.com/issue/VIM-566">VIM-566</a> Added support for <code>zm</code> command - decrease fold level to hide more folds<br> * <a href="https://youtrack.jetbrains.com/issue/VIM-566">VIM-566</a> Added support for <code>zm</code> command - decrease fold level to hide more folds<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-566">VIM-566</a> Added support for <code>zf</code> command - create fold from selection or motion<br> * <a href="https://youtrack.jetbrains.com/issue/VIM-566">VIM-566</a> Added support for <code>zf</code> command - create fold from selection or motion<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-566">VIM-566</a> Added support for <code>:set foldlevel</code> option - control fold visibility level<br> * <a href="https://youtrack.jetbrains.com/issue/VIM-566">VIM-566</a> Added support for <code>:set foldlevel</code> option - control fold visibility level<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-1158">VIM-1158</a> Added <code>gw</code> command - reformat code like <code>gq</code> but preserving the cursor position<br>
<br> <br>
<b>Fixes:</b><br> <b>Fixes:</b><br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4197">VIM-4197</a> Fixed Vim features (e.g., <code>f</code>, <code>w</code>, text objects) not working in Java files decompiled from Kotlin class files<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4112">VIM-4112</a> Fixed undo after block-visual edit (<code>&lt;C-V&gt;...x</code>, <code>&lt;C-V&gt;...c</code>, <code>&lt;C-V&gt;...I</code>) leaving stray carets in normal mode<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4176">VIM-4176</a> Fixed race condition in single-line output panel that could cause <code>*</code> search wrapping to behave unreliably<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4175">VIM-4175</a> Fixed search "not found" showing previous "Hit ENTER" text alongside the error - panel is now cleared before displaying errors like "E486: Pattern not found"<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4135">VIM-4135</a> Fixed IdeaVim not loading in Rider<br> * <a href="https://youtrack.jetbrains.com/issue/VIM-4135">VIM-4135</a> Fixed IdeaVim not loading in Rider<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4134">VIM-4134</a> Fixed undo in commentary - <code>gcc</code>/<code>gc{motion}</code> changes are now properly grouped as a single undo step<br> * <a href="https://youtrack.jetbrains.com/issue/VIM-4134">VIM-4134</a> Fixed undo in commentary - <code>gcc</code>/<code>gc{motion}</code> changes are now properly grouped as a single undo step<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4134">VIM-4134</a> Fixed <code>=</code> (format/auto-indent) action in split mode<br> * <a href="https://youtrack.jetbrains.com/issue/VIM-4134">VIM-4134</a> Fixed <code>=</code> (format/auto-indent) action in split mode<br>
@@ -455,6 +500,7 @@ intellijPlatform {
* <a href="https://youtrack.jetbrains.com/issue/VIM-4094">VIM-4094</a> Fixed UninitializedPropertyAccessException when loading history<br> * <a href="https://youtrack.jetbrains.com/issue/VIM-4094">VIM-4094</a> Fixed UninitializedPropertyAccessException when loading history<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4016">VIM-4016</a> Fixed <code>:edit</code> command when project has no source roots<br> * <a href="https://youtrack.jetbrains.com/issue/VIM-4016">VIM-4016</a> Fixed <code>:edit</code> command when project has no source roots<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-3948">VIM-3948</a> Improved hint generation visibility checks for better UI component detection<br> * <a href="https://youtrack.jetbrains.com/issue/VIM-3948">VIM-3948</a> Improved hint generation visibility checks for better UI component detection<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4195">VIM-4195</a> Fixed settings not being saved in remote development (split) mode<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-3473">VIM-3473</a> Fixed "Reload .ideavimrc" action in remote development (split) mode - no longer causes File Cache Conflict dialogs<br> * <a href="https://youtrack.jetbrains.com/issue/VIM-3473">VIM-3473</a> Fixed "Reload .ideavimrc" action in remote development (split) mode - no longer causes File Cache Conflict dialogs<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-2821">VIM-2821</a> Fixed undo grouping when repeating text insertion with <code>.</code> in remote development (split mode)<br> * <a href="https://youtrack.jetbrains.com/issue/VIM-2821">VIM-2821</a> Fixed undo grouping when repeating text insertion with <code>.</code> in remote development (split mode)<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-1705">VIM-1705</a> Fixed window-switching commands (e.g., <code>&lt;C-w&gt;h</code>) during macro playback<br> * <a href="https://youtrack.jetbrains.com/issue/VIM-1705">VIM-1705</a> Fixed window-switching commands (e.g., <code>&lt;C-w&gt;h</code>) during macro playback<br>
@@ -470,8 +516,31 @@ intellijPlatform {
* Fixed <code>IndexOutOfBoundsException</code> when using text objects like <code>a)</code> at end of file<br> * Fixed <code>IndexOutOfBoundsException</code> when using text objects like <code>a)</code> at end of file<br>
* Fixed high CPU usage while showing command line<br> * Fixed high CPU usage while showing command line<br>
* Fixed comparison of String and Number in VimScript expressions<br> * Fixed comparison of String and Number in VimScript expressions<br>
* Fixed <code>\/</code>, <code>\?</code>, and <code>\&</code> in Ex command ranges now correctly report E35/E33 errors when no previous search or substitute pattern exists, instead of crashing<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4172">VIM-4172</a> IdeaVim is now disabled in Python Console to prevent key interference<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4113">VIM-4113</a> Fixed Visual mode commands (e.g., <code>:'&lt;,'&gt;sort</code>) failing when run off the Event Dispatch Thread<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-3727">VIM-3727</a> Fixed Enter and arrow keys not working in Python Console in split mode<br>
* Fixed NERDTree navigation (<code>j</code>/<code>k</code>/<code>G</code>/<code>gg</code>/<code>p</code>/<code>&lt;C-J&gt;</code>/<code>&lt;C-K&gt;</code>) poor performance in split mode - navigation now uses Swing actions directly instead of going through backend RPC<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4180">VIM-4180</a> Fixed ReplaceWithRegister plugin's default <code>gr</code>/<code>grr</code> mappings overriding user-defined key mappings<br>
* Fixed <code>IndexOutOfBoundsException</code> when using <code>:command</code> with <code>-nargs</code> option but without a command name<br>
* Fixed spurious beep when pressing <code>&lt;Esc&gt;</code> to cancel register selection in normal mode (after pressing <code>"</code>)<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4202">VIM-4202</a> Fixed <code>&lt;S-Tab&gt;</code> being intercepted by IdeaVim - users can now remap <code>&lt;S-Tab&gt;</code> to other IntelliJ actions<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4202">VIM-4202</a> Fixed <code>gcc</code>/<code>gc{motion}</code> commentary leaving editor in incorrect mode in Rider/CLion split mode<br>
* <a href="https://youtrack.jetbrains.com/issue/VIM-4115">VIM-4115</a> Fixed NullPointerException in <code>CommandKeyConsumer</code> when pressing Esc after disabling and re-enabling IdeaVim with an open command line<br>
<br> <br>
<b>Merged PRs:</b><br> <b>Merged PRs:</b><br>
* <a href="https://github.com/JetBrains/ideavim/pull/1704">1704</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: VIM-4202 Don't intercept all &lt;S-Tab&gt;<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1703">1703</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: VIM-4202 Fix state after commentary action<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1700">1700</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: VIM-4139 Compute nesting depth for fold regions<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1699">1699</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: VIM-4112 collapse restored carets after undo of block-visual edit<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1696">1696</a> by <a href="https://github.com/citizenmatt">citizenmatt</a>: VIM-4197 Fix missing Vim features in Java files decompiled from Kotlin class files<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1695">1695</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: VIM-1693 Implement autocmd<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1690">1690</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: Make nerdtree work without calling backend actions<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1688">1688</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: VIM-4172 Disable ideavim in Python Console<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1687">1687</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: Restore old VimPLugin method signatures<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1685">1685</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: VIM-3727 Fix Python console Enter and arrow keys in split mode<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1548">1548</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: VIM-1158 Add <code>gw</code> to reformat code with preserving the cursor position<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1682">1682</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: VIM-268 Complete file names in edit command<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1632">1632</a> by <a href="https://github.com/chylex">chylex</a>: Fix pumvisible returning opposite result<br> * <a href="https://github.com/JetBrains/ideavim/pull/1632">1632</a> by <a href="https://github.com/chylex">chylex</a>: Fix pumvisible returning opposite result<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1615">1615</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: Fix IndexOutOfBoundsException in findBlock when caret is at end of file<br> * <a href="https://github.com/JetBrains/ideavim/pull/1615">1615</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: Fix IndexOutOfBoundsException in findBlock when caret is at end of file<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1613">1613</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: VIM-3473 Sync ideavim in remdev<br> * <a href="https://github.com/JetBrains/ideavim/pull/1613">1613</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: VIM-3473 Sync ideavim in remdev<br>
@@ -479,19 +548,14 @@ intellijPlatform {
* <a href="https://github.com/JetBrains/ideavim/pull/1585">1585</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: Break in case of maximum recursion depth<br> * <a href="https://github.com/JetBrains/ideavim/pull/1585">1585</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: Break in case of maximum recursion depth<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1414">1414</a> by <a href="https://github.com/citizenmatt">Matt Ellis</a>: Refactor/functions<br> * <a href="https://github.com/JetBrains/ideavim/pull/1414">1414</a> by <a href="https://github.com/citizenmatt">Matt Ellis</a>: Refactor/functions<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1442">1442</a> by <a href="https://github.com/citizenmatt">Matt Ellis</a>: Fix high CPU usage while showing command line<br> * <a href="https://github.com/JetBrains/ideavim/pull/1442">1442</a> by <a href="https://github.com/citizenmatt">Matt Ellis</a>: Fix high CPU usage while showing command line<br>
* <a href="https://github.com/JetBrains/ideavim/pull/1665">1665</a> by <a href="https://github.com/1grzyb1">1grzyb1</a>: Fix visual selection commands failing off-EDT due to nested write-in-read action<br>
<br> <br>
<a href="https://youtrack.jetbrains.com/issues/VIM?q=State:%20Fixed%20Fix%20versions:%20${version.get()}">Changelog</a> <a href="https://youtrack.jetbrains.com/issues/VIM?q=State:%20Fixed%20Fix%20versions:%20${version.get()}">Changelog</a>
""".trimIndent() """.trimIndent()
) )
ideaVersion { ideaVersion {
// Let the Gradle plugin set the since-build version. It defaults to the version of the IDE we're building against sinceBuild.set("253")
// specified as two components, `{branch}.{build}` (e.g., "241.15989"). There is no third component specified.
// The until-build version defaults to `{branch}.*`, but we want to support _all_ future versions, so we set it
// with a null provider (the provider is important).
// By letting the Gradle plugin handle this, the Plugin DevKit IntelliJ plugin cannot help us with the "Usage of
// IntelliJ API not available in older IDEs" inspection. However, since our since-build is the version we compile
// against, we can never get an API that's newer - it would be an unresolved symbol.
untilBuild.set(provider { null }) untilBuild.set(provider { null })
} }
} }

137
doc/autocmd.md Normal file
View File

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

View File

@@ -20,7 +20,7 @@ ideaVersion=2026.1
# Values for type: https://plugins.jetbrains.com/docs/intellij/tools-gradle-intellij-plugin.html#intellij-extension-type # Values for type: https://plugins.jetbrains.com/docs/intellij/tools-gradle-intellij-plugin.html#intellij-extension-type
ideaType=IU ideaType=IU
instrumentPluginCode=true instrumentPluginCode=true
version=SNAPSHOT version=9999.58-chylex
javaVersion=21 javaVersion=21
remoteRobotVersion=0.11.23 remoteRobotVersion=0.11.23
antlrVersion=4.10.1 antlrVersion=4.10.1
@@ -42,7 +42,6 @@ youtrackToken=
# Gradle settings # Gradle settings
org.gradle.jvmargs='-Dfile.encoding=UTF-8' org.gradle.jvmargs='-Dfile.encoding=UTF-8'
org.gradle.configuration-cache=true
org.gradle.caching=true org.gradle.caching=true
# Disable warning from gradle-intellij-plugin. Kotlin stdlib is included as compileOnly, so the warning is unnecessary # Disable warning from gradle-intellij-plugin. Kotlin stdlib is included as compileOnly, so the warning is unnecessary

View File

@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME

View File

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

View File

@@ -0,0 +1,87 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package com.maddyhome.idea.vim.group.file
import com.intellij.openapi.vfs.LocalFileSystem
import com.intellij.openapi.vfs.VirtualFile
import kotlin.io.path.Path
/**
* Resolves a user-typed path prefix into a list of matching file/directory names
* for command-line completion. Directories are suffixed with `/`.
*/
internal object FileCompletionHelper {
fun listMatchingFiles(pathPrefix: String, basePath: String?): List<String> {
val (parentDir, namePrefix) = resolveParentAndPrefix(pathPrefix, basePath)
if (parentDir == null || !parentDir.isDirectory) return emptyList()
return filterAndFormat(parentDir, namePrefix, pathPrefix)
}
private fun filterAndFormat(parentDir: VirtualFile, namePrefix: String, pathPrefix: String): List<String> {
val dirPrefix = pathPrefix.substringBeforeLast('/', "")
return parentDir.children
.filter { it.name.startsWith(namePrefix, ignoreCase = true) }
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name })
.map { formatChild(it, dirPrefix) }
}
private fun formatChild(child: VirtualFile, dirPrefix: String): String {
val name = if (child.isDirectory) child.name + "/" else child.name
if (dirPrefix.isEmpty()) return name
return "$dirPrefix/$name"
}
private fun resolveParentAndPrefix(pathPrefix: String, basePath: String?): Pair<VirtualFile?, String> {
if (pathPrefix.isEmpty()) return resolveProjectRoot(basePath)
if (pathPrefix.startsWith("~/") || pathPrefix.startsWith("~\\")) return resolveHomePath(pathPrefix)
if (Path(pathPrefix).isAbsolute) return resolveAbsolutePath(pathPrefix)
return resolveRelativePath(pathPrefix, basePath)
}
private fun resolveProjectRoot(basePath: String?): Pair<VirtualFile?, String> {
val dir = basePath?.let { LocalFileSystem.getInstance().findFileByNioFile(Path(it)) }
return dir to ""
}
private fun resolveHomePath(pathPrefix: String): Pair<VirtualFile?, String> {
val home = System.getProperty("user.home")
val relativePath = pathPrefix.substring(2)
return splitDirAndPrefix(relativePath) { dirPath ->
LocalFileSystem.getInstance().findFileByNioFile(Path(home, dirPath))
} ?: (LocalFileSystem.getInstance().findFileByNioFile(Path(home)) to relativePath)
}
private fun resolveAbsolutePath(pathPrefix: String): Pair<VirtualFile?, String> {
return splitDirAndPrefix(pathPrefix) { dirPath ->
LocalFileSystem.getInstance().findFileByNioFile(Path(dirPath.ifEmpty { "/" }))
} ?: (null to "")
}
private fun resolveRelativePath(pathPrefix: String, basePath: String?): Pair<VirtualFile?, String> {
val baseDir = basePath?.let { LocalFileSystem.getInstance().findFileByNioFile(Path(it)) }
return splitDirAndPrefix(pathPrefix) { dirPath ->
baseDir?.findFileByRelativePath(dirPath)
} ?: (baseDir to pathPrefix)
}
private fun splitDirAndPrefix(
path: String,
resolveDir: (String) -> VirtualFile?,
): Pair<VirtualFile?, String>? {
val lastSlash = path.lastIndexOf('/')
if (lastSlash < 0) return null
val dirPath = path.substring(0, lastSlash)
val prefix = path.substring(lastSlash + 1)
return resolveDir(dirPath) to prefix
}
}

View File

@@ -162,6 +162,11 @@ internal class FileRemoteApiImpl : FileRemoteApi {
if (first is TextEditor) !first.editor.isDisposed else false if (first is TextEditor) !first.editor.isDisposed else false
} }
override suspend fun listFilesForCompletion(pathPrefix: String, projectId: ProjectId?): List<String> = readAction {
val basePath = projectId?.findProjectOrNull()?.basePath
FileCompletionHelper.listMatchingFiles(pathPrefix, basePath)
}
// ======================== Private helpers ======================== // ======================== Private helpers ========================
private fun findFile(filename: String, project: Project): VirtualFile? { private fun findFile(filename: String, project: Project): VirtualFile? {

View File

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

View File

@@ -40,6 +40,8 @@
topic="com.intellij.openapi.keymap.KeymapManagerListener"/> topic="com.intellij.openapi.keymap.KeymapManagerListener"/>
<listener class="com.maddyhome.idea.vim.handler.IdeaVimCorrectorKeymapChangedListener" <listener class="com.maddyhome.idea.vim.handler.IdeaVimCorrectorKeymapChangedListener"
topic="com.intellij.openapi.keymap.KeymapManagerListener"/> topic="com.intellij.openapi.keymap.KeymapManagerListener"/>
<listener class="com.maddyhome.idea.vim.listener.VimAppActivationListener"
topic="com.intellij.openapi.application.ApplicationActivationListener"/>
</applicationListeners> </applicationListeners>
<projectListeners> <projectListeners>
@@ -169,6 +171,9 @@
<applicationService serviceImplementation="com.maddyhome.idea.vim.group.process.IjProcessGroup" <applicationService serviceImplementation="com.maddyhome.idea.vim.group.process.IjProcessGroup"
serviceInterface="com.maddyhome.idea.vim.api.VimProcessGroup"/> serviceInterface="com.maddyhome.idea.vim.api.VimProcessGroup"/>
<applicationService serviceImplementation="com.maddyhome.idea.vim.autocmd.AutoCmdImpl"
serviceInterface="com.maddyhome.idea.vim.api.AutoCmdService"/>
<platform.rpc.projectRemoteTopicListener <platform.rpc.projectRemoteTopicListener
implementation="com.maddyhome.idea.vim.group.JumpRemoteTopicListener"/> implementation="com.maddyhome.idea.vim.group.JumpRemoteTopicListener"/>
@@ -311,6 +316,7 @@
</group> </group>
<action id="VimFindActionIdAction" class="com.maddyhome.idea.vim.listener.FindActionIdAction"/> <action id="VimFindActionIdAction" class="com.maddyhome.idea.vim.listener.FindActionIdAction"/>
<action id="VimJumpToSource" class="com.intellij.diff.actions.impl.OpenInEditorAction" />
</actions> </actions>
<!-- Frontend vim extensions (editor/text manipulation, no PSI/file-system dependency) --> <!-- Frontend vim extensions (editor/text manipulation, no PSI/file-system dependency) -->

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,67 @@
package com.maddyhome.idea.vim.action.macro
import com.intellij.openapi.command.CommandProcessor
import com.intellij.openapi.command.UndoConfirmationPolicy
import com.intellij.openapi.command.impl.FinishMarkAction
import com.intellij.openapi.command.impl.StartMarkAction
import com.intellij.openapi.fileEditor.TextEditor
import com.intellij.openapi.fileEditor.ex.FileEditorManagerEx
import com.intellij.vim.annotations.CommandOrMotion
import com.intellij.vim.annotations.Mode
import com.maddyhome.idea.vim.KeyHandler
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.Argument
import com.maddyhome.idea.vim.command.Command
import com.maddyhome.idea.vim.command.OperatorArguments
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])
class PlaybackRegisterInOpenFilesAction : VimActionHandler.SingleExecution() {
override val type: Command.Type = Command.Type.OTHER_SELF_SYNCHRONIZED
override val argumentType: Argument.Type = Argument.Type.CHARACTER
private val playbackRegisterAction = PlaybackRegisterAction()
override fun execute(
editor: VimEditor,
context: ExecutionContext,
cmd: Command,
operatorArguments: OperatorArguments,
): Boolean {
val argument = cmd.argument as? Argument.Character ?: return false
val project = editor.ij.project ?: return false
val fileEditorManager = FileEditorManagerEx.getInstanceExIfCreated(project) ?: return false
val register = argument.character.let { if (it == '@') injector.macro.lastRegister else it }
val commandName = "Execute Macro '$register' in All Open Files"
val action = Runnable {
CommandProcessor.getInstance().markCurrentCommandAsGlobal(project)
for (textEditor in fileEditorManager.allEditors.filterIsInstance<TextEditor>()) {
fileEditorManager.openFile(textEditor.file, true)
val editor = textEditor.editor
val vimEditor = editor.vim
vimEditor.mode = com.maddyhome.idea.vim.state.mode.Mode.NORMAL()
KeyHandler.Companion.getInstance().reset(vimEditor)
val startMarkAction = StartMarkAction.start(editor, project, commandName)
playbackRegisterAction.execute(vimEditor, context, cmd, operatorArguments)
FinishMarkAction.finish(project, editor, startMarkAction)
}
}
CommandProcessor.getInstance()
.executeCommand(project, action, commandName, null, UndoConfirmationPolicy.REQUEST_CONFIRMATION)
return true
}
}

View File

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

View File

@@ -0,0 +1,64 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package com.maddyhome.idea.vim.autocmd
import com.maddyhome.idea.vim.api.AutoCmdService
import com.maddyhome.idea.vim.api.VimEditor
import com.maddyhome.idea.vim.api.injector
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
class AutoCmdImpl : AutoCmdService {
private val eventHandlers: MutableMap<AutoCmdEvent, MutableList<AuCommand>> = ConcurrentHashMap()
private var currentAugroup: String? = null
override fun registerEventCommand(command: String, event: AutoCmdEvent, pattern: String) {
eventHandlers.getOrPut(event.canonical) { CopyOnWriteArrayList() }
.add(AuCommand(command, currentAugroup, AutoCmdPattern(pattern)))
}
override fun clearEvents() {
val group = currentAugroup
if (group != null) {
clearAugroup(group)
return
}
eventHandlers.clear()
}
override fun startAugroup(name: String) {
currentAugroup = name
}
override fun endAugroup() {
currentAugroup = null
}
override fun clearAugroup(name: String) {
eventHandlers.values.forEach { handlers ->
handlers.removeAll { it.group == name }
}
}
override fun handleEvent(event: AutoCmdEvent, filePath: String?, editor: VimEditor?) {
val resolvedEditor = editor ?: injector.editorGroup.getSelectedEditor() ?: return
val path = filePath ?: resolvedEditor.getPath()
eventHandlers[event.canonical]?.forEach { auCommand ->
if (auCommand.pattern.matches(path)) {
executeCommand(auCommand.command, resolvedEditor)
}
}
}
private fun executeCommand(command: String, editor: VimEditor) {
val context = injector.executionContextManager.getEditorExecutionContext(editor)
injector.vimscriptExecutor.execute(command, editor, context, skipHistory = true)
}
}

View File

@@ -0,0 +1,89 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package com.maddyhome.idea.vim.autocmd
/**
* Vim-style file pattern for autocmd matching.
*
* Supports glob patterns:
* - `*` matches any characters except path separators
* - `**` matches any characters including path separators
* - `?` matches a single non-separator character
* - `[abc]` matches any character in the set
* - `{foo,bar}` matches "foo" or "bar"
*
* If the pattern contains `/`, it matches against the full path.
* Otherwise, it matches against only the file name.
*/
class AutoCmdPattern(val pattern: String) {
private val matchesAll = pattern == "*"
private val matchesFullPath = '/' in pattern || '\\' in pattern
private val regex: Regex by lazy { toRegex(pattern) }
fun matches(filePath: String?): Boolean {
if (matchesAll) return true
if (filePath == null) return false
val target = if (matchesFullPath) filePath else fileName(filePath)
return regex.matches(target)
}
private fun fileName(path: String): String {
return path.substringAfterLast('/').substringAfterLast('\\')
}
companion object {
private const val REGEX_SPECIAL = "\\+^$|()"
private fun toRegex(pattern: String): Regex {
val result = StringBuilder("^")
var i = 0
var inGroup = false
while (i < pattern.length) {
when (val ch = pattern[i]) {
'*' -> if (isDoubleStar(pattern, i)) {
result.append(".*")
i++
} else {
result.append("[^/\\\\]*")
}
'?' -> result.append("[^/\\\\]")
'.' -> result.append("\\.")
'{' -> {
result.append("(?:"); inGroup = true
}
'}' -> {
result.append(")"); inGroup = false
}
',' -> if (inGroup) result.append("|") else result.append(",")
'[' -> result.append("[")
']' -> result.append("]")
in REGEX_SPECIAL -> {
result.append("\\"); result.append(ch)
}
else -> result.append(ch)
}
i++
}
result.append("$")
return Regex(result.toString())
}
private fun isDoubleStar(pattern: String, i: Int): Boolean {
return i + 1 < pattern.length && pattern[i + 1] == '*'
}
}
}

View File

@@ -0,0 +1,41 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package com.maddyhome.idea.vim.autocmd
import com.intellij.openapi.vfs.VirtualFile
/**
* Maps IntelliJ's [com.intellij.openapi.fileTypes.FileType] name to a Vim-style filetype string
* suitable for matching against a `FileType` autocmd pattern.
*
* Most Vim filetypes are just the lowercase form of the IntelliJ name (e.g. `JAVA` → `java`,
* `Python` → `python`). A small override table covers the common cases where the conventional
* Vim name differs from IntelliJ's, so users can write `autocmd FileType python ...` and have
* it work out of the box.
*/
object IjFileTypeMapping {
private val overrides: Map<String, String> = mapOf(
"PLAIN_TEXT" to "text",
"C++" to "cpp",
"C#" to "cs",
"ObjectiveC" to "objc",
"Shell Script" to "sh",
"JavaScript" to "javascript",
"TypeScript" to "typescript",
"Vue.js" to "vue",
"Handlebars/Mustache" to "handlebars",
"CMakeLists.txt" to "cmake",
)
fun toVimFileType(virtualFile: VirtualFile?): String? {
val name = virtualFile?.fileType?.name ?: return null
return overrides[name] ?: name.lowercase()
}
}

View File

@@ -220,7 +220,7 @@ object VimExtensionFacade {
caret: ImmutableVimCaret, caret: ImmutableVimCaret,
keys: List<KeyStroke?>?, keys: List<KeyStroke?>?,
) { ) {
caret.registerStorage.setKeys(editor, context, register, keys?.filterNotNull() ?: emptyList()) caret.registerStorage.setKeys(register, keys?.filterNotNull() ?: emptyList())
} }
/** Set the current contents of the given register */ /** Set the current contents of the given register */

View File

@@ -21,9 +21,7 @@ import com.intellij.openapi.editor.markup.TextAttributes
import com.intellij.openapi.util.Disposer import com.intellij.openapi.util.Disposer
import com.intellij.util.Alarm import com.intellij.util.Alarm
import com.intellij.util.Alarm.ThreadToUse import com.intellij.util.Alarm.ThreadToUse
import com.jetbrains.rd.util.first
import com.maddyhome.idea.vim.VimPlugin import com.maddyhome.idea.vim.VimPlugin
import com.maddyhome.idea.vim.api.ImmutableVimCaret
import com.maddyhome.idea.vim.api.VimEditor import com.maddyhome.idea.vim.api.VimEditor
import com.maddyhome.idea.vim.api.injector import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.common.ModeChangeListener import com.maddyhome.idea.vim.common.ModeChangeListener
@@ -123,9 +121,9 @@ internal class VimHighlightedYank : VimExtension, VimYankListener, ModeChangeLis
initialised = false initialised = false
} }
override fun yankPerformed(caretToRange: Map<ImmutableVimCaret, TextRange>) { override fun yankPerformed(editor: VimEditor, range: TextRange) {
ensureInitialised() ensureInitialised()
highlightHandler.highlightYankRange(caretToRange) highlightHandler.highlightYankRange(editor.ij, range)
} }
override fun modeChanged(editor: VimEditor, oldMode: Mode) { override fun modeChanged(editor: VimEditor, oldMode: Mode) {
@@ -146,25 +144,22 @@ internal class VimHighlightedYank : VimExtension, VimYankListener, ModeChangeLis
private var lastEditor: Editor? = null private var lastEditor: Editor? = null
private val highlighters = mutableSetOf<RangeHighlighter>() private val highlighters = mutableSetOf<RangeHighlighter>()
fun highlightYankRange(caretToRange: Map<ImmutableVimCaret, TextRange>) { fun highlightYankRange(editor: Editor, range: TextRange) {
// from vim-highlightedyank docs: When a new text is yanked or user starts editing, the old highlighting would be deleted // from vim-highlightedyank docs: When a new text is yanked or user starts editing, the old highlighting would be deleted
clearYankHighlighters() clearYankHighlighters()
val editor = caretToRange.first().key.editor.ij
lastEditor = editor lastEditor = editor
val attributes = getHighlightTextAttributes(editor) val attributes = getHighlightTextAttributes(editor)
for (range in caretToRange.values) { for (i in 0 until range.size()) {
for (i in 0 until range.size()) { val highlighter = editor.markupModel.addRangeHighlighter(
val highlighter = editor.markupModel.addRangeHighlighter( range.startOffsets[i],
range.startOffsets[i], range.endOffsets[i],
range.endOffsets[i], HighlighterLayer.SELECTION,
HighlighterLayer.SELECTION, attributes,
attributes, HighlighterTargetArea.EXACT_RANGE,
HighlighterTargetArea.EXACT_RANGE, )
) highlighters.add(highlighter)
highlighters.add(highlighter)
}
} }
// from vim-highlightedyank docs: A negative number makes the highlight persistent. // from vim-highlightedyank docs: A negative number makes the highlight persistent.

View File

@@ -268,7 +268,7 @@ private object FileTypePatterns {
} else if (fileTypeName == "CMakeLists.txt" || fileName == "CMakeLists") { } else if (fileTypeName == "CMakeLists.txt" || fileName == "CMakeLists") {
this.cMakePatterns this.cMakePatterns
} else { } else {
return null this.htmlPatterns
} }
} }

View File

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

View File

@@ -1,5 +1,5 @@
/* /*
* Copyright 2003-2025 The IdeaVim authors * Copyright 2003-2026 The IdeaVim authors
* *
* Use of this source code is governed by an MIT-style * Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at * license that can be found in the LICENSE.txt file or at
@@ -11,6 +11,7 @@ package com.maddyhome.idea.vim.extension.nerdtree
import com.intellij.openapi.actionSystem.ActionManager import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.ApplicationManager
import com.intellij.ui.SwingActionDelegate
import com.intellij.ui.treeStructure.Tree import com.intellij.ui.treeStructure.Tree
import com.maddyhome.idea.vim.VimPlugin import com.maddyhome.idea.vim.VimPlugin
import com.maddyhome.idea.vim.api.ExecutionContext import com.maddyhome.idea.vim.api.ExecutionContext
@@ -47,5 +48,11 @@ class NerdTreeAction(val action: (AnActionEvent, Tree) -> Unit) {
* @return An [NerdTreeAction] that runs the specified action when triggered. * @return An [NerdTreeAction] that runs the specified action when triggered.
*/ */
fun ij(id: String) = NerdTreeAction { event, _ -> callAction(null, id, event.dataContext.vim) } fun ij(id: String) = NerdTreeAction { event, _ -> callAction(null, id, event.dataContext.vim) }
/**
* Creates an [NerdTreeAction] that delegates to the JTree's Swing ActionMap.
*/
fun swing(swingActionId: String) =
NerdTreeAction { _, tree -> SwingActionDelegate.performAction(swingActionId, tree) }
} }
} }

View File

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

View File

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

View File

@@ -0,0 +1,30 @@
package com.maddyhome.idea.vim.extension.surround
import com.intellij.util.text.CharSequenceSubSequence
internal data class RepeatedCharSequence(val text: CharSequence, val count: Int) : CharSequence {
override val length = text.length * count
override fun get(index: Int): Char {
if (index < 0 || index >= length) throw IndexOutOfBoundsException()
return text[index % text.length]
}
override fun subSequence(startIndex: Int, endIndex: Int): CharSequence {
return CharSequenceSubSequence(this, startIndex, endIndex)
}
override fun toString(): String {
return text.repeat(count)
}
companion object {
fun of(text: CharSequence, count: Int): CharSequence {
return when (count) {
0 -> ""
1 -> text
else -> RepeatedCharSequence(text, count)
}
}
}
}

View File

@@ -15,6 +15,7 @@ import com.maddyhome.idea.vim.KeyHandler
import com.maddyhome.idea.vim.VimPlugin import com.maddyhome.idea.vim.VimPlugin
import com.maddyhome.idea.vim.api.ExecutionContext import com.maddyhome.idea.vim.api.ExecutionContext
import com.maddyhome.idea.vim.api.VimCaret import com.maddyhome.idea.vim.api.VimCaret
import com.maddyhome.idea.vim.api.VimChangeGroup
import com.maddyhome.idea.vim.api.VimEditor import com.maddyhome.idea.vim.api.VimEditor
import com.maddyhome.idea.vim.api.endsWithNewLine import com.maddyhome.idea.vim.api.endsWithNewLine
import com.maddyhome.idea.vim.api.getLeadingCharacterOffset import com.maddyhome.idea.vim.api.getLeadingCharacterOffset
@@ -36,7 +37,10 @@ import com.maddyhome.idea.vim.extension.VimExtensionFacade.setRegisterForCaret
import com.maddyhome.idea.vim.extension.exportOperatorFunction import com.maddyhome.idea.vim.extension.exportOperatorFunction
import com.maddyhome.idea.vim.group.findBlockRange import com.maddyhome.idea.vim.group.findBlockRange
import com.maddyhome.idea.vim.helper.exitVisualMode import com.maddyhome.idea.vim.helper.exitVisualMode
import com.maddyhome.idea.vim.helper.runWithEveryCaretAndRestore
import com.maddyhome.idea.vim.key.OperatorFunction import com.maddyhome.idea.vim.key.OperatorFunction
import com.maddyhome.idea.vim.newapi.IjVimCaret
import com.maddyhome.idea.vim.newapi.IjVimEditor
import com.maddyhome.idea.vim.newapi.ij import com.maddyhome.idea.vim.newapi.ij
import com.maddyhome.idea.vim.newapi.vim import com.maddyhome.idea.vim.newapi.vim
import com.maddyhome.idea.vim.options.helpers.ClipboardOptionHelper import com.maddyhome.idea.vim.options.helpers.ClipboardOptionHelper
@@ -139,7 +143,7 @@ internal class VimSurroundExtension : VimExtension {
) )
} }
VimExtensionFacade.exportOperatorFunction(OPERATOR_FUNC, Operator()) VimExtensionFacade.exportOperatorFunction(OPERATOR_FUNC, Operator(supportsMultipleCursors = false, count = 1)) // TODO
} }
private class YSurroundHandler : ExtensionHandler { private class YSurroundHandler : ExtensionHandler {
@@ -166,7 +170,7 @@ internal class VimSurroundExtension : VimExtension {
val lastNonWhiteSpaceOffset = getLastNonWhitespaceCharacterOffset(editor.text(), lineStartOffset, lineEndOffset) val lastNonWhiteSpaceOffset = getLastNonWhitespaceCharacterOffset(editor.text(), lineStartOffset, lineEndOffset)
if (lastNonWhiteSpaceOffset != null) { if (lastNonWhiteSpaceOffset != null) {
val range = TextRange(lineStartOffset, lastNonWhiteSpaceOffset + 1) val range = TextRange(lineStartOffset, lastNonWhiteSpaceOffset + 1)
performSurround(pair, range, it) performSurround(pair, range, it, count = operatorArguments.count1)
} }
// it.moveToOffset(lineStartOffset) // it.moveToOffset(lineStartOffset)
} }
@@ -189,15 +193,13 @@ internal class VimSurroundExtension : VimExtension {
private class VSurroundHandler : ExtensionHandler { private class VSurroundHandler : ExtensionHandler {
override fun execute(editor: VimEditor, context: ExecutionContext, operatorArguments: OperatorArguments) { override fun execute(editor: VimEditor, context: ExecutionContext, operatorArguments: OperatorArguments) {
val selectionStart = editor.ij.caretModel.primaryCaret.selectionStart
// NB: Operator ignores SelectionType anyway // NB: Operator ignores SelectionType anyway
if (!Operator().apply(editor, context, editor.mode.selectionType)) { if (!Operator(supportsMultipleCursors = true, count = operatorArguments.count1).apply(editor, context, editor.mode.selectionType)) {
return return
} }
runWriteAction { runWriteAction {
// Leave visual mode // Leave visual mode
editor.exitVisualMode() editor.exitVisualMode()
editor.ij.caretModel.moveToOffset(selectionStart)
// Reset the key handler so that the command trie is updated for the new mode (Normal) // Reset the key handler so that the command trie is updated for the new mode (Normal)
// TODO: This should probably be handled by ToHandlerMapping.execute // TODO: This should probably be handled by ToHandlerMapping.execute
@@ -220,6 +222,10 @@ internal class VimSurroundExtension : VimExtension {
companion object { companion object {
fun change(editor: VimEditor, context: ExecutionContext, charFrom: Char, newSurround: SurroundPair?) { fun change(editor: VimEditor, context: ExecutionContext, charFrom: Char, newSurround: SurroundPair?) {
editor.ij.runWithEveryCaretAndRestore { changeAtCaret(editor, context, charFrom, newSurround) }
}
fun changeAtCaret(editor: VimEditor, context: ExecutionContext, charFrom: Char, newSurround: SurroundPair?) {
// Save old register values for carets // Save old register values for carets
val surroundings = editor.sortedCarets() val surroundings = editor.sortedCarets()
.map { .map {
@@ -263,7 +269,7 @@ internal class VimSurroundExtension : VimExtension {
it.first + trimmedValue + it.second it.first + trimmedValue + it.second
} ?: innerValue } ?: innerValue
val textData = val textData =
PutData.TextData(null, injector.clipboardManager.dumbCopiedText(text), SelectionType.CHARACTER_WISE) PutData.TextData(text, SelectionType.CHARACTER_WISE, emptyList(), null)
val putData = val putData =
PutData(textData, null, 1, insertTextBeforeCaret = true, rawIndent = true, caretAfterInsertedText = false) PutData(textData, null, 1, insertTextBeforeCaret = true, rawIndent = true, caretAfterInsertedText = false)
@@ -342,20 +348,41 @@ internal class VimSurroundExtension : VimExtension {
} }
} }
private class Operator : OperatorFunction { private class Operator(private val supportsMultipleCursors: Boolean, private val count: Int) : OperatorFunction {
override fun apply(editor: VimEditor, context: ExecutionContext, selectionType: SelectionType?): Boolean { override fun apply(editor: VimEditor, context: ExecutionContext, selectionType: SelectionType?): Boolean {
val ijEditor = editor.ij val ijEditor = editor.ij
val c = injector.keyGroup.getChar(editor) ?: return true val c = injector.keyGroup.getChar(editor) ?: return true
val pair = getOrInputPair(c, ijEditor, context.ij) ?: return false val pair = getOrInputPair(c, ijEditor, context.ij) ?: return false
// XXX: Will it work with line-wise or block-wise selections?
val range = getSurroundRange(editor.currentCaret()) ?: return false runWriteAction {
performSurround(pair, range, editor.currentCaret(), selectionType == SelectionType.LINE_WISE) val change = VimPlugin.getChange()
// Jump back to start if (supportsMultipleCursors) {
executeNormalWithoutMapping(injector.parser.parseKeys("`["), ijEditor) ijEditor.runWithEveryCaretAndRestore {
applyOnce(ijEditor, change, pair, count)
}
}
else {
applyOnce(ijEditor, change, pair, count)
// Jump back to start
executeNormalWithoutMapping(injector.parser.parseKeys("`["), ijEditor)
}
}
return true return true
} }
private fun applyOnce(editor: Editor, change: VimChangeGroup, pair: SurroundPair, count: Int) {
// XXX: Will it work with line-wise or block-wise selections?
val primaryCaret = editor.caretModel.primaryCaret
val range = getSurroundRange(primaryCaret.vim)
if (range != null) {
val start = RepeatedCharSequence.of(pair.first, count)
val end = RepeatedCharSequence.of(pair.second, count)
change.insertText(IjVimEditor(editor), IjVimCaret(primaryCaret), range.startOffset, start)
change.insertText(IjVimEditor(editor), IjVimCaret(primaryCaret), range.endOffset + start.length, end)
}
}
private fun getSurroundRange(caret: VimCaret): TextRange? { private fun getSurroundRange(caret: VimCaret): TextRange? {
val editor = caret.editor val editor = caret.editor
if (editor.mode is Mode.CMD_LINE) { if (editor.mode is Mode.CMD_LINE) {
@@ -444,14 +471,14 @@ private fun getOrInputPair(c: Char, editor: Editor, context: DataContext): Surro
} }
private fun performSurround(pair: SurroundPair, range: TextRange, caret: VimCaret, tagsOnNewLines: Boolean = false) { private fun performSurround(pair: SurroundPair, range: TextRange, caret: VimCaret, count: Int, tagsOnNewLines: Boolean = false) {
val editor = caret.editor val editor = caret.editor
val change = VimPlugin.getChange() val change = VimPlugin.getChange()
val leftSurround = pair.first + if (tagsOnNewLines) "\n" else "" val leftSurround = RepeatedCharSequence.of(pair.first + if (tagsOnNewLines) "\n" else "", count)
val isEOF = range.endOffset == editor.text().length val isEOF = range.endOffset == editor.text().length
val hasNewLine = editor.endsWithNewLine() val hasNewLine = editor.endsWithNewLine()
val rightSurround = if (tagsOnNewLines) { val rightSurround = (if (tagsOnNewLines) {
if (isEOF && !hasNewLine) { if (isEOF && !hasNewLine) {
"\n" + pair.second "\n" + pair.second
} else { } else {
@@ -459,7 +486,7 @@ private fun performSurround(pair: SurroundPair, range: TextRange, caret: VimCare
} }
} else { } else {
pair.second pair.second
} }).let { RepeatedCharSequence.of(it, count) }
change.insertText(editor, caret, range.startOffset, leftSurround) change.insertText(editor, caret, range.startOffset, leftSurround)
change.insertText(editor, caret, range.endOffset + leftSurround.length, rightSurround) change.insertText(editor, caret, range.endOffset + leftSurround.length, rightSurround)

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,63 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package com.maddyhome.idea.vim.group
import com.intellij.lang.LanguageCommenters
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.fileEditor.FileDocumentManager
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.psi.PsiDocumentManager
import com.maddyhome.idea.vim.api.Options
import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.autocmd.IjFileTypeMapping
import com.maddyhome.idea.vim.helper.CommenterMarkers
import com.maddyhome.idea.vim.helper.CommenterToComments
import com.maddyhome.idea.vim.helper.FiletypePresets
import com.maddyhome.idea.vim.newapi.vim
import com.maddyhome.idea.vim.vimscript.model.datatypes.VimString
/**
* Resolves a buffer-local `'comments'` value when an editor is created.
*
* Delegates to [OptionGroup.setBufferLocalDefaultIfUntouched], which preserves
* any value the user explicitly set via `.ideavimrc` or interactive `:set`.
*/
object CommentsOptionInitializer {
fun initializeForEditor(editor: Editor) {
val optionGroup = injector.optionGroup as? OptionGroup ?: return
val resolved = resolveComments(editor) ?: return
optionGroup.setBufferLocalDefaultIfUntouched(
Options.comments,
editor.vim,
VimString(resolved),
)
}
private fun resolveComments(editor: Editor): String? {
val filetypeName = filetypeOf(editor) ?: return null
return FiletypePresets.presetFor(filetypeName) ?: deriveFromCommenter(editor)
}
private fun filetypeOf(editor: Editor): String? {
val virtualFile: VirtualFile = FileDocumentManager.getInstance().getFile(editor.document) ?: return null
return IjFileTypeMapping.toVimFileType(virtualFile)
}
private fun deriveFromCommenter(editor: Editor): String? {
val project = editor.project ?: return null
val psiFile = PsiDocumentManager.getInstance(project).getPsiFile(editor.document) ?: return null
val commenter = LanguageCommenters.INSTANCE.forLanguage(psiFile.language) ?: return null
return CommenterToComments.derive(
CommenterMarkers(
linePrefix = commenter.lineCommentPrefix,
blockPrefix = commenter.blockCommentPrefix,
blockSuffix = commenter.blockCommentSuffix,
),
)
}
}

View File

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

View File

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

View File

@@ -141,7 +141,7 @@ object IjOptions {
// Temporary feature flags during development, not really intended for external use // Temporary feature flags during development, not really intended for external use
val closenotebooks: ToggleOption = val closenotebooks: ToggleOption =
addOption(ToggleOption("closenotebooks", GLOBAL, "closenotebooks", true, isHidden = true)) addOption(ToggleOption("closenotebooks", GLOBAL, "closenotebooks", true, isHidden = true))
val oldundo: ToggleOption = addOption(ToggleOption("oldundo", GLOBAL, "oldundo", false, isHidden = true)) val oldundo: ToggleOption = addOption(ToggleOption("oldundo", GLOBAL, "oldundo", true, isHidden = true))
val unifyjumps: ToggleOption = addOption(ToggleOption("unifyjumps", GLOBAL, "unifyjumps", true, isHidden = true)) val unifyjumps: ToggleOption = addOption(ToggleOption("unifyjumps", GLOBAL, "unifyjumps", true, isHidden = true))
// This needs to be Option<out VimDataType> so that it can work with derived option types, such as NumberOption, which // This needs to be Option<out VimDataType> so that it can work with derived option types, such as NumberOption, which
// derives from Option<VimInt> // derives from Option<VimInt>

View File

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

View File

@@ -0,0 +1,68 @@
package com.maddyhome.idea.vim.group
import com.intellij.codeInsight.daemon.ReferenceImporter
import com.intellij.openapi.actionSystem.CommonDataKeys
import com.intellij.openapi.actionSystem.DataContext
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.application.ReadAction
import com.intellij.openapi.command.WriteCommandAction
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.fileEditor.FileDocumentManager
import com.intellij.openapi.progress.ProgressIndicator
import com.intellij.openapi.progress.ProgressManager
import com.intellij.openapi.progress.Task
import com.intellij.psi.PsiDocumentManager
import com.intellij.psi.PsiElement
import com.intellij.psi.PsiRecursiveElementWalkingVisitor
import java.util.function.BooleanSupplier
internal object MacroAutoImport {
fun run(editor: Editor, dataContext: DataContext) {
val project = CommonDataKeys.PROJECT.getData(dataContext) ?: return
val file = PsiDocumentManager.getInstance(project).getPsiFile(editor.document) ?: return
if (!FileDocumentManager.getInstance().requestWriting(editor.document, project)) {
return
}
val importers = ReferenceImporter.EP_NAME.extensionList
if (importers.isEmpty()) {
return
}
ProgressManager.getInstance().run(object : Task.Backgroundable(project, "Auto import", true) {
override fun run(indicator: ProgressIndicator) {
val fixes = ReadAction.nonBlocking<List<BooleanSupplier>> {
val fixes = mutableListOf<BooleanSupplier>()
file.accept(object : PsiRecursiveElementWalkingVisitor() {
override fun visitElement(element: PsiElement) {
for (reference in element.references) {
if (reference.resolve() != null) {
continue
}
for (importer in importers) {
importer.computeAutoImportAtOffset(editor, file, element.textRange.startOffset, true)
?.let(fixes::add)
}
}
super.visitElement(element)
}
})
return@nonBlocking fixes
}.executeSynchronously()
ApplicationManager.getApplication().invokeAndWait {
WriteCommandAction.writeCommandAction(project)
.withName("Auto Import")
.withGroupId("IdeaVimAutoImportAfterMacro")
.shouldRecordActionForActiveDocument(true)
.run<RuntimeException> {
fixes.forEach { it.asBoolean }
}
}
}
})
}
}

View File

@@ -92,6 +92,9 @@ class MacroGroup : VimMacroBase() {
} finally { } finally {
keyStack.removeFirst() keyStack.removeFirst()
} }
if (!isInternalMacro) {
MacroAutoImport.run(editor.ij, context.ij)
}
} }
if (isInternalMacro) { if (isInternalMacro) {

View File

@@ -86,6 +86,9 @@ class MotionGroup : VimMotionGroupBase() {
} }
override fun moveCaretToCurrentDisplayLineStart(editor: VimEditor, caret: ImmutableVimCaret): Motion { override fun moveCaretToCurrentDisplayLineStart(editor: VimEditor, caret: ImmutableVimCaret): Motion {
if (editor.ij.softWrapModel.isSoftWrappingEnabled) {
return AbsoluteOffset(caret.ij.visualLineStart)
}
val col = EditorHelper.getVisualColumnAtLeftOfDisplay(editor.ij, caret.getVisualPosition().line) val col = EditorHelper.getVisualColumnAtLeftOfDisplay(editor.ij, caret.getVisualPosition().line)
return moveCaretToColumn(editor, caret, col, false) return moveCaretToColumn(editor, caret, col, false)
} }
@@ -94,6 +97,15 @@ class MotionGroup : VimMotionGroupBase() {
editor: VimEditor, editor: VimEditor,
caret: ImmutableVimCaret, caret: ImmutableVimCaret,
): @Range(from = 0, to = Int.MAX_VALUE.toLong()) Int { ): @Range(from = 0, to = Int.MAX_VALUE.toLong()) Int {
if (editor.ij.softWrapModel.isSoftWrappingEnabled) {
val offset = caret.ij.visualLineStart
val line = editor.offsetToBufferPosition(offset).line
return if (offset == editor.getLineStartOffset(line)) {
editor.getLeadingCharacterOffset(line, 0)
} else {
offset
}
}
val col = EditorHelper.getVisualColumnAtLeftOfDisplay(editor.ij, caret.getVisualPosition().line) val col = EditorHelper.getVisualColumnAtLeftOfDisplay(editor.ij, caret.getVisualPosition().line)
val bufferLine = caret.getLine() val bufferLine = caret.getLine()
return editor.getLeadingCharacterOffset(bufferLine, col) return editor.getLeadingCharacterOffset(bufferLine, col)
@@ -104,6 +116,9 @@ class MotionGroup : VimMotionGroupBase() {
caret: ImmutableVimCaret, caret: ImmutableVimCaret,
allowEnd: Boolean, allowEnd: Boolean,
): Motion { ): Motion {
if (editor.ij.softWrapModel.isSoftWrappingEnabled) {
return AbsoluteOffset(caret.ij.visualLineEnd - 1)
}
val col = EditorHelper.getVisualColumnAtRightOfDisplay(editor.ij, caret.getVisualPosition().line) val col = EditorHelper.getVisualColumnAtRightOfDisplay(editor.ij, caret.getVisualPosition().line)
return moveCaretToColumn(editor, caret, col, allowEnd) return moveCaretToColumn(editor, caret, col, allowEnd)
} }

View File

@@ -32,7 +32,6 @@ import com.intellij.openapi.ui.Messages
import com.intellij.openapi.util.SystemInfo import com.intellij.openapi.util.SystemInfo
import com.maddyhome.idea.vim.VimPlugin import com.maddyhome.idea.vim.VimPlugin
import com.maddyhome.idea.vim.api.VimEditor import com.maddyhome.idea.vim.api.VimEditor
import com.maddyhome.idea.vim.api.globalOptions
import com.maddyhome.idea.vim.api.injector import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.handler.KeyMapIssue import com.maddyhome.idea.vim.handler.KeyMapIssue
import com.maddyhome.idea.vim.helper.MessageHelper import com.maddyhome.idea.vim.helper.MessageHelper
@@ -40,8 +39,6 @@ import com.maddyhome.idea.vim.icons.VimIcons
import com.maddyhome.idea.vim.key.ShortcutOwner import com.maddyhome.idea.vim.key.ShortcutOwner
import com.maddyhome.idea.vim.key.ShortcutOwnerInfo import com.maddyhome.idea.vim.key.ShortcutOwnerInfo
import com.maddyhome.idea.vim.newapi.globalIjOptions import com.maddyhome.idea.vim.newapi.globalIjOptions
import com.maddyhome.idea.vim.newapi.ijOptions
import com.maddyhome.idea.vim.options.OptionConstants
import com.maddyhome.idea.vim.statistic.ActionTracker import com.maddyhome.idea.vim.statistic.ActionTracker
import com.maddyhome.idea.vim.ui.VimEmulationConfigurable import com.maddyhome.idea.vim.ui.VimEmulationConfigurable
import com.maddyhome.idea.vim.vimscript.services.VimRcService import com.maddyhome.idea.vim.vimscript.services.VimRcService
@@ -64,55 +61,9 @@ internal class NotificationService(private val project: Project?) : VimNotificat
@Suppress("unused") @Suppress("unused")
constructor() : this(null) constructor() : this(null)
override fun notifyAboutIdeaPut() { override fun notifyAboutIdeaPut() {}
val notification = Notification(
IDEAVIM_NOTIFICATION_ID,
IDEAVIM_NOTIFICATION_TITLE,
"""Add <code>ideaput</code> to <code>clipboard</code> option to perform a put via the IDE<br/><b><code>set clipboard+=ideaput</code></b>""",
NotificationType.INFORMATION,
)
notification.addAction(OpenIdeaVimRcAction(notification)) override fun notifyAboutIdeaJoin(editor: VimEditor) {}
notification.addAction(
AppendToIdeaVimRcAction(
notification,
"set clipboard^=ideaput",
"ideaput",
) {
// Technically, we're supposed to prepend values to clipboard so that it's not added to the "exclude" item.
// Since we don't handle exclude, it's safe to append. But let's be clean.
injector.globalOptions().clipboard.prependValue(OptionConstants.clipboard_ideaput)
},
)
notification.notify(project)
}
override fun notifyAboutIdeaJoin(editor: VimEditor) {
val notification = Notification(
IDEAVIM_NOTIFICATION_ID,
IDEAVIM_NOTIFICATION_TITLE,
"""Put <b><code>set ideajoin</code></b> into your <code>~/.ideavimrc</code> to perform a join via the IDE""",
NotificationType.INFORMATION,
)
notification.addAction(OpenIdeaVimRcAction(notification))
notification.addAction(
AppendToIdeaVimRcAction(
notification,
"set ideajoin",
"ideajoin"
) {
// This is a global-local option. Setting it will always set the global value
injector.ijOptions(editor).ideajoin = true
},
)
notification.addAction(HelpLink(ideajoinExamplesUrl))
notification.notify(project)
}
override fun enableRepeatingMode() = Messages.showYesNoDialog( override fun enableRepeatingMode() = Messages.showYesNoDialog(
"Do you want to enable repeating keys in macOS on press and hold?\n\n" + "Do you want to enable repeating keys in macOS on press and hold?\n\n" +

View File

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

View File

@@ -24,10 +24,9 @@ import org.jetbrains.annotations.Nullable;
import javax.swing.*; import javax.swing.*;
import java.awt.event.KeyEvent; import java.awt.event.KeyEvent;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections;
import java.util.List; import java.util.List;
import static com.maddyhome.idea.vim.api.VimInjectorKt.injector;
/** /**
* This group works with command associated with copying and pasting text * This group works with command associated with copying and pasting text
*/ */
@@ -128,7 +127,7 @@ public class RegisterGroup extends VimRegisterGroupBase
final String text = XMLGroup.getInstance().getSafeXmlText(textElement); final String text = XMLGroup.getInstance().getSafeXmlText(textElement);
if (text != null) { if (text != null) {
logger.trace("Register data parsed"); logger.trace("Register data parsed");
register = new Register(key, injector.getClipboardManager().dumbCopiedText(text), type); register = new Register(key, type, text, Collections.emptyList());
} }
else { else {
logger.trace("Cannot parse register data"); logger.trace("Cannot parse register data");

View File

@@ -37,7 +37,6 @@ import com.maddyhome.idea.vim.ide.isClionNova
import com.maddyhome.idea.vim.ide.isRider import com.maddyhome.idea.vim.ide.isRider
import com.maddyhome.idea.vim.mark.VimMarkConstants.MARK_CHANGE_POS import com.maddyhome.idea.vim.mark.VimMarkConstants.MARK_CHANGE_POS
import com.maddyhome.idea.vim.newapi.IjVimCaret import com.maddyhome.idea.vim.newapi.IjVimCaret
import com.maddyhome.idea.vim.newapi.IjVimCopiedText
import com.maddyhome.idea.vim.newapi.IjVimEditor import com.maddyhome.idea.vim.newapi.IjVimEditor
import com.maddyhome.idea.vim.newapi.ij import com.maddyhome.idea.vim.newapi.ij
import com.maddyhome.idea.vim.newapi.vim import com.maddyhome.idea.vim.newapi.vim
@@ -127,7 +126,7 @@ internal class PutGroup : VimPutBase() {
point.dispose() point.dispose()
if (!caret.isValid) return@forEach if (!caret.isValid) return@forEach
val caretPossibleEndOffset = lastPastedRegion?.endOffset ?: (startOffset + text.copiedText.text.length) val caretPossibleEndOffset = lastPastedRegion?.endOffset ?: (startOffset + text.text.length)
val endOffset = if (data.indent) { val endOffset = if (data.indent) {
doIndent( doIndent(
vimEditor, vimEditor,
@@ -179,10 +178,12 @@ internal class PutGroup : VimPutBase() {
val allContentsBefore = CopyPasteManager.getInstance().allContents val allContentsBefore = CopyPasteManager.getInstance().allContents
val sizeBeforeInsert = allContentsBefore.size val sizeBeforeInsert = allContentsBefore.size
val firstItemBefore = allContentsBefore.firstOrNull() val firstItemBefore = allContentsBefore.firstOrNull()
logger.debug { "Copied text: ${text.copiedText}" } logger.debug { "Transferable classes: ${text.transferableData.joinToString { it.javaClass.name }}" }
val (textContent, transferableData) = text.copiedText as IjVimCopiedText
val origContent: TextBlockTransferable = val origContent: TextBlockTransferable =
injector.clipboardManager.setClipboardText(textContent, textContent, transferableData) as TextBlockTransferable injector.clipboardManager.setClipboardText(
text.text,
transferableData = text.transferableData,
) as TextBlockTransferable
val allContentsAfter = CopyPasteManager.getInstance().allContents val allContentsAfter = CopyPasteManager.getInstance().allContents
val sizeAfterInsert = allContentsAfter.size val sizeAfterInsert = allContentsAfter.size
try { try {
@@ -190,7 +191,7 @@ internal class PutGroup : VimPutBase() {
} finally { } finally {
val textInClipboard = (firstItemBefore as? TextBlockTransferable) val textInClipboard = (firstItemBefore as? TextBlockTransferable)
?.getTransferData(DataFlavor.stringFlavor) as? String ?.getTransferData(DataFlavor.stringFlavor) as? String
val textOnTop = textInClipboard != null && textInClipboard != text.copiedText.text val textOnTop = textInClipboard != null && textInClipboard != text.text
if (sizeBeforeInsert != sizeAfterInsert || textOnTop) { if (sizeBeforeInsert != sizeAfterInsert || textOnTop) {
// Sometimes an inserted text replaces an existing one. E.g. on insert with + or * register // Sometimes an inserted text replaces an existing one. E.g. on insert with + or * register
(CopyPasteManager.getInstance() as? CopyPasteManagerEx)?.run { removeContent(origContent) } (CopyPasteManager.getInstance() as? CopyPasteManagerEx)?.run { removeContent(origContent) }

View File

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

View File

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

View File

@@ -12,9 +12,12 @@ package com.maddyhome.idea.vim.helper
import com.intellij.codeWithMe.ClientId import com.intellij.codeWithMe.ClientId
import com.intellij.openapi.editor.Caret import com.intellij.openapi.editor.Caret
import com.intellij.openapi.editor.CaretState
import com.intellij.openapi.editor.Editor import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.EditorKind
import com.intellij.openapi.editor.ex.util.EditorUtil import com.intellij.openapi.editor.ex.util.EditorUtil
import com.intellij.openapi.fileEditor.ex.FileEditorManagerEx import com.intellij.openapi.fileEditor.ex.FileEditorManagerEx
import com.intellij.openapi.ui.popup.util.PopupUtil
import com.intellij.util.ui.table.JBTableRowEditor import com.intellij.util.ui.table.JBTableRowEditor
import com.maddyhome.idea.vim.VimPlugin import com.maddyhome.idea.vim.VimPlugin
import com.maddyhome.idea.vim.api.StringListOptionValue import com.maddyhome.idea.vim.api.StringListOptionValue
@@ -22,6 +25,8 @@ import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.group.IjOptionConstants import com.maddyhome.idea.vim.group.IjOptionConstants
import com.maddyhome.idea.vim.key.IdeaVimDisablerExtensionPoint import com.maddyhome.idea.vim.key.IdeaVimDisablerExtensionPoint
import com.maddyhome.idea.vim.newapi.globalIjOptions import com.maddyhome.idea.vim.newapi.globalIjOptions
import com.maddyhome.idea.vim.newapi.vim
import com.maddyhome.idea.vim.state.mode.inBlockSelection
import java.awt.Component import java.awt.Component
import javax.swing.JComponent import javax.swing.JComponent
import javax.swing.JTable import javax.swing.JTable
@@ -49,7 +54,7 @@ internal val Editor.isIdeaVimDisabledHere: Boolean
!ClientId.isCurrentlyUnderLocalId || // CWM-927 !ClientId.isCurrentlyUnderLocalId || // CWM-927
(ideaVimDisabledForSingleLine(ideaVimSupportValue) && isSingleLine()) || (ideaVimDisabledForSingleLine(ideaVimSupportValue) && isSingleLine()) ||
IdeaVimDisablerExtensionPoint.isDisabledForEditor(this) || IdeaVimDisablerExtensionPoint.isDisabledForEditor(this) ||
isNotFileEditorExceptAllowed() !isAllowedFileEditor()
} }
/** /**
@@ -61,18 +66,21 @@ internal val Editor.isIdeaVimDisabledHere: Boolean
* Here are issues when non-file editors were supported: * Here are issues when non-file editors were supported:
* AI Chat VIM-3786 * AI Chat VIM-3786
* Debug evaluate console VIM-3929 * Debug evaluate console VIM-3929
* Python console - VIM-4172
* *
* However, we still support IdeaVim in a commit window because it works fine there, and removing vim from this place will * We do want to support Vim actions in some windows, such as the commit window, diff windows, and decompiled Java
* be quite a visible change for users. * files. We don't support the Python console.
* We detect the commit window by the name of the editor (Dummy.txt). If this causes issues, let's disable IdeaVim
* in the commit window as well.
*
* Also, we support IdeaVim in diff viewers.
*/ */
private fun Editor.isNotFileEditorExceptAllowed(): Boolean { private fun Editor.isAllowedFileEditor(): Boolean {
if (EditorHelper.getVirtualFile(this)?.name?.contains("Dummy.txt") == true) return false if (EditorHelper.getVirtualFile(this)?.name?.contains("Dummy.txt") == true) {
if (EditorHelper.isDiffEditor(this)) return false return PopupUtil.getPopupContainerFor(component) == null
return !EditorHelper.isFileEditor(this) }
if (EditorHelper.isPythonConsole(this)) return false
return EditorHelper.isCommitWindowEditor(this)
|| EditorHelper.isKotlinClassDecompiledToJavaFile(this)
|| EditorHelper.isDiffEditor(this)
|| EditorHelper.isFileEditor(this)
} }
private fun ideaVimDisabledInDialog(ideaVimSupportValue: StringListOptionValue): Boolean { private fun ideaVimDisabledInDialog(ideaVimSupportValue: StringListOptionValue): Boolean {
@@ -111,8 +119,7 @@ internal fun Editor.isPrimaryEditor(): Boolean {
internal fun Editor.isTerminalEditor(): Boolean { internal fun Editor.isTerminalEditor(): Boolean {
return !isViewer return !isViewer
&& document.isWritable && document.isWritable
&& !EditorHelper.isFileEditor(this) && this.editorKind == EditorKind.CONSOLE
&& !EditorHelper.isDiffEditor(this)
} }
// Optimized clone of com.intellij.ide.ui.laf.darcula.DarculaUIUtil.isTableCellEditor // Optimized clone of com.intellij.ide.ui.laf.darcula.DarculaUIUtil.isTableCellEditor
@@ -141,3 +148,41 @@ private fun vimEnabled(editor: Editor?): Boolean {
if (editor != null && editor.isIdeaVimDisabledHere) return false if (editor != null && editor.isIdeaVimDisabledHere) return false
return true return true
} }
internal inline fun Editor.runWithEveryCaretAndRestore(action: () -> Unit) {
val caretModel = this.caretModel
val carets = if (this.vim.inBlockSelection) null else caretModel.allCarets
if (carets == null || carets.size == 1) {
action()
}
else {
var initialDocumentSize = this.document.textLength
var documentSizeDifference = 0
val caretOffsets = carets.map { it.selectionStart to it.selectionEnd }
val restoredCarets = mutableListOf<CaretState>()
caretModel.removeSecondaryCarets()
for ((selectionStart, selectionEnd) in caretOffsets) {
if (selectionStart == selectionEnd) {
caretModel.primaryCaret.moveToOffset(selectionStart + documentSizeDifference)
}
else {
caretModel.primaryCaret.setSelection(
selectionStart + documentSizeDifference,
selectionEnd + documentSizeDifference
)
}
action()
restoredCarets.add(caretModel.caretsAndSelections.single())
val documentLength = this.document.textLength
documentSizeDifference += documentLength - initialDocumentSize
initialDocumentSize = documentLength
}
caretModel.caretsAndSelections = restoredCarets
}
}

View File

@@ -24,15 +24,19 @@ import com.intellij.openapi.diagnostic.thisLogger
import com.intellij.openapi.editor.actionSystem.DocCommandGroupId import com.intellij.openapi.editor.actionSystem.DocCommandGroupId
import com.intellij.openapi.progress.util.ProgressIndicatorUtils import com.intellij.openapi.progress.util.ProgressIndicatorUtils
import com.intellij.openapi.util.NlsContexts import com.intellij.openapi.util.NlsContexts
import com.intellij.refactoring.actions.BaseRefactoringAction
import com.maddyhome.idea.vim.RegisterActions import com.maddyhome.idea.vim.RegisterActions
import com.maddyhome.idea.vim.api.ExecutionContext import com.maddyhome.idea.vim.api.ExecutionContext
import com.maddyhome.idea.vim.api.NativeAction import com.maddyhome.idea.vim.api.NativeAction
import com.maddyhome.idea.vim.api.VimActionExecutor import com.maddyhome.idea.vim.api.VimActionExecutor
import com.maddyhome.idea.vim.api.VimEditor import com.maddyhome.idea.vim.api.VimEditor
import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.command.OperatorArguments import com.maddyhome.idea.vim.command.OperatorArguments
import com.maddyhome.idea.vim.handler.EditorActionHandlerBase import com.maddyhome.idea.vim.handler.EditorActionHandlerBase
import com.maddyhome.idea.vim.newapi.IjNativeAction import com.maddyhome.idea.vim.newapi.IjNativeAction
import com.maddyhome.idea.vim.newapi.ij import com.maddyhome.idea.vim.newapi.ij
import com.maddyhome.idea.vim.state.mode.Mode
import com.maddyhome.idea.vim.state.mode.inVisualMode
import org.jetbrains.annotations.NonNls import org.jetbrains.annotations.NonNls
import java.awt.Component import java.awt.Component
import javax.swing.JComponent import javax.swing.JComponent
@@ -68,6 +72,12 @@ class IjActionExecutor : VimActionExecutor {
thisLogger().error("Actions cannot be updated when write-action is running or pending") thisLogger().error("Actions cannot be updated when write-action is running or pending")
} }
val startVisualModeType = (editor?.mode as? Mode.VISUAL)?.selectionType
val startVisualCaretSelection = if (editor != null && startVisualModeType != null && action.action !is BaseRefactoringAction)
editor.primaryCaret().let { Triple(it.offset, it.selectionStart, it.selectionEnd) }
else
null
val ijAction = (action as IjNativeAction).action val ijAction = (action as IjNativeAction).action
try { try {
isRunningActionFromVim = true isRunningActionFromVim = true
@@ -77,6 +87,20 @@ class IjActionExecutor : VimActionExecutor {
val place = ijAction.choosePlace() val place = ijAction.choosePlace()
val res = ActionManager.getInstance().tryToExecute(ijAction, null, contextComponent, place, true) val res = ActionManager.getInstance().tryToExecute(ijAction, null, contextComponent, place, true)
res.waitFor(5_000) res.waitFor(5_000)
if (startVisualModeType != null && startVisualCaretSelection != null) {
val primaryCaret = editor.primaryCaret()
val endVisualCaretOffset = primaryCaret.offset
if (startVisualCaretSelection.first != endVisualCaretOffset) {
if (!editor.inVisualMode || (editor.mode as Mode.VISUAL).selectionType != startVisualModeType) {
injector.visualMotionGroup.toggleVisual(editor, 1, 0, startVisualModeType)
}
primaryCaret.moveToOffset(startVisualCaretSelection.first)
primaryCaret.setSelection(startVisualCaretSelection.second, startVisualCaretSelection.third)
primaryCaret.moveToOffset(endVisualCaretOffset)
}
}
return res.isDone return res.isDone
} finally { } finally {
isRunningActionFromVim = false isRunningActionFromVim = false

View File

@@ -58,7 +58,7 @@ internal object ScrollViewHelper {
// that this needs to be replaced as a more or less dumb line for line rewrite. // that this needs to be replaced as a more or less dumb line for line rewrite.
val topLine = getVisualLineAtTopOfScreen(editor) val topLine = getVisualLineAtTopOfScreen(editor)
val bottomLine = getVisualLineAtBottomOfScreen(editor) val bottomLine = getVisualLineAtBottomOfScreen(editor)
val lastLine = vimEditor.getVisualLineCount() - 1 val lastLine = vimEditor.getVisualLineCount() + editor.settings.additionalLinesCount
// We need the non-normalised value here, so we can handle cases such as so=999 to keep the current line centred // We need the non-normalised value here, so we can handle cases such as so=999 to keep the current line centred
val scrollOffset = injector.options(vimEditor).scrolloff val scrollOffset = injector.options(vimEditor).scrolloff

View File

@@ -17,6 +17,7 @@ import com.intellij.openapi.editor.markup.HighlighterLayer
import com.intellij.openapi.editor.markup.HighlighterTargetArea import com.intellij.openapi.editor.markup.HighlighterTargetArea
import com.intellij.openapi.editor.markup.RangeHighlighter import com.intellij.openapi.editor.markup.RangeHighlighter
import com.intellij.openapi.editor.markup.TextAttributes import com.intellij.openapi.editor.markup.TextAttributes
import com.intellij.util.application
import com.maddyhome.idea.vim.api.VimEditor import com.maddyhome.idea.vim.api.VimEditor
import com.maddyhome.idea.vim.api.globalOptions import com.maddyhome.idea.vim.api.globalOptions
import com.maddyhome.idea.vim.api.injector import com.maddyhome.idea.vim.api.injector
@@ -28,8 +29,10 @@ import com.maddyhome.idea.vim.newapi.vim
import com.maddyhome.idea.vim.state.mode.inCommandLineModeWithVisual import com.maddyhome.idea.vim.state.mode.inCommandLineModeWithVisual
import com.maddyhome.idea.vim.state.mode.inVisualMode import com.maddyhome.idea.vim.state.mode.inVisualMode
import org.jetbrains.annotations.Contract import org.jetbrains.annotations.Contract
import java.awt.Color
import java.awt.Font import java.awt.Font
import java.util.* import java.util.*
import javax.swing.Timer
fun updateSearchHighlights( fun updateSearchHighlights(
pattern: String?, pattern: String?,
@@ -84,6 +87,12 @@ fun addSubstitutionConfirmationHighlight(editor: Editor, start: Int, end: Int):
) )
} }
val removeHighlightsEditors = mutableListOf<Editor>()
val removeHighlightsTimer = Timer(450) {
removeHighlightsEditors.forEach(::removeSearchHighlights)
removeHighlightsEditors.clear()
}
/** /**
* Refreshes current search highlights for all visible editors * Refreshes current search highlights for all visible editors
*/ */
@@ -125,27 +134,43 @@ private fun updateSearchHighlights(
// hlsearch (+ incsearch/noincsearch) // hlsearch (+ incsearch/noincsearch)
// Make sure the range fits this editor. Note that Vim will use the same range for all windows. E.g., given // Make sure the range fits this editor. Note that Vim will use the same range for all windows. E.g., given
// `:1,5s/foo`, Vim will highlight all occurrences of `foo` in the first five lines of all visible windows // `:1,5s/foo`, Vim will highlight all occurrences of `foo` in the first five lines of all visible windows
val vimEditor = editor.vim val isSearching = injector.commandLine.getActiveCommandLine() != null
val editorLastLine = vimEditor.lineCount() - 1 application.invokeLater {
val searchStartLine = searchRange?.startLine ?: 0 val vimEditor = editor.vim
val searchEndLine = (searchRange?.endLine ?: -1).coerceAtMost(editorLastLine) val editorLastLine = vimEditor.lineCount() - 1
if (searchStartLine <= editorLastLine) { val searchStartLine = searchRange?.startLine ?: 0
val results = val searchEndLine = (searchRange?.endLine ?: -1).coerceAtMost(editorLastLine)
injector.searchHelper.findAll( if (searchStartLine <= editorLastLine) {
vimEditor, val visibleArea = editor.scrollingModel.visibleAreaOnScrollingFinished
pattern, val visibleTopLeft = visibleArea.location
searchStartLine, val visibleBottomRight = visibleArea.location.apply { translate(visibleArea.width, visibleArea.height) }
searchEndLine, val visibleStartOffset = editor.logicalPositionToOffset(editor.xyToLogicalPosition(visibleTopLeft))
shouldIgnoreCase(pattern, shouldIgnoreSmartCase) val visibleEndOffset = editor.logicalPositionToOffset(editor.xyToLogicalPosition(visibleBottomRight))
) val visibleStartLine = editor.document.getLineNumber(visibleStartOffset)
if (results.isNotEmpty()) { val visibleEndLine = editor.document.getLineNumber(visibleEndOffset)
if (editor === currentEditor?.ij) { removeSearchHighlights(editor)
currentMatchOffset = findClosestMatch(results, initialOffset, count1, forwards)
val results =
injector.searchHelper.findAll(
vimEditor,
pattern,
searchStartLine.coerceAtLeast(visibleStartLine),
searchEndLine.coerceAtMost(visibleEndLine),
shouldIgnoreCase(pattern, shouldIgnoreSmartCase)
)
if (results.isNotEmpty()) {
if (editor === currentEditor?.ij) {
currentMatchOffset = findClosestMatch(results, initialOffset, count1, forwards)
}
highlightSearchResults(editor, results, currentMatchOffset)
if (!isSearching) {
removeHighlightsEditors.add(editor)
removeHighlightsTimer.restart()
}
} }
highlightSearchResults(editor, pattern, results, currentMatchOffset)
} }
editor.vimLastSearch = pattern
} }
editor.vimLastSearch = pattern
} else if (shouldAddCurrentMatchSearchHighlight(pattern, showHighlights, initialOffset)) { } else if (shouldAddCurrentMatchSearchHighlight(pattern, showHighlights, initialOffset)) {
// nohlsearch + incsearch. Even though search highlights are disabled, we still show a highlight (current editor // nohlsearch + incsearch. Even though search highlights are disabled, we still show a highlight (current editor
// only), because 'incsearch' is active. But we don't show a search if Visual is active (behind Command-line of // only), because 'incsearch' is active. But we don't show a search if Visual is active (behind Command-line of
@@ -160,7 +185,7 @@ private fun updateSearchHighlights(
if (result != null) { if (result != null) {
if (!it.inVisualMode && !it.inCommandLineModeWithVisual) { if (!it.inVisualMode && !it.inCommandLineModeWithVisual) {
val results = listOf(result) val results = listOf(result)
highlightSearchResults(editor, pattern, results, result.startOffset) highlightSearchResults(editor, results, result.startOffset)
} }
currentMatchOffset = result.startOffset currentMatchOffset = result.startOffset
} }
@@ -179,6 +204,7 @@ private fun updateSearchHighlights(
} }
} }
removeHighlightsTimer.restart()
return currentEditorCurrentMatchOffset return currentEditorCurrentMatchOffset
} }
@@ -204,7 +230,7 @@ private fun removeSearchHighlights(editor: Editor) {
*/ */
@Contract("_, _, false -> false; _, null, true -> false") @Contract("_, _, false -> false; _, null, true -> false")
private fun shouldAddAllSearchHighlights(editor: Editor, newPattern: String?, hlSearch: Boolean): Boolean { private fun shouldAddAllSearchHighlights(editor: Editor, newPattern: String?, hlSearch: Boolean): Boolean {
return hlSearch && newPattern != null && newPattern != editor.vimLastSearch && newPattern != "" return hlSearch && newPattern != null && newPattern != ""
} }
private fun findClosestMatch( private fun findClosestMatch(
@@ -240,9 +266,18 @@ private fun findClosestMatch(
return sortedResults[nextIndex % results.size].startOffset return sortedResults[nextIndex % results.size].startOffset
} }
@Suppress("UseJBColor")
private val DEFAULT_RESULT_ATTRIBUTES = TextAttributes().apply {
backgroundColor = Color(50, 81, 61)
}
@Suppress("UseJBColor")
private val NEARBY_RESULT_ATTRIBUTES = TextAttributes().apply {
backgroundColor = Color(89, 80, 50)
}
fun highlightSearchResults( fun highlightSearchResults(
editor: Editor, editor: Editor,
pattern: String,
results: List<TextRange>, results: List<TextRange>,
currentMatchOffset: Int, currentMatchOffset: Int,
) { ) {
@@ -251,38 +286,28 @@ fun highlightSearchResults(
highlighters = mutableListOf() highlighters = mutableListOf()
editor.vimLastHighlighters = highlighters editor.vimLastHighlighters = highlighters
} }
for (range in results) {
val current = range.startOffset == currentMatchOffset val allCaretOffsets = editor.caretModel.allCarets.map { it.offset }
val highlighter = highlightMatch(editor, range.startOffset, range.endOffset, current, pattern)
highlighters.add(highlighter) for ((index, range) in results.withIndex()) {
if (allCaretOffsets.any { range.startOffset == it }) {
continue
}
val attributes = if (allCaretOffsets.any { (index > 0 && results[index - 1].startOffset == it) || (index < results.lastIndex && results[index + 1].startOffset == it) })
NEARBY_RESULT_ATTRIBUTES
else
DEFAULT_RESULT_ATTRIBUTES
highlighters.add(highlightMatch(editor, range.startOffset, range.endOffset, attributes))
} }
editor.vimIncsearchCurrentMatchOffset = currentMatchOffset editor.vimIncsearchCurrentMatchOffset = currentMatchOffset
} }
private fun highlightMatch(editor: Editor, start: Int, end: Int, current: Boolean, tooltip: String): RangeHighlighter { private fun highlightMatch(editor: Editor, start: Int, end: Int, attributes: TextAttributes): RangeHighlighter {
val layer = HighlighterLayer.SELECTION - 1 val layer = HighlighterLayer.SELECTION - 1
val targetArea = HighlighterTargetArea.EXACT_RANGE val targetArea = HighlighterTargetArea.EXACT_RANGE
if (!current) { return editor.markupModel.addRangeHighlighter(start, end, layer, attributes, targetArea)
// If we use a text attribute key, it will update automatically when the editor's colour scheme changes
val highlighter =
editor.markupModel.addRangeHighlighter(EditorColors.TEXT_SEARCH_RESULT_ATTRIBUTES, start, end, layer, targetArea)
highlighter.errorStripeTooltip = tooltip
return highlighter
}
// There isn't a text attribute key for current selection. This means we won't update automatically when the editor's
// colour scheme changes. However, this is only used during incsearch, so it should be replaced pretty quickly. It's a
// small visual glitch that will fix itself quickly. Let's not bother implementing an editor colour scheme listener
// just for this.
// These are the same modifications that the Find live preview does. We could look at using LivePreviewPresentation,
// which might also be useful for text attributes in selection (if we supported that)
val attributes = editor.colorsScheme.getAttributes(EditorColors.TEXT_SEARCH_RESULT_ATTRIBUTES).clone().apply {
effectType = EffectType.ROUNDED_BOX
effectColor = editor.colorsScheme.getColor(EditorColors.CARET_COLOR)
}
return editor.markupModel.addRangeHighlighter(start, end, layer, attributes, targetArea).apply {
errorStripeTooltip = tooltip
}
} }
/** /**

View File

@@ -19,6 +19,7 @@ import com.intellij.openapi.fileEditor.TextEditorWithPreview
import com.intellij.openapi.fileEditor.impl.text.TextEditorProvider import com.intellij.openapi.fileEditor.impl.text.TextEditorProvider
import com.intellij.openapi.util.registry.Registry import com.intellij.openapi.util.registry.Registry
import com.intellij.util.PlatformUtils import com.intellij.util.PlatformUtils
import com.maddyhome.idea.vim.VimPlugin
import com.maddyhome.idea.vim.api.ExecutionContext import com.maddyhome.idea.vim.api.ExecutionContext
import com.maddyhome.idea.vim.api.VimCaret import com.maddyhome.idea.vim.api.VimCaret
import com.maddyhome.idea.vim.api.VimEditor import com.maddyhome.idea.vim.api.VimEditor
@@ -28,6 +29,9 @@ import com.maddyhome.idea.vim.common.InsertSequence
import com.maddyhome.idea.vim.newapi.IjVimCaret import com.maddyhome.idea.vim.newapi.IjVimCaret
import com.maddyhome.idea.vim.newapi.globalIjOptions import com.maddyhome.idea.vim.newapi.globalIjOptions
import com.maddyhome.idea.vim.newapi.ij import com.maddyhome.idea.vim.newapi.ij
import com.maddyhome.idea.vim.state.mode.Mode
import com.maddyhome.idea.vim.state.mode.SelectionType
import com.maddyhome.idea.vim.state.mode.inVisualMode
import com.maddyhome.idea.vim.undo.VimTimestampBasedUndoService import com.maddyhome.idea.vim.undo.VimTimestampBasedUndoService
/** /**
@@ -51,6 +55,7 @@ class UndoRedoHelper : VimTimestampBasedUndoService {
val textEditor = getTextEditor(editor.ij) val textEditor = getTextEditor(editor.ij)
val undoManager = UndoManager.getInstance(project) val undoManager = UndoManager.getInstance(project)
if (undoManager.isUndoAvailable(textEditor)) { if (undoManager.isUndoAvailable(textEditor)) {
val caretCountBeforeUndo = editor.ij.caretModel.allCarets.size
val scrollingModel = editor.getScrollingModel() val scrollingModel = editor.getScrollingModel()
scrollingModel.accumulateViewportChanges() scrollingModel.accumulateViewportChanges()
@@ -58,6 +63,8 @@ class UndoRedoHelper : VimTimestampBasedUndoService {
scrollingModel.flushViewportChanges() scrollingModel.flushViewportChanges()
collapseRestoredBlockVisualCarets(editor, caretCountBeforeUndo)
return true return true
} }
return false return false
@@ -81,15 +88,7 @@ class UndoRedoHelper : VimTimestampBasedUndoService {
// TODO refactor me after VIM-308 when restoring selection and caret movement will be ignored by undo // TODO refactor me after VIM-308 when restoring selection and caret movement will be ignored by undo
editor.runWithChangeTracking { editor.runWithChangeTracking {
undoManager.undo(fileEditor) undoManager.undo(fileEditor)
restoreVisualMode(editor)
// We execute undo one more time if the previous one just restored selection
if (!hasChanges && hasSelection(editor) && undoManager.isUndoAvailable(fileEditor)) {
undoManager.undo(fileEditor)
}
}
CommandProcessor.getInstance().runUndoTransparentAction {
removeSelections(editor)
} }
} else { } else {
runWithBooleanRegistryOption("ide.undo.transparent.caret.movement", true) { runWithBooleanRegistryOption("ide.undo.transparent.caret.movement", true) {
@@ -196,6 +195,23 @@ class UndoRedoHelper : VimTimestampBasedUndoService {
} }
} }
/**
* VIM-4112. IntelliJ's undo restores the pre-edit `CaretState`; for a block-visual edit that
* means one caret per block row. A 1 → N caret-count jump across undo uniquely identifies
* this, since [com.maddyhome.idea.vim.helper.exitVisualMode] is the only flow that collapses
* multi-carets to one. The remaining caret is placed at the block's top-left, matching Vim's
* convention of cursor-at-start-of-undone-change.
*/
private fun collapseRestoredBlockVisualCarets(editor: VimEditor, caretCountBeforeUndo: Int) {
val caretModel = editor.ij.caretModel
val restoredExtraCarets = caretCountBeforeUndo == 1 && caretModel.allCarets.size > 1
if (!restoredExtraCarets || editor.mode !is Mode.NORMAL) return
val blockTopOffset = caretModel.allCarets.minOf { it.offset }
caretModel.removeSecondaryCarets()
caretModel.primaryCaret.moveToOffset(blockTopOffset)
}
private fun removeSelections(editor: VimEditor) { private fun removeSelections(editor: VimEditor) {
editor.carets().forEach { editor.carets().forEach {
val ijCaret = it.ij val ijCaret = it.ij
@@ -240,4 +256,21 @@ class UndoRedoHelper : VimTimestampBasedUndoService {
val hasChanges: Boolean val hasChanges: Boolean
get() = changeListener.hasChanged || initialPath != editor.getPath() get() = changeListener.hasChanged || initialPath != editor.getPath()
} }
private fun restoreVisualMode(editor: VimEditor) {
if (!editor.inVisualMode && editor.ij.selectionModel.hasSelection()) {
val detectedMode = VimPlugin.getVisualMotion().detectSelectionType(editor)
// Visual block selection is restored into multiple carets, so multi-carets that form a block are always
// identified as visual block mode, leading to false positives.
// Since I use visual block mode much less often than multi-carets, this is a judgment call to never restore
// visual block mode.
val wantedMode = if (detectedMode == SelectionType.BLOCK_WISE)
SelectionType.CHARACTER_WISE
else
detectedMode
VimPlugin.getVisualMotion().enterVisualMode(editor, wantedMode)
}
}
} }

View File

@@ -18,7 +18,6 @@ import com.intellij.openapi.editor.VisualPosition
import com.intellij.openapi.editor.markup.RangeHighlighter import com.intellij.openapi.editor.markup.RangeHighlighter
import com.intellij.openapi.util.Key import com.intellij.openapi.util.Key
import com.intellij.openapi.util.UserDataHolder import com.intellij.openapi.util.UserDataHolder
import com.maddyhome.idea.vim.api.CaretRegisterStorageBase
import com.maddyhome.idea.vim.api.LocalMarkStorage import com.maddyhome.idea.vim.api.LocalMarkStorage
import com.maddyhome.idea.vim.api.SelectionInfo import com.maddyhome.idea.vim.api.SelectionInfo
import com.maddyhome.idea.vim.api.VimOutputPanel import com.maddyhome.idea.vim.api.VimOutputPanel
@@ -97,9 +96,8 @@ var Caret.vimInsertStart: RangeMarker by userDataOr {
} }
// TODO: Data could be lost during visual block motion // TODO: Data could be lost during visual block motion
var Caret.registerStorage: CaretRegisterStorageBase? by userDataCaretToEditor() internal var Caret.markStorage: LocalMarkStorage? by userDataCaretToEditor()
var Caret.markStorage: LocalMarkStorage? by userDataCaretToEditor() internal var Caret.lastSelectionInfo: SelectionInfo? by userDataCaretToEditor()
var Caret.lastSelectionInfo: SelectionInfo? by userDataCaretToEditor()
var Editor.vimInitialised: Boolean by userDataOr { false } var Editor.vimInitialised: Boolean by userDataOr { false }

View File

@@ -64,8 +64,10 @@ class IJEditorFocusListener : EditorListener {
VimPlugin.getChange().insertBeforeCaret(editor, context) VimPlugin.getChange().insertBeforeCaret(editor, context)
KeyHandler.getInstance().lastUsedEditorInfo = LastUsedEditorInfo(currentEditorHashCode, true) KeyHandler.getInstance().lastUsedEditorInfo = LastUsedEditorInfo(currentEditorHashCode, true)
} }
if (isCurrentEditorTerminal && !ijEditor.inInsertMode) { if (isCurrentEditorTerminal) {
switchToInsertMode.run() if (!ijEditor.inInsertMode) {
switchToInsertMode.run()
}
} else if (ijEditor.isInsertMode && (oldEditorInfo.isInsertModeForced || !ijEditor.document.isWritable)) { } else if (ijEditor.isInsertMode && (oldEditorInfo.isInsertModeForced || !ijEditor.document.isWritable)) {
val context: ExecutionContext = injector.executionContextManager.getEditorExecutionContext(editor) val context: ExecutionContext = injector.executionContextManager.getEditorExecutionContext(editor)
val mode = injector.vimState.mode val mode = injector.vimState.mode

View File

@@ -17,7 +17,9 @@ import com.intellij.codeInsight.template.Template
import com.intellij.codeInsight.template.TemplateEditingAdapter import com.intellij.codeInsight.template.TemplateEditingAdapter
import com.intellij.codeInsight.template.TemplateManagerListener import com.intellij.codeInsight.template.TemplateManagerListener
import com.intellij.codeInsight.template.impl.TemplateImpl import com.intellij.codeInsight.template.impl.TemplateImpl
import com.intellij.codeInsight.template.impl.TemplateManagerImpl
import com.intellij.codeInsight.template.impl.TemplateState import com.intellij.codeInsight.template.impl.TemplateState
import com.intellij.codeInsight.template.impl.actions.NextVariableAction
import com.intellij.find.FindModelListener import com.intellij.find.FindModelListener
import com.intellij.ide.actions.ApplyIntentionAction import com.intellij.ide.actions.ApplyIntentionAction
import com.intellij.openapi.actionSystem.ActionManager import com.intellij.openapi.actionSystem.ActionManager
@@ -33,6 +35,7 @@ import com.intellij.openapi.actionSystem.impl.ProxyShortcutSet
import com.intellij.openapi.editor.Editor import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.RangeMarker import com.intellij.openapi.editor.RangeMarker
import com.intellij.openapi.editor.actions.EnterAction import com.intellij.openapi.editor.actions.EnterAction
import com.intellij.openapi.editor.impl.ScrollingModelImpl
import com.intellij.openapi.fileEditor.FileEditorManager import com.intellij.openapi.fileEditor.FileEditorManager
import com.intellij.openapi.keymap.KeymapManager import com.intellij.openapi.keymap.KeymapManager
import com.intellij.openapi.project.DumbAwareToggleAction import com.intellij.openapi.project.DumbAwareToggleAction
@@ -48,7 +51,6 @@ import com.maddyhome.idea.vim.group.NotificationService
import com.maddyhome.idea.vim.group.visual.IdeaSelectionControl import com.maddyhome.idea.vim.group.visual.IdeaSelectionControl
import com.maddyhome.idea.vim.helper.exitSelectMode import com.maddyhome.idea.vim.helper.exitSelectMode
import com.maddyhome.idea.vim.helper.exitVisualMode import com.maddyhome.idea.vim.helper.exitVisualMode
import com.maddyhome.idea.vim.helper.hasVisualSelection
import com.maddyhome.idea.vim.helper.isIdeaVimDisabledHere import com.maddyhome.idea.vim.helper.isIdeaVimDisabledHere
import com.maddyhome.idea.vim.ide.isClionNova import com.maddyhome.idea.vim.ide.isClionNova
import com.maddyhome.idea.vim.ide.isRider import com.maddyhome.idea.vim.ide.isRider
@@ -75,6 +77,7 @@ internal object IdeaSpecifics {
private val surrounderAction = private val surrounderAction =
"com.intellij.codeInsight.generation.surroundWith.SurroundWithHandler\$InvokeSurrounderAction" "com.intellij.codeInsight.generation.surroundWith.SurroundWithHandler\$InvokeSurrounderAction"
private var editor: Editor? = null private var editor: Editor? = null
private var caretOffset = -1
private var completionData: CompletionData? = null private var completionData: CompletionData? = null
override fun beforeActionPerformed(action: AnAction, event: AnActionEvent) { override fun beforeActionPerformed(action: AnAction, event: AnActionEvent) {
@@ -83,6 +86,7 @@ internal object IdeaSpecifics {
val hostEditor = event.dataContext.getData(CommonDataKeys.HOST_EDITOR) val hostEditor = event.dataContext.getData(CommonDataKeys.HOST_EDITOR)
if (hostEditor != null) { if (hostEditor != null) {
editor = hostEditor editor = hostEditor
caretOffset = hostEditor.caretModel.offset
} }
val actionId = ActionManager.getInstance().getId(action) val actionId = ActionManager.getInstance().getId(action)
@@ -164,26 +168,46 @@ internal object IdeaSpecifics {
if (VimPlugin.isNotEnabled()) return if (VimPlugin.isNotEnabled()) return
val editor = editor val editor = editor
if (editor != null && action is ChooseItemAction && injector.registerGroup.isRecording) { if (editor != null) {
completionData?.recordCompletion(editor, VimPlugin.getRegister()) if (action is ChooseItemAction && injector.registerGroup.isRecording) {
} completionData?.recordCompletion(editor, VimPlugin.getRegister()
)
}
//region Enter insert mode after surround with if //region Enter insert mode after surround with if
if (surrounderAction == action.javaClass.name && surrounderItems.any { if (surrounderAction == action.javaClass.name && surrounderItems.any {
action.templatePresentation.text.endsWith( action.templatePresentation.text.endsWith(
it, it,
) )
}
) {
editor?.let {
it.vim.mode = Mode.NORMAL()
VimPlugin.getChange().insertBeforeCaret(it.vim, event.dataContext.vim)
KeyHandler.getInstance().reset(it.vim)
}
} }
) { else if (action is NextVariableAction && TemplateManagerImpl.getTemplateState(editor) == null) {
editor?.let { editor.vim.exitInsertMode(event.dataContext.vim)
it.vim.mode = Mode.NORMAL() KeyHandler.getInstance().reset(editor.vim)
VimPlugin.getChange().insertBeforeCaret(it.vim, event.dataContext.vim) }
KeyHandler.getInstance().reset(it.vim) //endregion
if (caretOffset != -1 && caretOffset != editor.caretModel.offset) {
val scrollModel = editor.scrollingModel as ScrollingModelImpl
if (scrollModel.isScrollingNow) {
val v = scrollModel.verticalScrollOffset
val h = scrollModel.horizontalScrollOffset
scrollModel.finishAnimation()
scrollModel.scroll(h, v)
scrollModel.finishAnimation()
}
injector.scroll.scrollCaretIntoView(editor.vim)
} }
} }
//endregion
this.editor = null this.editor = null
this.caretOffset = -1
this.completionData?.dispose() this.completionData?.dispose()
this.completionData = null this.completionData = null
@@ -331,23 +355,6 @@ internal object IdeaSpecifics {
vimEditor.exitMode() vimEditor.exitMode()
vimEditor.mode = Mode.NORMAL() vimEditor.mode = Mode.NORMAL()
} }
} else {
// IdeaSelectionControl will not be called if we're moving to a new variable with no change in selection.
// And if we're moving to the end of the template, the change in selection will reset us to Normal because
// IdeaSelectionControl will be called when the template is no longer active.
if ((!editor.selectionModel.hasSelection() && !vimEditor.mode.hasVisualSelection) || newIndex == -1) {
if (vimEditor.isIdeaRefactorModeSelect) {
if (vimEditor.mode !is Mode.INSERT) {
vimEditor.exitMode()
injector.application.runReadAction {
val context = injector.executionContextManager.getEditorExecutionContext(editor.vim)
VimPlugin.getChange().insertBeforeCaret(editor.vim, context)
}
}
} else {
vimEditor.mode = Mode.NORMAL()
}
}
} }
} }
} }

View File

@@ -0,0 +1,33 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package com.maddyhome.idea.vim.listener
import com.intellij.openapi.application.ApplicationActivationListener
import com.intellij.openapi.wm.IdeFrame
import com.maddyhome.idea.vim.VimPlugin
import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.autocmd.AutoCmdEvent
/**
* Fires FocusGained/FocusLost autocmd events when the IDE window gains or loses OS-level focus.
* This matches Vim's behavior where these events fire on application-level focus changes (e.g., alt-tab),
* not on editor-level focus changes within the IDE.
*/
class VimAppActivationListener : ApplicationActivationListener {
override fun applicationActivated(ideFrame: IdeFrame) {
if (VimPlugin.isNotEnabled()) return
injector.autoCmd.handleEvent(AutoCmdEvent.FocusGained)
}
override fun applicationDeactivated(ideFrame: IdeFrame) {
if (VimPlugin.isNotEnabled()) return
injector.autoCmd.handleEvent(AutoCmdEvent.FocusLost)
}
}

View File

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

View File

@@ -0,0 +1,80 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package com.maddyhome.idea.vim.newapi
import com.intellij.openapi.editor.FoldRegion
/**
* Computes nesting depth for each fold region in O(N log N).
*
* A fold's depth is the count of other folds that contain it by offset range,
* excluding folds with an identical (start, end) range.
*/
internal object FoldDepthCalculator {
fun computeDepths(folds: Array<FoldRegion>): IntArray {
if (folds.isEmpty()) return IntArray(0)
val ranges = FoldRanges.from(folds)
return ranges.sweepDepths(ranges.orderOuterFirst())
}
}
private class FoldRanges(private val starts: IntArray, private val ends: IntArray) {
val size: Int get() = starts.size
fun orderOuterFirst(): IntArray =
(0 until size).sortedWith(byStartAscendingEndDescending()).toIntArray()
fun sweepDepths(orderedFolds: IntArray): IntArray {
val depths = IntArray(size)
val openFolds = IntArray(size)
var openCount = 0
for (fold in orderedFolds) {
openCount = dropFoldsClosedBefore(openFolds, openCount, fold)
val duplicates = countDuplicatesAtTop(openFolds, openCount, fold)
depths[fold] = openCount - duplicates
openFolds[openCount++] = fold
}
return depths
}
private fun byStartAscendingEndDescending() = Comparator<Int> { a, b ->
val byStart = starts[a].compareTo(starts[b])
if (byStart != 0) byStart else ends[b].compareTo(ends[a])
}
private fun dropFoldsClosedBefore(stack: IntArray, stackSize: Int, fold: Int): Int {
var size = stackSize
val foldStart = starts[fold]
while (size > 0 && ends[stack[size - 1]] <= foldStart) size--
return size
}
private fun countDuplicatesAtTop(stack: IntArray, stackSize: Int, fold: Int): Int {
var count = 0
var i = stackSize - 1
while (i >= 0 && hasSameRange(stack[i], fold)) {
count++
i--
}
return count
}
private fun hasSameRange(a: Int, b: Int): Boolean =
starts[a] == starts[b] && ends[a] == ends[b]
companion object {
fun from(folds: Array<FoldRegion>): FoldRanges {
val starts = IntArray(folds.size) { folds[it].startOffset }
val ends = IntArray(folds.size) { folds[it].endOffset }
return FoldRanges(starts, ends)
}
}
}

View File

@@ -38,7 +38,7 @@ import java.io.IOException
internal class IjClipboardManager : VimClipboardManager { internal class IjClipboardManager : VimClipboardManager {
override fun getPrimaryContent(editor: VimEditor, context: ExecutionContext): IjVimCopiedText? { override fun getPrimaryContent(): IjVimCopiedText? {
val clipboard = Toolkit.getDefaultToolkit()?.systemSelection ?: return null val clipboard = Toolkit.getDefaultToolkit()?.systemSelection ?: return null
val contents = clipboard.getContents(null) ?: return null val contents = clipboard.getContents(null) ?: return null
val (text, transferableData) = getTextAndTransferableData(contents) ?: return null val (text, transferableData) = getTextAndTransferableData(contents) ?: return null
@@ -241,6 +241,6 @@ internal class IjClipboardManager : VimClipboardManager {
} }
} }
data class IjVimCopiedText(override val text: String, val transferableData: List<Any>) : VimCopiedText { data class IjVimCopiedText(override val text: String, override val transferableData: List<Any>) : VimCopiedText {
override fun updateText(newText: String): VimCopiedText = IjVimCopiedText(newText, transferableData) override fun updateText(newText: String): VimCopiedText = IjVimCopiedText(newText, transferableData)
} }

View File

@@ -12,8 +12,6 @@ import com.intellij.openapi.editor.Caret
import com.intellij.openapi.editor.LogicalPosition import com.intellij.openapi.editor.LogicalPosition
import com.intellij.openapi.editor.VisualPosition import com.intellij.openapi.editor.VisualPosition
import com.maddyhome.idea.vim.api.BufferPosition import com.maddyhome.idea.vim.api.BufferPosition
import com.maddyhome.idea.vim.api.CaretRegisterStorage
import com.maddyhome.idea.vim.api.CaretRegisterStorageBase
import com.maddyhome.idea.vim.api.ImmutableVimCaret import com.maddyhome.idea.vim.api.ImmutableVimCaret
import com.maddyhome.idea.vim.api.LocalMarkStorage import com.maddyhome.idea.vim.api.LocalMarkStorage
import com.maddyhome.idea.vim.api.SelectionInfo import com.maddyhome.idea.vim.api.SelectionInfo
@@ -21,6 +19,7 @@ import com.maddyhome.idea.vim.api.VimCaret
import com.maddyhome.idea.vim.api.VimCaretBase import com.maddyhome.idea.vim.api.VimCaretBase
import com.maddyhome.idea.vim.api.VimEditor import com.maddyhome.idea.vim.api.VimEditor
import com.maddyhome.idea.vim.api.VimVisualPosition import com.maddyhome.idea.vim.api.VimVisualPosition
import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.common.InsertSequence import com.maddyhome.idea.vim.common.InsertSequence
import com.maddyhome.idea.vim.common.LiveRange import com.maddyhome.idea.vim.common.LiveRange
import com.maddyhome.idea.vim.group.visual.VisualChange import com.maddyhome.idea.vim.group.visual.VisualChange
@@ -29,7 +28,6 @@ import com.maddyhome.idea.vim.helper.insertHistory
import com.maddyhome.idea.vim.helper.lastSelectionInfo import com.maddyhome.idea.vim.helper.lastSelectionInfo
import com.maddyhome.idea.vim.helper.markStorage import com.maddyhome.idea.vim.helper.markStorage
import com.maddyhome.idea.vim.helper.moveToInlayAwareOffset import com.maddyhome.idea.vim.helper.moveToInlayAwareOffset
import com.maddyhome.idea.vim.helper.registerStorage
import com.maddyhome.idea.vim.helper.resetVimLastColumn import com.maddyhome.idea.vim.helper.resetVimLastColumn
import com.maddyhome.idea.vim.helper.vimInsertStart import com.maddyhome.idea.vim.helper.vimInsertStart
import com.maddyhome.idea.vim.helper.vimLastColumn import com.maddyhome.idea.vim.helper.vimLastColumn
@@ -37,22 +35,14 @@ import com.maddyhome.idea.vim.helper.vimLastVisualOperatorRange
import com.maddyhome.idea.vim.helper.vimLine import com.maddyhome.idea.vim.helper.vimLine
import com.maddyhome.idea.vim.helper.vimSelectionStart import com.maddyhome.idea.vim.helper.vimSelectionStart
import com.maddyhome.idea.vim.helper.vimSelectionStartClear import com.maddyhome.idea.vim.helper.vimSelectionStartClear
import com.maddyhome.idea.vim.register.VimRegisterGroup
import com.maddyhome.idea.vim.state.mode.SelectionType import com.maddyhome.idea.vim.state.mode.SelectionType
class IjVimCaret(val caret: Caret) : VimCaretBase() { class IjVimCaret(val caret: Caret) : VimCaretBase() {
override val registerStorage: CaretRegisterStorage override val registerStorage: VimRegisterGroup
get() { get() = injector.registerGroup
var storage = this.caret.registerStorage
if (storage == null) {
initInjector() // To initialize injector used in CaretRegisterStorageBase
storage = CaretRegisterStorageBase(this)
this.caret.registerStorage = storage
} else if (storage.caret != this) {
storage.caret = this
}
return storage
}
override val markStorage: LocalMarkStorage override val markStorage: LocalMarkStorage
get() { get() {
var storage = this.caret.markStorage var storage = this.caret.markStorage

View File

@@ -22,6 +22,7 @@ import com.intellij.openapi.editor.ex.ScrollingModelEx
import com.intellij.openapi.editor.ex.util.EditorUtil import com.intellij.openapi.editor.ex.util.EditorUtil
import com.intellij.openapi.editor.impl.CaretModelImpl import com.intellij.openapi.editor.impl.CaretModelImpl
import com.intellij.openapi.editor.impl.EditorImpl import com.intellij.openapi.editor.impl.EditorImpl
import com.intellij.openapi.util.text.StringUtil
import com.intellij.openapi.vfs.VirtualFileManager import com.intellij.openapi.vfs.VirtualFileManager
import com.intellij.platform.project.projectId import com.intellij.platform.project.projectId
import com.maddyhome.idea.vim.api.BufferPosition import com.maddyhome.idea.vim.api.BufferPosition
@@ -150,7 +151,7 @@ class IjVimEditor(editor: Editor) : MutableLinearEditor, VimEditorBase() {
} }
} }
if (!isWritable()) return if (!isWritable()) return
editor.document.insertString(atPosition, text) editor.document.insertString(atPosition, StringUtil.convertLineSeparators(text, "\n"))
} }
override fun replaceString(start: Int, end: Int, newString: String) { override fun replaceString(start: Int, end: Int, newString: String) {
@@ -179,21 +180,38 @@ class IjVimEditor(editor: Editor) : MutableLinearEditor, VimEditorBase() {
return editor.caretModel.allCarets.map { IjVimCaret(it) } return editor.caretModel.allCarets.map { IjVimCaret(it) }
} }
override var isFirstCaret = true
override var isReversingCarets = false
@Suppress("ideavimRunForEachCaret") @Suppress("ideavimRunForEachCaret")
override fun forEachCaret(action: (VimCaret) -> Unit) { override fun forEachCaret(action: (VimCaret) -> Unit) {
if (editor.vim.inBlockSelection) { if (editor.vim.inBlockSelection) {
action(IjVimCaret(editor.caretModel.primaryCaret)) action(IjVimCaret(editor.caretModel.primaryCaret))
} else { } else {
editor.caretModel.runForEachCaret({ try {
if (it.isValid) { editor.caretModel.runForEachCaret({
action(IjVimCaret(it)) if (it.isValid) {
} action(IjVimCaret(it))
}, false) isFirstCaret = false
}
}, false)
} finally {
isFirstCaret = true
}
} }
} }
override fun forEachNativeCaret(action: (VimCaret) -> Unit, reverse: Boolean) { override fun forEachNativeCaret(action: (VimCaret) -> Unit, reverse: Boolean) {
editor.caretModel.runForEachCaret({ action(IjVimCaret(it)) }, reverse) isReversingCarets = reverse
try {
editor.caretModel.runForEachCaret({
action(IjVimCaret(it))
isFirstCaret = false
}, reverse)
} finally {
isFirstCaret = true
isReversingCarets = false
}
} }
override fun isInForEachCaretScope(): Boolean { override fun isInForEachCaretScope(): Boolean {
@@ -496,12 +514,11 @@ class IjVimEditor(editor: Editor) : MutableLinearEditor, VimEditorBase() {
val allFolds = editor.foldingModel.allFoldRegions val allFolds = editor.foldingModel.allFoldRegions
if (allFolds.isEmpty()) return if (allFolds.isEmpty()) return
val depths = FoldDepthCalculator.computeDepths(allFolds)
editor.foldingModel.runBatchFoldingOperation { editor.foldingModel.runBatchFoldingOperation {
// I'm aware it's O(n^2) comparison here, for (i in allFolds.indices) {
// but it doesn't affect performance even on a large amount of fold allFolds[i].isExpanded = depths[i] < foldLevel
allFolds.forEach { fold ->
val depth = calculateFoldDepth(fold, allFolds)
fold.isExpanded = depth < foldLevel
} }
} }
} }
@@ -510,9 +527,10 @@ class IjVimEditor(editor: Editor) : MutableLinearEditor, VimEditorBase() {
val allFolds = editor.foldingModel.allFoldRegions val allFolds = editor.foldingModel.allFoldRegions
if (allFolds.isEmpty()) return 0 if (allFolds.isEmpty()) return 0
return allFolds.maxOfOrNull { fold -> val depths = FoldDepthCalculator.computeDepths(allFolds)
calculateFoldDepth(fold, allFolds) var max = 0
} ?: 0 for (d in depths) if (d > max) max = d
return max
} }
override fun createFoldRegion(startOffset: Int, endOffset: Int, collapse: Boolean): VimFoldRegion? { override fun createFoldRegion(startOffset: Int, endOffset: Int, collapse: Boolean): VimFoldRegion? {
@@ -571,27 +589,6 @@ class IjVimEditor(editor: Editor) : MutableLinearEditor, VimEditorBase() {
.minByOrNull { fold -> fold.endOffset - fold.startOffset } .minByOrNull { fold -> fold.endOffset - fold.startOffset }
} }
private fun calculateFoldDepth(fold: FoldRegion, allFolds: Array<FoldRegion>): Int {
return allFolds.count { otherFold ->
isWrappedBy(fold, otherFold)
}
}
/**
* Returns true if the inner fold is completely contained by the outer fold (allowing matching boundaries)
* but excludes identical folds.
*/
private fun isWrappedBy(inner: FoldRegion, outer: FoldRegion): Boolean {
return outer.startOffset <= inner.startOffset &&
outer.endOffset >= inner.endOffset &&
areDifferentFolds(inner, outer)
}
private fun areDifferentFolds(
first: FoldRegion,
second: FoldRegion,
): Boolean = first.startOffset != second.startOffset || first.endOffset != second.endOffset
private fun toVimFoldRegion(ijFoldRegion: FoldRegion): VimFoldRegion { private fun toVimFoldRegion(ijFoldRegion: FoldRegion): VimFoldRegion {
return IjVimFoldRegion(ijFoldRegion, editor) return IjVimFoldRegion(ijFoldRegion, editor)
} }
@@ -613,6 +610,10 @@ class IjVimEditor(editor: Editor) : MutableLinearEditor, VimEditorBase() {
get() = ijFoldRegion.endOffset get() = ijFoldRegion.endOffset
} }
override fun getSoftWrapStartAtOffset(offset: Int): Int? {
return editor.softWrapModel.getSoftWrap(offset)?.start
}
override fun <T : ImmutableVimCaret> findLastVersionOfCaret(caret: T): T { override fun <T : ImmutableVimCaret> findLastVersionOfCaret(caret: T): T {
return caret return caret
} }

View File

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

View File

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

View File

@@ -13,6 +13,7 @@ import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.editor.Editor import com.intellij.openapi.editor.Editor
import com.intellij.openapi.wm.ex.ToolWindowManagerListener import com.intellij.openapi.wm.ex.ToolWindowManagerListener
import com.intellij.openapi.wm.impl.IdeBackgroundUtil import com.intellij.openapi.wm.impl.IdeBackgroundUtil
import com.intellij.openapi.wm.impl.ToolWindowManagerImpl
import com.intellij.ui.ClientProperty import com.intellij.ui.ClientProperty
import com.intellij.ui.JBColor import com.intellij.ui.JBColor
import com.intellij.ui.components.JBPanel import com.intellij.ui.components.JBPanel
@@ -102,6 +103,7 @@ class OutputPanel private constructor(
val keyListener = OutputPanelKeyListener() val keyListener = OutputPanelKeyListener()
addKeyListener(keyListener) addKeyListener(keyListener)
textPane.addKeyListener(keyListener) textPane.addKeyListener(keyListener)
editor?.let { putClientProperty(ToolWindowManagerImpl.PARENT_COMPONENT, it.component) }
updateUI() updateUI()
} }
@@ -252,17 +254,11 @@ class OutputPanel private constructor(
return textPane.getBackground() return textPane.getBackground()
} }
/** fun deactivate() {
* Turns off the output panel and optionally puts the focus back to the original component.
*/
fun deactivate(refocusOwningEditor: Boolean) {
if (!active) return if (!active) return
active = false active = false
clearText() clearText()
textPane.text = "" textPane.text = ""
if (refocusOwningEditor) {
requestFocus(editor.contentComponent)
}
if (glassPane != null) { if (glassPane != null) {
glassPane!!.removeComponentListener(resizeAdapter) glassPane!!.removeComponentListener(resizeAdapter)
toolWindowListenerConnection?.disconnect() toolWindowListenerConnection?.disconnect()
@@ -319,7 +315,7 @@ class OutputPanel private constructor(
fun close(key: KeyStroke?) { fun close(key: KeyStroke?) {
val passKeyBack = isSingleLine val passKeyBack = isSingleLine
ApplicationManager.getApplication().invokeLater { ApplicationManager.getApplication().invokeLater {
deactivate(true) deactivate()
val project = editor.project val project = editor.project
// For single line messages, pass any key back to the editor (including Enter) // For single line messages, pass any key back to the editor (including Enter)
// For multi-line messages, don't pass Enter back (it was used to dismiss) // For multi-line messages, don't pass Enter back (it was used to dismiss)

View File

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

View File

@@ -16,14 +16,15 @@ import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.colors.EditorColors import com.intellij.openapi.editor.colors.EditorColors
import com.intellij.openapi.wm.IdeFocusManager import com.intellij.openapi.wm.IdeFocusManager
import com.intellij.openapi.wm.ex.ToolWindowManagerListener import com.intellij.openapi.wm.ex.ToolWindowManagerListener
import com.maddyhome.idea.vim.ui.ToolWindowPositioningListener import com.intellij.openapi.wm.impl.ToolWindowManagerImpl
import com.intellij.util.messages.MessageBusConnection
import com.intellij.ui.DocumentAdapter import com.intellij.ui.DocumentAdapter
import com.intellij.util.IJSwingUtilities import com.intellij.util.IJSwingUtilities
import com.intellij.util.messages.MessageBusConnection
import com.maddyhome.idea.vim.EventFacade import com.maddyhome.idea.vim.EventFacade
import com.maddyhome.idea.vim.KeyHandler.Companion.getInstance import com.maddyhome.idea.vim.KeyHandler.Companion.getInstance
import com.maddyhome.idea.vim.VimPlugin import com.maddyhome.idea.vim.VimPlugin
import com.maddyhome.idea.vim.action.VimShortcutKeyAction import com.maddyhome.idea.vim.action.VimShortcutKeyAction
import com.maddyhome.idea.vim.api.CommandLineCompletion
import com.maddyhome.idea.vim.api.VimCommandLine import com.maddyhome.idea.vim.api.VimCommandLine
import com.maddyhome.idea.vim.api.VimCommandLineCaret import com.maddyhome.idea.vim.api.VimCommandLineCaret
import com.maddyhome.idea.vim.api.VimEditor import com.maddyhome.idea.vim.api.VimEditor
@@ -41,6 +42,7 @@ import com.maddyhome.idea.vim.key.interceptors.VimInputInterceptor
import com.maddyhome.idea.vim.newapi.IjVimCaret import com.maddyhome.idea.vim.newapi.IjVimCaret
import com.maddyhome.idea.vim.newapi.IjVimEditor import com.maddyhome.idea.vim.newapi.IjVimEditor
import com.maddyhome.idea.vim.ui.ExPanelBorder import com.maddyhome.idea.vim.ui.ExPanelBorder
import com.maddyhome.idea.vim.ui.ToolWindowPositioningListener
import com.maddyhome.idea.vim.vimscript.model.commands.Command import com.maddyhome.idea.vim.vimscript.model.commands.Command
import com.maddyhome.idea.vim.vimscript.model.commands.GlobalCommand import com.maddyhome.idea.vim.vimscript.model.commands.GlobalCommand
import com.maddyhome.idea.vim.vimscript.model.commands.SubstituteCommand import com.maddyhome.idea.vim.vimscript.model.commands.SubstituteCommand
@@ -80,6 +82,46 @@ class ExEntryPanel private constructor() : JPanel(), VimCommandLine {
var context: DataContext? = null var context: DataContext? = null
override var histIndex: Int = 0 override var histIndex: Int = 0
override var lastEntry: String? = null override var lastEntry: String? = null
override var activeCompletion: CommandLineCompletion? = null
override fun isExCommand(): Boolean {
return getLabel().startsWith(":")
}
override fun showCompletionBar(completion: CommandLineCompletion) {
if (ApplicationManager.getApplication().isUnitTestMode) return
val editor = this.ijEditor ?: return
completionPanel.updateColors(editor.colorsScheme.defaultForeground, entry.getBackground())
completionPanel.updateFont(entry.getFont())
completionPanel.setItems(completion.displayNames, completion.currentIndex)
if (!isCompletionBarVisible) {
oldGlass?.add(completionPanel)
isCompletionBarVisible = true
}
positionCompletionPanel()
}
override fun selectCompletionItem(selectedIndex: Int?) {
if (!isCompletionBarVisible || selectedIndex == null) return
completionPanel.setSelectedIndex(selectedIndex)
}
override fun hideCompletionBar() {
if (!isCompletionBarVisible) return
isCompletionBarVisible = false
oldGlass?.remove(completionPanel)
oldGlass?.repaint()
}
private fun dismissCompletionIfTextChanged() {
val completion = activeCompletion ?: return
if (text != completion.expectedText) {
activeCompletion = null
hideCompletionBar()
}
}
val ijEditor: Editor? val ijEditor: Editor?
get() = if (weakEditor != null) weakEditor!!.get() else null get() = if (weakEditor != null) weakEditor!!.get() else null
@@ -152,6 +194,7 @@ class ExEntryPanel private constructor() : JPanel(), VimCommandLine {
} }
positionPanel() positionPanel()
glassPane.isVisible = true glassPane.isVisible = true
putClientProperty(ToolWindowManagerImpl.PARENT_COMPONENT, parent)
entry.requestFocusInWindow() entry.requestFocusInWindow()
} }
this.isActive = true this.isActive = true
@@ -169,6 +212,8 @@ class ExEntryPanel private constructor() : JPanel(), VimCommandLine {
if (!this.isActive) return if (!this.isActive) return
clearPromptCharacter() clearPromptCharacter()
hideCompletionBar()
activeCompletion = null
try { try {
entry.document.removeDocumentListener(fontListener) entry.document.removeDocumentListener(fontListener)
// incsearch won't change in the lifetime of this activation // incsearch won't change in the lifetime of this activation
@@ -199,6 +244,7 @@ class ExEntryPanel private constructor() : JPanel(), VimCommandLine {
requestFocus(parent!!) requestFocus(parent!!)
} }
putClientProperty(ToolWindowManagerImpl.PARENT_COMPONENT, null)
oldGlass!!.removeComponentListener(resizePanelListener) oldGlass!!.removeComponentListener(resizePanelListener)
toolWindowListenerConnection?.disconnect() toolWindowListenerConnection?.disconnect()
toolWindowListenerConnection = null toolWindowListenerConnection = null
@@ -250,6 +296,7 @@ class ExEntryPanel private constructor() : JPanel(), VimCommandLine {
} }
} }
private val incSearchDocumentListener: DocumentListener = object : DocumentAdapter() { private val incSearchDocumentListener: DocumentListener = object : DocumentAdapter() {
override fun textChanged(e: DocumentEvent) { override fun textChanged(e: DocumentEvent) {
try { try {
@@ -294,7 +341,7 @@ class ExEntryPanel private constructor() : JPanel(), VimCommandLine {
.calculateCount0Snapshot() .calculateCount0Snapshot()
) )
if (labelText == "/" || labelText == "?" || searchCommand) { if ((labelText == "/" || labelText == "?" || searchCommand) && !injector.macro.isExecutingMacro) {
val forwards = labelText != "?" // :s, :g, :v are treated as forwards val forwards = labelText != "?" // :s, :g, :v are treated as forwards
val patternEnd: Int = injector.searchGroup.findEndOfPattern(searchText, separator, 0) val patternEnd: Int = injector.searchGroup.findEndOfPattern(searchText, separator, 0)
val pattern = searchText.take(patternEnd) val pattern = searchText.take(patternEnd)
@@ -485,6 +532,25 @@ class ExEntryPanel private constructor() : JPanel(), VimCommandLine {
setBounds(bounds) setBounds(bounds)
repaint() repaint()
} }
if (isCompletionBarVisible) {
positionCompletionPanel()
}
}
private fun positionCompletionPanel() {
val myBounds = bounds
if (myBounds.width == 0) return
val completionHeight = completionPanel.preferredSize.height
completionPanel.setBounds(
myBounds.x,
myBounds.y - completionHeight,
myBounds.width,
completionHeight,
)
completionPanel.revalidate()
completionPanel.repaint()
} }
private val isIncSearchEnabled: Boolean private val isIncSearchEnabled: Boolean
@@ -505,6 +571,8 @@ class ExEntryPanel private constructor() : JPanel(), VimCommandLine {
private var oldGlass: JComponent? = null private var oldGlass: JComponent? = null
private var oldLayout: LayoutManager? = null private var oldLayout: LayoutManager? = null
private var wasOpaque = false private var wasOpaque = false
private val completionPanel = ExCompletionPanel()
private var isCompletionBarVisible = false
// incsearch stuff // incsearch stuff
private var verticalOffset = 0 private var verticalOffset = 0
@@ -553,10 +621,13 @@ class ExEntryPanel private constructor() : JPanel(), VimCommandLine {
entry.updateText(string) entry.updateText(string)
if (updateLastEntry) entry.saveLastEntry() if (updateLastEntry) entry.saveLastEntry()
caret.offset = min(offset, text.length) caret.offset = min(offset, text.length)
dismissCompletionIfTextChanged()
} }
override fun deleteText(offset: Int, length: Int) { override fun deleteText(offset: Int, length: Int) {
entry.deleteText(offset, length) entry.deleteText(offset, length)
dismissCompletionIfTextChanged()
} }
override fun insertText(offset: Int, string: String) { override fun insertText(offset: Int, string: String) {
@@ -565,6 +636,7 @@ class ExEntryPanel private constructor() : JPanel(), VimCommandLine {
entry.deleteText(offset, string.length) entry.deleteText(offset, string.length)
} }
entry.insertText(offset, string) entry.insertText(offset, string)
dismissCompletionIfTextChanged()
} }
override fun clearCurrentAction() { override fun clearCurrentAction() {

View File

@@ -96,7 +96,7 @@ data class ReadCommand(val range: Range, val modifier: CommandModifier, val argu
val copiedText = injector.clipboardManager.dumbCopiedText(content) val copiedText = injector.clipboardManager.dumbCopiedText(content)
val caret = editor.currentCaret() val caret = editor.currentCaret()
val address = if (range.addresses.isEmpty()) -1 else range.addresses.last().getLine1(editor, caret) val address = if (range.addresses.isEmpty()) -1 else range.addresses.last().getLine1(editor, caret)
val textData = PutData.TextData(null, copiedText, SelectionType.LINE_WISE) val textData = PutData.TextData(null, SelectionType.LINE_WISE, copiedText.transferableData, null)
return PutData( return PutData(
textData, textData,
null, null,

View File

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

View File

@@ -1,12 +1,4 @@
<!-- <idea-plugin>
~ 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.
-->
<idea-plugin url="https://plugins.jetbrains.com/plugin/164">
<name>IdeaVim</name> <name>IdeaVim</name>
<id>IdeaVIM</id> <id>IdeaVIM</id>
<description><![CDATA[ <description><![CDATA[
@@ -40,7 +32,7 @@
<li><a href="https://youtrack.jetbrains.com/issues/VIM">Issue tracker</a> — feature requests and bug reports</li> <li><a href="https://youtrack.jetbrains.com/issues/VIM">Issue tracker</a> — feature requests and bug reports</li>
</ul> </ul>
]]></description> ]]></description>
<version>SNAPSHOT</version> <version>chylex</version>
<vendor>JetBrains</vendor> <vendor>JetBrains</vendor>
<!-- Mark the plugin as compatible with RubyMine and other products based on the IntelliJ platform (including CWM) --> <!-- Mark the plugin as compatible with RubyMine and other products based on the IntelliJ platform (including CWM) -->

View File

@@ -78,5 +78,10 @@
"keys": "gJ", "keys": "gJ",
"class": "com.maddyhome.idea.vim.action.change.delete.DeleteJoinVisualLinesAction", "class": "com.maddyhome.idea.vim.action.change.delete.DeleteJoinVisualLinesAction",
"modes": "X" "modes": "X"
},
{
"keys": "z@",
"class": "com.maddyhome.idea.vim.action.macro.PlaybackRegisterInOpenFilesAction",
"modes": "N"
} }
] ]

View File

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

View File

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

View File

@@ -0,0 +1,37 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package org.jetbrains.plugins.ideavim.action
import com.maddyhome.idea.vim.action.VimShortcutKeyAction
import org.jetbrains.plugins.ideavim.SkipNeovimReason
import org.jetbrains.plugins.ideavim.TestWithoutNeovim
import org.jetbrains.plugins.ideavim.VimTestCase
import org.junit.jupiter.api.Test
import java.awt.event.InputEvent
import java.awt.event.KeyEvent
import javax.swing.KeyStroke
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class VimShortcutKeyActionTest : VimTestCase() {
@TestWithoutNeovim(SkipNeovimReason.NOT_VIM_TESTING)
@Test
fun `plain Tab is a Vim-only editor key`() {
val tab = KeyStroke.getKeyStroke(KeyEvent.VK_TAB, 0)
assertTrue(VimShortcutKeyAction.VIM_ONLY_EDITOR_KEYS.contains(tab))
}
@TestWithoutNeovim(SkipNeovimReason.NOT_VIM_TESTING)
@Test
fun `S-Tab is not a Vim-only editor key so sethandler can release it to the IDE`() {
val shiftTab = KeyStroke.getKeyStroke(KeyEvent.VK_TAB, InputEvent.SHIFT_DOWN_MASK)
assertFalse(VimShortcutKeyAction.VIM_ONLY_EDITOR_KEYS.contains(shiftTab))
}
}

View File

@@ -1,5 +1,5 @@
/* /*
* Copyright 2003-2023 The IdeaVim authors * Copyright 2003-2026 The IdeaVim authors
* *
* Use of this source code is governed by an MIT-style * Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at * license that can be found in the LICENSE.txt file or at
@@ -65,6 +65,34 @@ class UndoActionTest : VimTestCase() {
} }
} }
@Test
fun `test undo after visual block mode delete clears leftover native carets`() {
configureByText(
"""
${c}1. Item
2. Item
3. Item
""".trimIndent()
)
typeText("<C-V>jjllx")
typeText("u")
assertState(
"""
${c}1. Item
2. Item
3. Item
""".trimIndent()
)
assertMode(Mode.NORMAL())
ApplicationManager.getApplication().runReadAction {
kotlin.test.assertFalse(hasSelection())
kotlin.test.assertEquals(1, fixture.editor.caretModel.allCarets.size)
}
}
@Test @Test
fun `test undo with count`() { fun `test undo with count`() {
val keys = listOf("dwdwdw", "2u") val keys = listOf("dwdwdw", "2u")

View File

@@ -0,0 +1,113 @@
/*
* Copyright 2003-2025 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package org.jetbrains.plugins.ideavim.autocmd
import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.state.mode.Mode
import org.jetbrains.plugins.ideavim.VimTestCase
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInfo
class AugroupTest : VimTestCase() {
@BeforeEach
override fun setUp(testInfo: TestInfo) {
super.setUp(testInfo)
configureByText("\n")
enterCommand("autocmd!")
}
@Test
fun `should register autocmd inside augroup`() {
enterCommand("augroup TestGroup")
enterCommand("autocmd InsertEnter * echo 23")
enterCommand("augroup END")
typeText(injector.parser.parseKeys("i"))
assertExOutput("23")
}
@Test
fun `autocmd bang inside augroup should clear only that group`() {
enterCommand("augroup G1")
enterCommand("autocmd InsertEnter * echo 1")
enterCommand("augroup END")
enterCommand("augroup G2")
enterCommand("autocmd InsertEnter * echo 2")
enterCommand("augroup END")
enterCommand("augroup G1")
enterCommand("autocmd!")
enterCommand("augroup END")
typeText(injector.parser.parseKeys("i"))
assertExOutput("2")
}
@Test
fun `augroup bang should remove all handlers from group`() {
enterCommand("augroup TestGroup")
enterCommand("autocmd InsertEnter * echo 23")
enterCommand("augroup END")
enterCommand("augroup! TestGroup")
typeText(injector.parser.parseKeys("i"))
assertNoExOutput()
}
@Test
fun `augroup should allow redefining group without bang (append handlers)`() {
enterCommand("augroup TestGroup")
enterCommand("autocmd InsertEnter * echo 1")
enterCommand("augroup END")
enterCommand("augroup TestGroup")
enterCommand("autocmd InsertEnter * echo 2")
enterCommand("augroup END")
typeText(injector.parser.parseKeys("i"))
assertExOutput("1\n2")
}
@Test
fun `augroup bang should redefine group (drop previous handlers)`() {
enterCommand("augroup TestGroup")
enterCommand("autocmd InsertEnter * echo 1")
enterCommand("augroup END")
enterCommand("augroup! TestGroup")
enterCommand("augroup TestGroup")
enterCommand("autocmd InsertEnter * echo 2")
enterCommand("augroup END")
typeText(injector.parser.parseKeys("i"))
assertExOutput("2")
}
@Test
fun `should keep groups independent`() {
enterCommand("augroup G1")
enterCommand("autocmd InsertEnter * echo 1")
enterCommand("augroup END")
enterCommand("augroup G2")
enterCommand("autocmd InsertLeave * echo 2")
enterCommand("augroup END")
typeText(injector.parser.parseKeys("i"))
assertExOutput("1")
typeText(injector.parser.parseKeys("<esc>"))
assertState(Mode.NORMAL())
assertExOutput("2")
}
}

View File

@@ -0,0 +1,122 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package org.jetbrains.plugins.ideavim.autocmd
import com.maddyhome.idea.vim.vimscript.model.commands.AutoCmdCommand
import com.maddyhome.idea.vim.vimscript.parser.VimscriptParser
import com.maddyhome.idea.vim.vimscript.parser.errors.IdeavimErrorListener
import org.jetbrains.plugins.ideavim.VimTestCase
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInfo
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertTrue
class AutoCmdParseTest : VimTestCase() {
@BeforeEach
override fun setUp(testInfo: TestInfo) {
super.setUp(testInfo)
configureByText("\n")
}
private fun parseAutocmd(text: String): AutoCmdCommand {
val script = VimscriptParser.parse(text)
assertTrue(IdeavimErrorListener.testLogger.isEmpty(), "Parser errors: ${IdeavimErrorListener.testLogger}")
assertEquals(1, script.units.size)
return assertIs<AutoCmdCommand>(script.units.first())
}
@Test
fun `parse single event with star pattern`() {
val cmd = parseAutocmd("autocmd InsertEnter * echo hi")
assertEquals(listOf("InsertEnter"), cmd.eventNames)
assertEquals("*", cmd.filePattern)
assertEquals("echo hi", cmd.commandText)
}
@Test
fun `parse single event with extension pattern`() {
val cmd = parseAutocmd("autocmd InsertEnter *.py echo hi")
assertEquals(listOf("InsertEnter"), cmd.eventNames)
assertEquals("*.py", cmd.filePattern)
assertEquals("echo hi", cmd.commandText)
}
@Test
fun `parse comma-separated events with pattern`() {
val cmd = parseAutocmd("autocmd InsertEnter,InsertLeave *.txt echo hi")
assertEquals(listOf("InsertEnter", "InsertLeave"), cmd.eventNames)
assertEquals("*.txt", cmd.filePattern)
assertEquals("echo hi", cmd.commandText)
}
@Test
fun `parse events with spaces around commas`() {
val cmd = parseAutocmd("autocmd InsertEnter , InsertLeave * echo hi")
assertEquals(listOf("InsertEnter", "InsertLeave"), cmd.eventNames)
assertEquals("*", cmd.filePattern)
assertEquals("echo hi", cmd.commandText)
}
@Test
fun `parse brace pattern`() {
val cmd = parseAutocmd("autocmd InsertEnter *.{py,txt} echo hi")
assertEquals(listOf("InsertEnter"), cmd.eventNames)
assertEquals("*.{py,txt}", cmd.filePattern)
assertEquals("echo hi", cmd.commandText)
}
@Test
fun `parse bang has no events or pattern`() {
val cmd = parseAutocmd("autocmd!")
assertTrue(cmd.eventNames.isEmpty())
assertEquals(null, cmd.filePattern)
assertEquals(null, cmd.commandText)
}
@Test
fun `parse command with multiple spaces`() {
val cmd = parseAutocmd("autocmd InsertEnter * echo \"hello world\"")
assertEquals("*", cmd.filePattern)
assertEquals("echo \"hello world\"", cmd.commandText)
}
@Test
fun `parse exact filename pattern`() {
val cmd = parseAutocmd("autocmd InsertEnter Makefile echo hi")
assertEquals(listOf("InsertEnter"), cmd.eventNames)
assertEquals("Makefile", cmd.filePattern)
assertEquals("echo hi", cmd.commandText)
}
@Test
fun `parse unknown event name without errors`() {
val script = VimscriptParser.parse("autocmd BufReadPost * echo hi")
assertTrue(IdeavimErrorListener.testLogger.isEmpty())
assertEquals(1, script.units.size)
val cmd = assertIs<AutoCmdCommand>(script.units.first())
assertEquals(listOf("BufReadPost"), cmd.eventNames)
}
@Test
fun `parse multiline autocmd without errors`() {
val script = VimscriptParser.parse(
"""
autocmd BufReadPost *
\ if line("'\"") > 0 && line ("'\"") <= line("$") |
\ exe "normal! g'\"" |
\ endif
""".trimIndent(),
)
assertEquals(1, script.units.size)
assertTrue(IdeavimErrorListener.testLogger.isEmpty())
}
}

View File

@@ -0,0 +1,146 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package org.jetbrains.plugins.ideavim.autocmd
import com.maddyhome.idea.vim.autocmd.AutoCmdPattern
import org.junit.jupiter.api.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class AutoCmdPatternTest {
@Test
fun `star matches any file`() {
assertTrue(AutoCmdPattern("*").matches("/path/to/file.txt"))
}
@Test
fun `star matches null path`() {
assertTrue(AutoCmdPattern("*").matches(null))
}
@Test
fun `non-star pattern does not match null path`() {
assertFalse(AutoCmdPattern("*.py").matches(null))
}
@Test
fun `extension pattern matches correct extension`() {
assertTrue(AutoCmdPattern("*.py").matches("/path/to/script.py"))
}
@Test
fun `extension pattern does not match wrong extension`() {
assertFalse(AutoCmdPattern("*.py").matches("/path/to/script.txt"))
}
@Test
fun `extension pattern matches file name only`() {
assertTrue(AutoCmdPattern("*.py").matches("/some/deep/path/test.py"))
}
@Test
fun `brace alternation matches first option`() {
assertTrue(AutoCmdPattern("*.{py,txt}").matches("/path/to/file.py"))
}
@Test
fun `brace alternation matches second option`() {
assertTrue(AutoCmdPattern("*.{py,txt}").matches("/path/to/file.txt"))
}
@Test
fun `brace alternation does not match unlisted extension`() {
assertFalse(AutoCmdPattern("*.{py,txt}").matches("/path/to/file.kt"))
}
@Test
fun `question mark matches single character`() {
assertTrue(AutoCmdPattern("?.txt").matches("/path/to/a.txt"))
}
@Test
fun `question mark does not match multiple characters`() {
assertFalse(AutoCmdPattern("?.txt").matches("/path/to/ab.txt"))
}
@Test
fun `exact filename matches`() {
assertTrue(AutoCmdPattern("Makefile").matches("/path/to/Makefile"))
}
@Test
fun `exact filename does not match different name`() {
assertFalse(AutoCmdPattern("Makefile").matches("/path/to/Rakefile"))
}
@Test
fun `pattern with path matches full path`() {
assertTrue(AutoCmdPattern("/home/user/*.py").matches("/home/user/script.py"))
}
@Test
fun `pattern with path does not match different directory`() {
assertFalse(AutoCmdPattern("/home/user/*.py").matches("/other/path/script.py"))
}
@Test
fun `double star matches across directories`() {
assertTrue(AutoCmdPattern("**/*.py").matches("/some/deep/path/script.py"))
}
@Test
fun `star does not match path separators`() {
assertFalse(AutoCmdPattern("src/*.py").matches("src/sub/script.py"))
}
@Test
fun `double star matches path separators`() {
assertTrue(AutoCmdPattern("src/**/*.py").matches("src/sub/script.py"))
}
@Test
fun `bracket character class matches`() {
assertTrue(AutoCmdPattern("*.[ch]").matches("/path/to/file.c"))
assertTrue(AutoCmdPattern("*.[ch]").matches("/path/to/file.h"))
}
@Test
fun `bracket character class does not match unlisted`() {
assertFalse(AutoCmdPattern("*.[ch]").matches("/path/to/file.o"))
}
@Test
fun `dot in extension is escaped properly`() {
assertFalse(AutoCmdPattern("*.py").matches("/path/to/file_py"))
}
@Test
fun `prefix pattern matches`() {
assertTrue(AutoCmdPattern("test*").matches("/path/to/test_file.py"))
}
@Test
fun `prefix pattern does not match different prefix`() {
assertFalse(AutoCmdPattern("test*").matches("/path/to/prod_file.py"))
}
@Test
fun `multiple extensions with brace`() {
val pattern = AutoCmdPattern("*.{c,h,cpp,hpp}")
assertTrue(pattern.matches("/path/to/main.cpp"))
assertTrue(pattern.matches("/path/to/main.h"))
assertFalse(pattern.matches("/path/to/main.py"))
}
@Test
fun `simple filename without extension`() {
assertTrue(AutoCmdPattern("*").matches("/path/to/Makefile"))
}
}

View File

@@ -0,0 +1,324 @@
/*
* Copyright 2003-2026 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package org.jetbrains.plugins.ideavim.autocmd
import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.state.mode.Mode
import org.jetbrains.plugins.ideavim.VimTestCase
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInfo
class AutoCmdTest : VimTestCase() {
@BeforeEach
override fun setUp(testInfo: TestInfo) {
super.setUp(testInfo)
configureByText("\n")
enterCommand("autocmd!")
injector.outputPanel.getCurrentOutputPanel()?.close()
}
@Test
fun `should execute command on InsertEnter`() {
enterCommand("autocmd InsertEnter * echo \"hi\"")
typeText(injector.parser.parseKeys("i"))
assertExOutput("hi")
}
@Test
fun `should do nothing on invalid syntax`() {
enterCommand("autocmd InsertEnter echo 23")
typeText(injector.parser.parseKeys("i"))
assertNoExOutput()
}
@Test
fun `should execute command on InsertLeave`() {
enterCommand("autocmd InsertLeave * echo 23")
typeText(injector.parser.parseKeys("i"))
typeText(injector.parser.parseKeys("<esc>"))
assertState(Mode.NORMAL())
assertExOutput("23")
}
@Test
fun `should fire InsertEnter when entering Replace mode`() {
enterCommand("autocmd InsertEnter * echo \"enter\"")
typeText(injector.parser.parseKeys("R"))
assertState(Mode.REPLACE)
assertExOutput("enter")
}
@Test
fun `should fire InsertLeave when leaving Replace mode`() {
enterCommand("autocmd InsertLeave * echo \"leave\"")
typeText(injector.parser.parseKeys("R"))
typeText(injector.parser.parseKeys("<esc>"))
assertState(Mode.NORMAL())
assertExOutput("leave")
}
@Test
fun `should clear commands`() {
enterCommand("autocmd InsertEnter * echo 23")
enterCommand("autocmd!")
typeText(injector.parser.parseKeys("i"))
assertNoExOutput()
}
@Test
fun `should do nothing when pattern does not match file`() {
enterCommand("autocmd InsertEnter *.py echo 23")
typeText(injector.parser.parseKeys("i"))
assertNoExOutput()
}
@Test
fun `should execute command every time InsertEnter is triggered`() {
enterCommand("autocmd InsertEnter * echo 23")
typeText(injector.parser.parseKeys("i"))
typeText(injector.parser.parseKeys("<esc>"))
assertExOutput("23")
typeText(injector.parser.parseKeys("i"))
typeText(injector.parser.parseKeys("<esc>"))
assertExOutput("23")
}
@Test
fun `should not execute InsertLeave command if insert mode is not left`() {
enterCommand("autocmd InsertLeave * echo 23")
typeText(injector.parser.parseKeys("i"))
assertNoExOutput()
}
@Test
fun `should execute multiple handlers for same event`() {
enterCommand("autocmd InsertEnter * echo 1")
enterCommand("autocmd InsertEnter * echo 2")
typeText(injector.parser.parseKeys("i"))
assertExOutput("1\n2")
}
@Test
fun `should execute only matching event handlers`() {
enterCommand("autocmd InsertEnter * echo 1")
enterCommand("autocmd InsertLeave * echo 2")
typeText(injector.parser.parseKeys("i"))
assertExOutput("1")
typeText(injector.parser.parseKeys("<esc>"))
assertExOutput("2")
}
@Test
fun `autocmd bang should clear all event handlers`() {
enterCommand("autocmd InsertEnter * echo 1")
enterCommand("autocmd InsertLeave * echo 2")
enterCommand("autocmd!")
typeText(injector.parser.parseKeys("i"))
typeText(injector.parser.parseKeys("<esc>"))
assertNoExOutput()
}
@Test
fun `should execute InsertEnter when entering insert from visual mode with c`() {
configureByText("hello world")
enterCommand("autocmd InsertEnter * echo \"entering insert\"")
typeText(injector.parser.parseKeys("viw")) // select word
typeText(injector.parser.parseKeys("c")) // change (enters insert)
assertExOutput("entering insert")
assertState(Mode.INSERT)
}
@Test
fun `should execute InsertEnter when entering insert from visual mode with s`() {
configureByText("hello world")
enterCommand("autocmd InsertEnter * echo \"substitute\"")
typeText(injector.parser.parseKeys("viw")) // select word
typeText(injector.parser.parseKeys("s")) // substitute (enters insert)
assertExOutput("substitute")
assertState(Mode.INSERT)
}
@Test
fun `should execute InsertLeave after entering from visual mode`() {
configureByText("hello world")
enterCommand("autocmd InsertLeave * echo \"leaving insert\"")
typeText(injector.parser.parseKeys("viw")) // select word
typeText(injector.parser.parseKeys("c")) // change (enters insert)
typeText(injector.parser.parseKeys("<esc>"))
assertExOutput("leaving insert")
assertState(Mode.NORMAL())
}
@Test
fun `should execute both InsertEnter and InsertLeave from visual mode`() {
configureByText("hello world")
enterCommand("autocmd InsertEnter * echo \"enter\"")
enterCommand("autocmd InsertLeave * echo \"leave\"")
typeText(injector.parser.parseKeys("viw")) // select word
typeText(injector.parser.parseKeys("c")) // change (enters insert)
assertExOutput("enter")
typeText(injector.parser.parseKeys("<esc>"))
assertExOutput("leave")
}
@Test
fun `should register multiple events with comma-separated syntax`() {
enterCommand("autocmd InsertEnter,InsertLeave * echo \"triggered\"")
typeText(injector.parser.parseKeys("i"))
assertExOutput("triggered")
typeText(injector.parser.parseKeys("<esc>"))
assertExOutput("triggered")
}
@Test
fun `should handle spaces around commas in multiple events`() {
enterCommand("autocmd InsertEnter , InsertLeave * echo \"triggered\"")
typeText(injector.parser.parseKeys("i"))
assertExOutput("triggered")
typeText(injector.parser.parseKeys("<esc>"))
assertExOutput("triggered")
}
@Test
fun `should register three events with comma-separated syntax`() {
configureByText("hello")
enterCommand("autocmd InsertEnter,InsertLeave,BufEnter * echo \"event\"")
// InsertEnter
typeText(injector.parser.parseKeys("i"))
assertExOutput("event")
// InsertLeave
typeText(injector.parser.parseKeys("<esc>"))
assertExOutput("event")
}
@Test
fun `should fail gracefully with invalid event in comma-separated list`() {
enterCommand("autocmd InsertEnter,InvalidEvent * echo \"test\"")
typeText(injector.parser.parseKeys("i"))
assertNoExOutput()
}
@Test
fun `should match file extension pattern`() {
configureByFileName("test.txt")
enterCommand("autocmd!")
enterCommand("autocmd InsertEnter *.txt echo \"matched\"")
typeText(injector.parser.parseKeys("i"))
assertExOutput("matched")
}
@Test
fun `should not match wrong file extension pattern`() {
configureByFileName("test.txt")
enterCommand("autocmd!")
enterCommand("autocmd InsertEnter *.py echo \"matched\"")
typeText(injector.parser.parseKeys("i"))
assertNoExOutput()
}
@Test
fun `should match brace alternation pattern`() {
configureByFileName("test.txt")
enterCommand("autocmd!")
enterCommand("autocmd InsertEnter *.{py,txt} echo \"matched\"")
typeText(injector.parser.parseKeys("i"))
assertExOutput("matched")
}
@Test
fun `should not match brace alternation when extension not listed`() {
configureByFileName("test.kt")
enterCommand("autocmd!")
enterCommand("autocmd InsertEnter *.{py,txt} echo \"matched\"")
typeText(injector.parser.parseKeys("i"))
assertNoExOutput()
}
@Test
fun `should match question mark single char pattern`() {
configureByFileName("test.py")
enterCommand("autocmd!")
enterCommand("autocmd InsertEnter *.?y echo \"matched\"")
typeText(injector.parser.parseKeys("i"))
assertExOutput("matched")
}
@Test
fun `should match exact filename pattern`() {
configureByFileName("Makefile")
enterCommand("autocmd!")
enterCommand("autocmd InsertEnter Makefile echo \"matched\"")
typeText(injector.parser.parseKeys("i"))
assertExOutput("matched")
}
@Test
fun `should not match different exact filename`() {
configureByFileName("Rakefile")
enterCommand("autocmd!")
enterCommand("autocmd InsertEnter Makefile echo \"matched\"")
typeText(injector.parser.parseKeys("i"))
assertNoExOutput()
}
@Test
fun `should match star pattern on any file`() {
configureByFileName("anything.xyz")
enterCommand("autocmd!")
enterCommand("autocmd InsertEnter * echo \"matched\"")
typeText(injector.parser.parseKeys("i"))
assertExOutput("matched")
}
@Test
fun `should execute only commands with matching pattern`() {
configureByFileName("test.py")
enterCommand("autocmd!")
enterCommand("autocmd InsertEnter *.py echo \"python\"")
enterCommand("autocmd InsertEnter *.txt echo \"text\"")
typeText(injector.parser.parseKeys("i"))
assertExOutput("python")
}
@Test
fun `should execute commands with star and specific pattern`() {
configureByFileName("test.py")
enterCommand("autocmd!")
enterCommand("autocmd InsertEnter * echo \"all\"")
enterCommand("autocmd InsertEnter *.py echo \"python\"")
typeText(injector.parser.parseKeys("i"))
assertExOutput("all\npython")
}
}

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