Compare commits

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

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