Compare commits

..
29 Commits
Author SHA1 Message Date
chylex dba931191c Set plugin version to 58 2026-09-16 13:07:20 +02:00
chylex 7d42ac1a55 Make search highlights temporary 2026-09-16 13:07:20 +02:00
chylex bd83e8e90e Disable IdeaVim in popup editors 2026-09-12 23:22:11 +02:00
chylex a9c4dfb104 Fix AltGr not triggering Ctrl-Alt bindings on Windows 2026-09-12 23:22:11 +02:00
chylex 888dcc6cd7 Add 'isactionenabled' function 2026-09-12 23:22:11 +02:00
chylex da8a492bea Preserve visual mode after executing IDE action 2026-09-12 23:22:10 +02:00
chylex f2217d9339 Make g0/g^/g$ work with soft wraps 2026-09-12 23:22:10 +02:00
chylex b4cf5c3c5e Make gj/gk jump over soft wraps 2026-09-12 23:22:10 +02:00
chylex d19a7afd12 Make camelCase motions adjust based on direction of visual selection 2026-09-12 23:22:10 +02:00
chylex b84172a4dd Do not switch to normal mode after inserting a live template 2026-09-12 23:22:09 +02:00
chylex effbdc9d10 Exit insert mode after refactoring 2026-09-12 23:22:09 +02:00
chylex 67fbaba415 Add action to run last macro in all opened files 2026-09-12 23:22:09 +02:00
chylex 01f2f6b97e Stop macro execution after a failed search 2026-09-12 23:22:09 +02:00
chylex 3e1fe53fce Fix losing yanked text when number of carets changes 2026-09-12 23:22:08 +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
40 changed files with 396 additions and 1871 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
@@ -21,16 +21,6 @@ jobs:
with:
fetch-depth: 0 # Full history so the changelog can be analyzed and the PR branch pushed
# 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
@@ -123,34 +113,6 @@ jobs:
# Changelog needs the skill + git/gh/web; the page generation needs file read/write.
claude_args: '--allowed-tools "Skill,Read,Write,Edit,Glob,Grep,Bash(git:*),Bash(gh:*),WebSearch,WebFetch(domain:plugins.jetbrains.com),WebFetch(domain:youtrack.jetbrains.com),WebFetch(domain:github.com)"'
# Decodes the session log that the action writes but never prints.
- name: Claude run post-mortem
if: ${{ always() }}
id: postmortem
uses: ./.github/actions/claude-postmortem
with:
execution_file: ${{ steps.claude.outputs.execution_file }}
label: Prepare What's New
# Re-probe after a failure: a pre-flight that was OK plus a post-flight that is not
# means the quota ran out during this very run.
- name: Re-check Claude credentials and quota after a failure
if: ${{ failure() }}
uses: ./.github/actions/claude-preflight
with:
base_url: ${{ secrets.PROXY_URL }}
jwt: ${{ secrets.ANTHROPIC_API_KEY }}
label: post-failure
- name: Upload the Claude execution log
if: ${{ always() && steps.claude.outputs.execution_file != '' }}
uses: actions/upload-artifact@v4
with:
name: claude-execution-log
path: ${{ steps.claude.outputs.execution_file }}
if-no-files-found: ignore
retention-days: 14
- name: Comment rendered preview link on the PR
if: ${{ always() }}
uses: actions/github-script@v7
+1 -1
View File
@@ -21,7 +21,7 @@ repositories {
}
dependencies {
compileOnly("com.google.devtools.ksp:symbol-processing-api:2.3.12")
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")
+1 -1
View File
@@ -25,7 +25,7 @@ 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.5.2")
-3
View File
@@ -255,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>
-2
View File
@@ -59,8 +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` |
| `<C-D>` | Move the selection half a page down | - |
| `<C-U>` | Move the selection half a page up | - |
| `r` | Recursively refresh the current directory | `g:NERDTreeMapRefresh` |
| `R` | Recursively refresh the current root | `g:NERDTreeMapRefreshRoot` |
| `m` | Display the NERDTree menu | `g:NERDTreeMapMenu` |
+14 -20
View File
@@ -238,26 +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.
Remove a value to revert that editor to its native behaviour with no
Vim keybindings. The change takes effect immediately for all open
console 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>")
@@ -274,6 +254,20 @@ The old name is deprecated, but still accepted by `:set` and by the `&{option}`
global
Maps Vim's global marks to IDE bookmarks.
'ideapythonconsole' boolean (default on)
global
When on, IdeaVim is active in the Python console tool window. The
console still intercepts Enter (execute) and the horizontal arrow keys
(caret movement within the input line) as normal; Up/Down navigate
command history from any Vim mode.
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.
This option was previously called 'pythonconsole'. The old name is
deprecated, but still accepted.
'idearefactormode' string (default "select")
global or local to buffer
Specifies the mode to be used when a refactoring selects text to be
+1 -1
View File
@@ -18,7 +18,7 @@ ideaVersion=2026.1
# Values for type: https://plugins.jetbrains.com/docs/intellij/tools-gradle-intellij-plugin.html#intellij-extension-type
ideaType=IU
instrumentPluginCode=true
version=9999.60-chylex
version=9999.59-chylex
javaVersion=21
remoteRobotVersion=0.11.23
antlrVersion=4.10.1
+2 -2
View File
@@ -21,7 +21,7 @@ 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")
@@ -34,7 +34,7 @@ dependencies {
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")
}
@@ -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;
@@ -91,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) {
@@ -8,6 +8,11 @@
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
@@ -47,5 +52,21 @@ internal class PluginStartup : ProjectActivity/*, LightEditCompatible*/ {
VimPlugin.getInstance().initialize()
(injector.enabler as IjVimEnabler).ideOpened()
// Uninstall survey. Should be registered once for all projects
PluginStateManager.addStateListener(object : PluginStateListener {
override fun install(p0: IdeaPluginDescriptor) {/*Nothing*/
}
override fun uninstall(descriptor: IdeaPluginDescriptor) {
val pluginId = VimPlugin.getPluginId()
// This event is called for both uninstall and update. There is no proper way to distinguish these two events.
// In order not to show the form for the update, we check if the new version is available. If so,
// this may be an update (and may not), and we don't show the form.
if (descriptor.pluginId == pluginId && !InstalledPluginsState.getInstance().hasNewerVersion(pluginId)) {
BrowserUtil.open("https://jb.gg/z6c7db")
}
}
})
}
}
@@ -284,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 {
@@ -10,7 +10,6 @@ package com.maddyhome.idea.vim.extension.commentary
import com.intellij.openapi.editor.impl.editorId
import com.intellij.vim.api.VimInitApi
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.ImmutableVimCaret
import com.maddyhome.idea.vim.api.VimEditor
@@ -47,13 +46,13 @@ internal class CommentaryExtension : VimExtension {
selectionType: SelectionType,
resetCaret: Boolean = true,
): Boolean {
val mode = editor.mode
val ijEditor = editor.ij
val document = ijEditor.document
val editorId = ijEditor.editorId()
val caretOffset = if (resetCaret) range.startOffset else -1
val blockCommentEnabled = areBlockCommentsEnabled()
if (selectionType === SelectionType.LINE_WISE || !blockCommentEnabled) {
if (selectionType === SelectionType.LINE_WISE) {
val startLine = document.getLineNumber(range.startOffset)
var endLine = document.getLineNumber(range.endOffset)
// Adjust endLine if the range ends at the start of a line (don't include that line)
@@ -72,12 +71,6 @@ internal class CommentaryExtension : VimExtension {
}
return true
}
private fun areBlockCommentsEnabled(): Boolean {
val value =
VimPlugin.getVariableService().getGlobalVariableValue("commentary_block_comments")?.toVimNumber()?.booleanValue
return value == null || value
}
}
companion object {
@@ -13,11 +13,8 @@ import com.intellij.util.ui.tree.TreeUtil
import com.maddyhome.idea.vim.VimPlugin
import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.vimscript.model.datatypes.VimString
import javax.swing.JTree
import javax.swing.KeyStroke
import javax.swing.tree.TreeNode
import kotlin.math.max
import kotlin.math.min
fun MutableMap<List<KeyStroke>, NerdTreeAction>.register(
variable: String,
@@ -64,12 +61,6 @@ val navigationMappings: Map<List<KeyStroke>, NerdTreeAction> = mutableMapOf<List
register("j", NerdTreeAction.swing("selectNext"))
register("G", NerdTreeAction.swing("selectLast"))
register("gg", NerdTreeAction.swing("selectFirst"))
register("<C-D>", NerdTreeAction { _, tree ->
navigateHalfPage(tree, ScrollDirection.DOWN)
})
register("<C-U>", NerdTreeAction { _, tree ->
navigateHalfPage(tree, ScrollDirection.UP)
})
// FIXME lazy loaded tree nodes are not expanded
register("NERDTreeMapOpenRecursively", "O", NerdTreeAction.ij("FullyExpandTreeNode"))
@@ -150,16 +141,3 @@ val navigationMappings: Map<List<KeyStroke>, NerdTreeAction> = mutableMapOf<List
})
register("<ESC>", NerdTreeAction { _, _ -> })
}
private fun navigateHalfPage(tree: JTree, direction: ScrollDirection) {
val visible = TreeUtil.getVisibleRowCount(tree)
val first = tree.getClosestRowForLocation(tree.visibleRect.x, tree.visibleRect.y)
val selectedRow = tree.selectionRows?.firstOrNull() ?: first
val delta = visible / 2 * direction.sign
val row = min(max(selectedRow - delta, 0), tree.rowCount - 1)
val top = min(first - delta, tree.rowCount - 1)
val bottom = top + visible - 1
TreeUtil.showAndSelect(tree, top, bottom, row, -1)
}
private enum class ScrollDirection(val sign: Int) { UP(1), DOWN(-1) }
@@ -16,9 +16,6 @@ class IjOptionConstants {
const val idearefactormode_select: String = "select"
const val idearefactormode_visual: String = "visual"
const val ideaeditor_python: String = "python"
const val ideaeditor_debug: String = "debug"
const val ideastatusicon_enabled: String = "enabled"
const val ideastatusicon_gray: String = "gray"
const val ideastatusicon_disabled: String = "disabled"
@@ -30,7 +27,6 @@ class IjOptionConstants {
const val ideawrite_all: String = "all"
const val ideawrite_file: String = "file"
val ideaEditorValues: Set<String> = setOf(ideaeditor_python, ideaeditor_debug)
val ideaStatusIconValues: Set<String> = setOf(ideastatusicon_enabled, ideastatusicon_gray, ideastatusicon_disabled)
val ideaRefactorModeValues: Set<String> =
setOf(idearefactormode_keep, idearefactormode_select, idearefactormode_visual)
@@ -21,9 +21,9 @@ import com.maddyhome.idea.vim.options.OptionAccessScope
@Suppress("SpellCheckingInspection")
open class GlobalIjOptions(scope: OptionAccessScope) : OptionsPropertiesBase(scope) {
var ide: String by optionProperty(IjOptions.ide)
val ideaeditor: StringListOptionValue by optionProperty(IjOptions.ideaeditor)
val idealookupkeys: StringListOptionValue by optionProperty(IjOptions.idealookupkeys)
var ideamarks: Boolean by optionProperty(IjOptions.ideamarks)
val ideapythonconsole: Boolean by optionProperty(IjOptions.ideapythonconsole)
var ideastatusicon: String by optionProperty(IjOptions.ideastatusicon)
var ideatrackactionids: Boolean by optionProperty(IjOptions.ideatrackactionids)
val ideavimsupport: StringListOptionValue by optionProperty(IjOptions.ideavimsupport)
@@ -91,18 +91,6 @@ object IjOptions {
val ideacopypreprocess: ToggleOption = addOption(
ToggleOption("ideacopypreprocess", GLOBAL_OR_LOCAL_TO_BUFFER, "ideacopypreprocess", false)
)
/**
* Which non-file editors get Vim support. See `EditorHelper.isAllowedFileEditor`.
*/
val ideaeditor: StringListOption = addOption(
StringListOption(
"ideaeditor",
GLOBAL,
"ideaeditor",
"python,debug",
IjOptionConstants.ideaEditorValues
)
)
val ideajoin: ToggleOption = addOption(ToggleOption("ideajoin", GLOBAL_OR_LOCAL_TO_BUFFER, "ideajoin", false))
val idealookupkeys: StringListOption = addOption(
StringListOption(
@@ -114,7 +102,8 @@ object IjOptions {
"lookupkeys"
)
val ideamarks: ToggleOption = addOption(ToggleOption("ideamarks", GLOBAL, "ideamarks", true))
val ideapythonconsole: ToggleOption =
addOption(ToggleOption("ideapythonconsole", GLOBAL, "ideapythonconsole", true), "pythonconsole")
val idearefactormode: StringOption = addOption(
StringOption(
"idearefactormode",
@@ -8,31 +8,33 @@
package com.maddyhome.idea.vim.group
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.EditorFactory
import com.maddyhome.idea.vim.api.LocalOptionInitialisationScenario
import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.helper.EditorHelper
import com.maddyhome.idea.vim.helper.isEnabledConsole
import com.maddyhome.idea.vim.helper.removeCaretsVisualAttributes
import com.maddyhome.idea.vim.helper.updateCaretsVisualAttributes
import com.maddyhome.idea.vim.listener.VimListenerManager
import com.maddyhome.idea.vim.newapi.globalIjOptions
import com.maddyhome.idea.vim.options.GlobalOptionChangeListener
/**
* Reacts to runtime changes of the 'ideaeditor' option and enables or disables Vim in all currently open console
* editors without requiring a restart or plugin toggle.
* Reacts to runtime changes of the `ideapythonconsole` toggle option and enables or disables Vim in all currently open
* Python console editors without requiring a restart or plugin toggle.
*
* Enabling initialises Vim listeners, shortcuts and local options for every open console editor that has not yet been
* set up, e.g. consoles that were opened while the option didn't list them. Disabling tears Vim down again and resets
* the caret shape to the IDE default.
* When `set ideapythonconsole`: initialises Vim listeners, shortcuts, and local options for every open Python console
* editor that has not yet been set up (e.g. consoles that were opened while the option was off).
*
* When `set noideapythonconsole`: tears down Vim from every open Python console editor and resets the caret shape to the
* IDE default.
*/
internal object ConsoleOptionChangeListener : GlobalOptionChangeListener {
internal object PythonConsoleOptionChangeListener : GlobalOptionChangeListener {
override fun onGlobalOptionChanged() {
val enabled = injector.globalIjOptions().ideapythonconsole
for (editor in EditorFactory.getInstance().allEditors) {
if (editor.isDisposed) continue
if (!editor.isConsole()) continue
if (editor.isEnabledConsole()) {
if (!EditorHelper.isPythonConsole(editor)) continue
if (enabled) {
VimListenerManager.EditorListeners.add(editor, injector.fallbackWindow, LocalOptionInitialisationScenario.NEW)
editor.updateCaretsVisualAttributes()
} else {
@@ -41,6 +43,4 @@ internal object ConsoleOptionChangeListener : GlobalOptionChangeListener {
}
}
}
private fun Editor.isConsole() = EditorHelper.isPythonConsole(this) || EditorHelper.isRunConsole(this)
}
@@ -8,8 +8,6 @@
package com.maddyhome.idea.vim.helper;
import com.intellij.execution.console.LanguageConsoleView;
import com.intellij.execution.impl.ConsoleViewImpl;
import com.intellij.injected.editor.VirtualFileWindow;
import com.intellij.openapi.editor.*;
import com.intellij.openapi.editor.ex.util.EditorUtil;
@@ -436,7 +434,7 @@ public class EditorHelper {
private static int getStickyLinesPanelHeight(final @NotNull Editor editor) {
if (editor instanceof EditorImpl editorImpl) {
return 0;
return editorImpl.getStickyLinesPanelHeight();
}
return 0;
}
@@ -729,17 +727,6 @@ public class EditorHelper {
return editor.getEditorKind() == EditorKind.DIFF;
}
/**
* Checks if the editor hosts the console of a run or debug configuration, e.g. the console of a debugged Spring Boot
* application.
* <p>
* Language consoles also extend {@code ConsoleViewImpl}, but have their own handling - see {@link #isPythonConsole}.
*/
public static boolean isRunConsole(@NotNull Editor editor) {
final ConsoleViewImpl consoleView = editor.getUserData(ConsoleViewImpl.CONSOLE_VIEW_IN_EDITOR_VIEW);
return consoleView != null && !(consoleView instanceof LanguageConsoleView);
}
/**
* Checks if the editor hosts a Vim command-line / search history window
* (the synthetic buffer opened by `q:`, `q/`, or `q?`).
@@ -67,12 +67,9 @@ internal val Editor.isIdeaVimDisabledHere: Boolean
* AI Chat – VIM-3786
* Debug evaluate console – VIM-3929
*
* We do want to support Vim actions in some windows, such as the commit window, diff windows and decompiled Java
* files. The consoles are opt-out via the 'ideaeditor' option. Note that the Python console still needs special
* handling so it doesn't lose the Enter and arrow keys (see KeyGroup and ToolWindowNavEverywhere).
*
* VIM-3929 was about the debugger's expression editors, which stay disabled. The run/debug configuration console is
* allowed again, because blocking every non-file editor also took Vim away from the process console.
* We do want to support Vim actions in some windows, such as the commit window, diff windows, decompiled Java
* files, and the Python console. Note that the Python console still needs special handling so it doesn't lose the
* Enter and arrow keys (see KeyGroup and ToolWindowNavEverywhere).
*/
private fun Editor.isAllowedFileEditor(): Boolean {
if (EditorHelper.getVirtualFile(this)?.name?.contains("Dummy.txt") == true) {
@@ -84,16 +81,7 @@ private fun Editor.isAllowedFileEditor(): Boolean {
|| EditorHelper.isDiffEditor(this)
|| EditorHelper.isFileEditor(this)
|| EditorHelper.isCommandHistoryWindow(this)
|| isEnabledConsole()
}
/**
* Checks if this editor is a console that the user has enabled via the 'ideaeditor' option.
*/
internal fun Editor.isEnabledConsole(): Boolean {
val enabledEditors = injector.globalIjOptions().ideaeditor
return (EditorHelper.isPythonConsole(this) && enabledEditors.contains(IjOptionConstants.ideaeditor_python))
|| (EditorHelper.isRunConsole(this) && enabledEditors.contains(IjOptionConstants.ideaeditor_debug))
|| (EditorHelper.isPythonConsole(this) && injector.globalIjOptions().ideapythonconsole)
}
private fun ideaVimDisabledInDialog(ideaVimSupportValue: StringListOptionValue): Boolean {
@@ -1,422 +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.helper
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.colors.EditorColors
import com.intellij.openapi.editor.ex.RangeHighlighterEx
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.openapi.util.Key
import com.maddyhome.idea.vim.api.globalOptions
import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.api.normalizeVisualLine
import com.maddyhome.idea.vim.api.visualLineToBufferLine
import com.maddyhome.idea.vim.common.TextRange
import com.maddyhome.idea.vim.newapi.vim
/**
* Owns the `'hlsearch'`/`'incsearch'` highlighters of a single editor.
*
* This is the only thing allowed to add or remove a search highlighter. It keeps its own list and the editor's markup
* model in step, and a second writer would leave one of the two stale - which is exactly the class of bug this type
* exists to make impossible. Overlays that own their own lifecycle are not search highlights and stay outside: the
* `inccommand` preview ([highlightPreviewMatch]) and the `:s///c` confirmation
* ([addSubstitutionConfirmationHighlight]).
*
* Only the lines that could be on screen are highlighted. Vim computes `'hlsearch'` during redraw, per screen cell,
* and keeps no highlight objects at all; IdeaVim has to materialise a [RangeHighlighter] per match, so it only
* materialises the ones that can be seen.
*
* Highlights are described rather than patched by the caller. [ensureUpToDate] says which pattern should be
* highlighted over which lines, and the class works out how little it can get away with doing.
*/
internal class SearchHighlights(private val editor: Editor) {
private enum class Mode {
/** Nothing is highlighted and no pattern is being tracked. */
NONE,
/** Every match of the pattern inside the realized lines is highlighted - `'hlsearch'`. */
ALL_MATCHES,
/** A single match is highlighted - the `'incsearch'` preview while `'hlsearch'` is off. */
SINGLE_MATCH,
}
/** Kept sorted by start offset, so that trimming an end when the viewport moves is a prefix or a suffix. */
private val highlighters = mutableListOf<RangeHighlighter>()
private var mode = Mode.NONE
private var pattern: String? = null
private var ignoreCase = false
/** The `:1,5s/foo` style line range the highlights are limited to. A [searchEndLine] of -1 means "to the end". */
private var searchStartLine = 0
private var searchEndLine = -1
/** The lines [highlighters] actually covers. Null when nothing has been realized. */
private var realized: IntRange? = null
/**
* The match styled as Vim's `hl-CurSearch`, or null for none.
*
* The class renders the current match but does not decide which one it is - see [setCurrentMatch]. It has to be
* remembered here rather than only applied to a highlighter, because the highlighter it belongs to is thrown away
* and recreated as the viewport moves.
*/
private var currentMatch: TextRange? = null
/** The highlighter added for a current match that fell outside the realized lines - see [adoptCurrentMatch]. */
private var adopted: RangeHighlighter? = null
/**
* True if the pattern being highlighted matches anywhere in the document - what `v:hlsearch` reports.
*
* Deliberately not "is there a highlighter?". The highlights only cover the viewport, but `v:hlsearch` is about the
* document, so a pattern that only matches off screen still counts. The search is the slow path, and only runs when
* there is nothing on screen to answer the question with.
*/
fun hasMatches(): Boolean {
if (highlighters.isNotEmpty()) return true
if (mode != Mode.ALL_MATCHES) return false
val pattern = pattern ?: return false
val lines = searchRangeLines() ?: return false
return injector.searchHelper.findAll(editor.vim, pattern, lines.first, lines.last, ignoreCase, 1).isNotEmpty()
}
/** True if a pattern is being tracked, even if it currently has no matches to show for it. */
val isActive: Boolean
get() = mode != Mode.NONE
/**
* Highlight every match of [pattern] between [searchStartLine] and [searchEndLine] (-1 for the end of the
* document), over the lines that could be on screen. Does nothing if that is already what the editor is showing.
*/
fun ensureUpToDate(pattern: String, ignoreCase: Boolean, searchStartLine: Int = 0, searchEndLine: Int = -1) {
val sameRequest = mode == Mode.ALL_MATCHES &&
pattern == this.pattern &&
ignoreCase == this.ignoreCase &&
searchStartLine == this.searchStartLine &&
searchEndLine == this.searchEndLine
mode = Mode.ALL_MATCHES
this.pattern = pattern
this.ignoreCase = ignoreCase
this.searchStartLine = searchStartLine
this.searchEndLine = searchEndLine
val desired = desiredLines()
val realized = this.realized
when {
!sameRequest || realized == null || desired == null -> rebuild(desired)
// Already covered - the common case for a small scroll, thanks to the margin
realized.first <= desired.first && realized.last >= desired.last -> return
// A jump rather than a scroll, so there is nothing worth reusing
desired.last < realized.first || desired.first > realized.last -> rebuild(desired)
else -> extend(desired, realized)
}
}
/**
* Show [range] as the only highlight - the `'incsearch'` preview of the match the in-progress search would move to,
* when `'hlsearch'` is off and there is nothing else to draw.
*/
fun showSingleMatch(pattern: String, range: TextRange) {
removeAll()
mode = Mode.SINGLE_MATCH
this.pattern = pattern
realized = null
highlighters.add(createSearchMatchHighlighter(editor, range.startOffset, range.endOffset, pattern))
applyCurrentMatch()
}
/**
* Style [match] as Vim's `hl-CurSearch`, or pass null to style every match the same.
*
* Restyles in place, so it does not repeat the search, and does nothing when the current match hasn't moved.
*/
fun setCurrentMatch(match: TextRange?) {
currentMatch = match
applyCurrentMatch()
}
/**
* The viewport moved, so a different set of lines may need to be realized.
*
* Does nothing unless `'hlsearch'` highlights are showing, and nothing unless the new viewport has actually left
* the lines that are already realized - which is the common case for a small scroll.
*/
fun refreshForViewport() {
if (mode != Mode.ALL_MATCHES) return
ensureUpToDate(pattern ?: return, ignoreCase, searchStartLine, searchEndLine)
}
/** The document changed underneath the highlights, so what was realized no longer describes the editor. */
fun refreshAfterDocumentChange() {
when (mode) {
Mode.NONE -> return
// The edit can create or destroy matches anywhere in the realized lines, and it moves every offset after it,
// so there is nothing to reuse
Mode.ALL_MATCHES -> rebuild(desiredLines())
// The preview owns its single match. Keep it, unless the edit deleted the text it covers
Mode.SINGLE_MATCH -> removeInvalid()
}
}
/** Remove every highlight and stop tracking a pattern. */
fun clear() {
removeAll()
mode = Mode.NONE
pattern = null
ignoreCase = false
searchStartLine = 0
searchEndLine = -1
realized = null
currentMatch = null
}
private fun rebuild(desired: IntRange?) {
removeAll()
realized = desired
if (desired != null) {
addMatches(desired, mutableSetOf())
}
applyCurrentMatch()
}
/**
* Move the realized lines to [desired], reusing the highlighters that both ranges have in common.
*
* Scrolling is what drives this, and scrolling is monotonic: [desired] and [realized] overlap in most of their
* length, so throwing everything away and searching again would mean thousands of markup model operations to
* recreate what is already there. Each end is trimmed or filled independently.
*/
private fun extend(desired: IntRange, realized: IntRange) {
trimTo(desired)
// The boundary lines are searched again rather than skipped. A match can start on the last realized line and
// reach into the gap, and a search starting on the next line would never report it. Anything already highlighted
// is filtered out by start offset
val known = highlighters.mapTo(mutableSetOf()) { it.startOffset }
if (desired.first < realized.first) addMatches(desired.first..realized.first, known)
if (desired.last > realized.last) addMatches(realized.last..desired.last, known)
highlighters.sortBy { it.startOffset }
this.realized = desired
applyCurrentMatch()
}
/** Drop the highlighters that [desired] no longer reaches. */
private fun trimTo(desired: IntRange) {
val document = editor.document
val startOffset = document.getLineStartOffset(desired.first)
val endOffset = document.getLineEndOffset(desired.last)
val iterator = highlighters.iterator()
while (iterator.hasNext()) {
val highlighter = iterator.next()
// Keep anything that still overlaps - a multi-line match can start outside the range and reach into it
if (highlighter.isValid && highlighter.endOffset >= startOffset && highlighter.startOffset <= endOffset) {
continue
}
iterator.remove()
if (highlighter === adopted) adopted = null
editor.markupModel.removeHighlighter(highlighter)
}
}
/** Highlight the matches in [lines], skipping any start offset already in [known]. */
private fun addMatches(lines: IntRange, known: MutableSet<Int>) {
val pattern = pattern ?: return
val limit = highlighterLimit()
for (match in injector.searchHelper.findAll(editor.vim, pattern, lines.first, lines.last, ignoreCase)) {
if (highlighters.size >= limit) break
if (!known.add(match.startOffset)) continue
highlighters.add(createSearchMatchHighlighter(editor, match.startOffset, match.endOffset, pattern))
}
}
private fun applyCurrentMatch() {
// Work out the new current match first - it can drop a previously adopted highlighter, which must not then be
// restyled
val current = currentMatch?.let { match ->
highlighters.firstOrNull { it.startOffset == match.startOffset } ?: adoptCurrentMatch(match)
}
highlighters.filter { it !== current && it.isVimCurrentSearchMatch }.forEach { it.clearVimCurrentSearchMatch() }
if (current != null && !current.isVimCurrentSearchMatch) {
current.setAsVimCurrentSearchMatch(editor)
}
}
/**
* Highlight the current match even though it is outside the realized lines, returning null if it shouldn't be.
*
* `'incsearch'` previews the match before scrolling to it, so the preview is regularly off screen when the
* highlights are built. Without this the user would type a pattern and see nothing until the caret caught up.
*
* A match inside the realized lines that has no highlighter is simply not a match, and a `:1,5s/foo` range still
* has to be respected. With `'nohlsearch'` the preview is the only highlight and [showSingleMatch] has drawn it
* already - or deliberately hasn't, because Visual is active and the selection says the same thing.
*/
private fun adoptCurrentMatch(match: TextRange): RangeHighlighter? {
if (mode != Mode.ALL_MATCHES) return null
val pattern = pattern ?: return null
val line = editor.offsetToLogicalPosition(match.startOffset).line
if (realized?.contains(line) == true) return null
if (!isInSearchRange(line)) return null
// At most one match is ever adopted. `c_CTRL-G` can step the preview through several off-screen matches without
// the viewport moving, and each one would otherwise leave a highlighter behind
dropAdopted()
return createSearchMatchHighlighter(editor, match.startOffset, match.endOffset, pattern).also {
highlighters.add(it)
adopted = it
}
}
private fun dropAdopted() {
val highlighter = adopted ?: return
adopted = null
if (highlighters.remove(highlighter)) {
editor.markupModel.removeHighlighter(highlighter)
}
}
private fun isInSearchRange(line: Int) =
line >= searchStartLine && (searchEndLine < 0 || line <= searchEndLine)
/** The `:1,5s/foo` search range, clamped to the document. Null if it doesn't overlap the document at all. */
private fun searchRangeLines(): IntRange? {
val lastLine = editor.document.lineCount - 1
if (lastLine < 0) return null
val endLine = minOf(if (searchEndLine < 0) lastLine else searchEndLine, lastLine)
return if (searchStartLine > endLine) null else searchStartLine..endLine
}
/**
* The lines the highlights should cover: what is on screen, plus a screen's worth of margin above and below,
* narrowed to the search range.
*
* The margin is measured in *visual* lines, because that is what a screen is made of, and only then converted to
* buffer lines. Measuring it in buffer lines gets this wrong in both directions, and badly: one collapsed fold
* makes a 35 line screen span thousands of buffer lines, so the "one screen" margin becomes thousands of lines and
* every rebuild walks all of them - and collapsed folds are the normal state of an IntelliJ editor. Soft wrap does
* the opposite, shrinking the margin to a handful of lines so that a couple of lines of scrolling rebuilds.
*
* The margin is what makes scrolling cheap: it has to be left behind entirely before anything is rebuilt, and it
* is why a match starting above the top edge is still found.
*/
private fun desiredLines(): IntRange? {
val searchRange = searchRangeLines() ?: return null
val vimEditor = editor.vim
val topVisualLine = EditorHelper.getVisualLineAtTopOfScreen(editor)
val bottomVisualLine = EditorHelper.getVisualLineAtBottomOfScreen(editor)
val screenHeight = bottomVisualLine - topVisualLine + 1
val firstVisualLine = vimEditor.normalizeVisualLine(topVisualLine - screenHeight)
val lastVisualLine = vimEditor.normalizeVisualLine(bottomVisualLine + screenHeight)
val first = maxOf(searchRange.first, vimEditor.visualLineToBufferLine(firstVisualLine))
val last = minOf(searchRange.last, vimEditor.visualLineToBufferLine(lastVisualLine))
return if (first > last) null else first..last
}
private fun removeAll() {
highlighters.forEach { editor.markupModel.removeHighlighter(it) }
highlighters.clear()
adopted = null
}
private fun removeInvalid() {
val iterator = highlighters.iterator()
while (iterator.hasNext()) {
val highlighter = iterator.next()
if (!highlighter.isValid) {
iterator.remove()
if (highlighter === adopted) adopted = null
editor.markupModel.removeHighlighter(highlighter)
}
}
}
}
/**
* Never hold more than this many search highlighters in one editor.
*
* A backstop for patterns that match everything: `/.` puts a highlighter on every single character. Scoping the
* highlights to the viewport bounds the ordinary case; this bounds the pathological one, where even a single screen
* is thousands of highlighters.
*
* The limit is 'maxsearchcount', which already bounds the `[1/999]` search count, so the default is 1000. That is
* far more than any realistic pattern matches on one screen, but less than `/.` does - which is the point, though it
* does mean such a search is highlighted only as far as the limit.
*/
private fun highlighterLimit(): Int {
val maxSearchCount = injector.globalOptions().maxsearchcount
return if (maxSearchCount > 0) maxSearchCount + 1 else Int.MAX_VALUE
}
/**
* Create a highlighter styled as a normal search match. Always uses a text attribute key, so the highlight updates
* automatically when the colour scheme changes.
*
* The caller owns the returned highlighter and is responsible for removing it from the markup model.
*/
internal fun createSearchMatchHighlighter(editor: Editor, start: Int, end: Int, tooltip: String): RangeHighlighter {
val highlighter = editor.markupModel.addRangeHighlighter(
EditorColors.TEXT_SEARCH_RESULT_ATTRIBUTES,
start,
end,
HighlighterLayer.SELECTION - 1,
HighlighterTargetArea.EXACT_RANGE,
)
highlighter.errorStripeTooltip = tooltip
return highlighter
}
/** True if this highlighter is for the current match, i.e. it is styled as Vim's `hl-CurSearch`. */
val RangeHighlighter.isVimCurrentSearchMatch: Boolean
get() = getUserData(CURRENT_SEARCH_MATCH) == true
/** Marks the highlighter of the current match, so we can find and restyle it as the caret moves. */
private val CURRENT_SEARCH_MATCH = Key.create<Boolean>("ideavim.search.currentMatch")
/** Apply the current match style - Vim's `hl-CurSearch`, as opposed to `hl-Search` for the other matches. */
private fun RangeHighlighter.setAsVimCurrentSearchMatch(editor: Editor) {
val highlighter = this as? RangeHighlighterEx ?: return
highlighter.setTextAttributes(currentSearchMatchAttributes(editor))
putUserData(CURRENT_SEARCH_MATCH, true)
}
/** Remove the current match style, falling back to the text attribute key the highlighter was created with. */
private fun RangeHighlighter.clearVimCurrentSearchMatch() {
val highlighter = this as? RangeHighlighterEx ?: return
highlighter.setTextAttributes(null)
putUserData(CURRENT_SEARCH_MATCH, null)
}
/**
* The attributes of the current match - Vim's `hl-CurSearch`. These are the same modifications that the Find live
* preview makes to the search result attributes.
*
* There is no text attribute key for the current match, so unlike the other matches, it won't follow a change to the
* editor's colour scheme until it's restyled by the next caret move or search.
*/
private fun currentSearchMatchAttributes(editor: Editor): TextAttributes {
return editor.colorsScheme.getAttributes(EditorColors.TEXT_SEARCH_RESULT_ATTRIBUTES).clone().apply {
effectType = EffectType.ROUNDED_BOX
effectColor = editor.colorsScheme.getColor(EditorColors.CARET_COLOR)
}
}
@@ -12,11 +12,13 @@ package com.maddyhome.idea.vim.helper
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.colors.EditorColors
import com.intellij.openapi.editor.ex.RangeHighlighterEx
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.openapi.util.Key
import com.maddyhome.idea.vim.api.VimEditor
import com.maddyhome.idea.vim.api.globalOptions
import com.maddyhome.idea.vim.api.injector
@@ -108,11 +110,11 @@ fun addSubstitutionConfirmationHighlight(editor: Editor, start: Int, end: Int):
* Highlight a single range using the standard search-result attributes, returning the highlighter so the caller can
* remove it later.
*
* Unlike the search highlights, this is not tracked by [SearchHighlights], so it is suitable for transient overlays -
* such as the `inccommand` preview - that manage their own highlighter lifecycle.
* Unlike [highlightSearchResults], this does not touch the editor's tracked incsearch highlighters, so it is suitable
* for transient overlays - such as the `inccommand` preview - that manage their own highlighter lifecycle.
*/
fun highlightPreviewMatch(editor: Editor, start: Int, end: Int, tooltip: String): RangeHighlighter {
return createSearchMatchHighlighter(editor, start, end, tooltip)
return addSearchMatchHighlighter(editor, start, end, tooltip)
}
/**
@@ -158,32 +160,30 @@ private fun updateSearchHighlights(
val searchStartLine = searchRange?.startLine ?: 0
val searchEndLine = (searchRange?.endLine ?: -1).coerceAtMost(vimEditor.lineCount() - 1)
// The current match belongs to the 'incsearch' preview, and only to the editor being searched. Once the search has
// been accepted or cancelled there is no current match any more, however the caret is then moved - the result is
// simply where the caret is, and every match looks the same again (VIM-4308)
val currentMatch = if (isIncsearch && isCurrentEditor) {
findIncsearchMatch(vimEditor, pattern, initialOffset, count1, forwards, shouldIgnoreSmartCase)
} else {
null
}
val currentMatchOffset = currentMatch?.startOffset ?: -1
editor.vimSearchHighlights.setCurrentMatch(currentMatch)
if (shouldAddAllSearchHighlights(pattern, showHighlights)) {
var incsearchMatchOffset = -1
if (shouldAddAllSearchHighlights(editor, pattern, showHighlights)) {
// hlsearch (+ incsearch/noincsearch)
addAllSearchHighlights(vimEditor, pattern, searchStartLine, searchEndLine, shouldIgnoreSmartCase)
editor.vimLastSearch = pattern
} else if (currentMatch != null && shouldAddCurrentMatchSearchHighlight(pattern, showHighlights, initialOffset)) {
} else if (isCurrentEditor && shouldAddCurrentMatchSearchHighlight(pattern, showHighlights, initialOffset)) {
// nohlsearch + incsearch. Even though search highlights are disabled, we still show a highlight (current editor
// only), because 'incsearch' is active. We don't show one if Visual is active (behind Command-line, of course),
// because the Visual selection is enough
if (!vimEditor.inVisualMode && !vimEditor.inCommandLineModeWithVisual) {
editor.vimSearchHighlights.showSingleMatch(pattern, currentMatch)
}
// only), because 'incsearch' is active
incsearchMatchOffset =
addIncsearchMatchHighlight(vimEditor, pattern, initialOffset, count1, forwards, shouldIgnoreSmartCase)
}
// Remember the incsearch match, so that `c_CTRL-R_CTRL-W` can insert the word after it
editor.vimIncsearchCurrentMatch = currentMatch
// The current match highlight belongs to the 'incsearch' preview, and only in the editor being searched. Once the
// search has been accepted or cancelled there is no current match any more, however the caret is then moved - the
// result is simply where the caret is, and every match looks the same again (VIM-4308)
val currentMatchOffset = when {
!isIncsearch || !isCurrentEditor -> -1
incsearchMatchOffset != -1 -> incsearchMatchOffset
else -> findClosestMatch(highlightedMatches(editor), initialOffset, count1, forwards)
}
setCurrentSearchMatchHighlight(editor, currentMatchOffset)
// Remember where the incsearch match is, so that `c_CTRL-R_CTRL-W` can insert the word after it
editor.vimIncsearchCurrentMatchOffset = currentMatchOffset
if (isCurrentEditor) {
currentEditorCurrentMatchOffset = currentMatchOffset
@@ -219,36 +219,42 @@ private fun addAllSearchHighlights(
// `:1,5s/foo`, Vim will highlight all occurrences of `foo` in the first five lines of all visible windows
if (searchStartLine > editor.lineCount() - 1) return
editor.ij.vimSearchHighlights.ensureUpToDate(
val results = injector.searchHelper.findAll(
editor,
pattern,
shouldIgnoreCase(pattern, shouldIgnoreSmartCase),
searchStartLine,
searchEndLine,
shouldIgnoreCase(pattern, shouldIgnoreSmartCase)
)
highlightSearchResults(editor.ij, pattern, results)
}
/**
* Find the match that the in-progress search would move to - Vim's `hl-CurSearch`, and what `c_CTRL-G`/`c_CTRL-T`
* step through. Returns null if the pattern doesn't match.
*
* This is a real search rather than a lookup among the highlighted matches. The highlights only cover part of the
* document, and are only rebuilt when something about them changes, but the current match moves while the pattern
* stays the same.
* Highlight the single match that the in-progress search would move to, returning its start offset, or -1 if the
* pattern doesn't match.
*/
private fun findIncsearchMatch(
private fun addIncsearchMatchHighlight(
editor: VimEditor,
pattern: String,
initialOffset: Int,
count1: Int,
forwards: Boolean,
shouldIgnoreSmartCase: Boolean,
): TextRange? {
): Int {
val searchOptions = EnumSet.of(SearchOptions.WHOLE_FILE)
if (injector.globalOptions().wrapscan) searchOptions.add(SearchOptions.WRAP)
if (shouldIgnoreSmartCase) searchOptions.add(SearchOptions.IGNORE_SMARTCASE)
if (!forwards) searchOptions.add(SearchOptions.BACKWARDS)
return injector.searchHelper.findPattern(editor, pattern, initialOffset, count1, searchOptions)
val result = injector.searchHelper.findPattern(editor, pattern, initialOffset, count1, searchOptions) ?: return -1
// We don't show a highlight if Visual is active (behind Command-line, of course), because the Visual selection is
// enough. We still return the offset, so the caller can update the selection
if (!editor.inVisualMode && !editor.inCommandLineModeWithVisual) {
highlightSearchResults(editor.ij, pattern, listOf(result))
}
return result.startOffset
}
fun updateSearchCount(
@@ -342,18 +348,94 @@ private fun shouldRemoveSearchHighlights(editor: Editor, newPattern: String?, hl
private fun removeSearchHighlights(editor: Editor) {
editor.vimLastSearch = null
editor.vimSearchHighlights.clear()
val ehl = editor.vimLastHighlighters ?: return
for (rh in ehl) {
editor.markupModel.removeHighlighter(rh)
}
editor.vimLastHighlighters = null
}
/**
* Add search highlights if hlSearch is true and there is a pattern to highlight.
*
* There is deliberately no check for the pattern being unchanged - [SearchHighlights.ensureUpToDate] compares the
* request against what the editor is already showing, so asking for highlights that are already there costs nothing.
* Add search highlights if hlSearch is true and the pattern is changed
*/
@Contract("_, false -> false; null, true -> false")
private fun shouldAddAllSearchHighlights(newPattern: String?, hlSearch: Boolean): Boolean {
return hlSearch && !newPattern.isNullOrEmpty()
@Contract("_, _, false -> false; _, null, true -> false")
private fun shouldAddAllSearchHighlights(editor: Editor, newPattern: String?, hlSearch: Boolean): Boolean {
return hlSearch && newPattern != null && newPattern != editor.vimLastSearch && newPattern != ""
}
/**
* The current match is deliberately derived from the existing highlights rather than from a fresh search. The highlights
* are only recreated when the pattern changes, but the current match can move while the pattern stays the same -
* `c_CTRL-G`/`c_CTRL-T` step the incsearch preview through the matches, and `hl-CurSearch` follows the caret
*/
private fun highlightedMatches(editor: Editor): List<TextRange> {
val highlighters = editor.vimLastHighlighters ?: return emptyList()
return highlighters.filter { it.isValid }.map { TextRange(it.startOffset, it.endOffset) }
}
private fun findClosestMatch(
results: List<TextRange>,
initialOffset: Int,
count: Int,
forwards: Boolean,
): Int {
if (results.isEmpty() || initialOffset == -1) {
return -1
}
val sortedResults = if (forwards) {
results.sortedBy { it.startOffset }
} else {
results.sortedByDescending { it.startOffset }
}
val closestIndex = if (forwards) {
sortedResults.indexOfFirst { it.startOffset > initialOffset }
} else {
sortedResults.indexOfFirst { it.startOffset < initialOffset }
}
if (closestIndex == -1 && !injector.globalOptions().wrapscan) {
return -1
}
val nextIndex = closestIndex.coerceAtLeast(0) + (count - 1)
if (nextIndex >= sortedResults.size && !injector.globalOptions().wrapscan) {
return -1
}
return sortedResults[nextIndex % results.size].startOffset
}
/**
* Add and track a highlight for each of [results]. They are all added as normal matches - the current match is styled
* separately, by [setCurrentSearchMatchHighlight].
*/
fun highlightSearchResults(
editor: Editor,
pattern: String,
results: List<TextRange>,
) {
// Don't start tracking highlighters for an empty list - that would make the editor look like it has search highlights
if (results.isEmpty()) return
val highlighters = editor.vimLastHighlighters
?: mutableListOf<RangeHighlighter>().also { editor.vimLastHighlighters = it }
for (range in results) {
highlighters.add(addSearchMatchHighlighter(editor, range.startOffset, range.endOffset, pattern))
}
}
/** Always uses a text attribute key, so the highlight updates automatically when the colour scheme changes. */
private fun addSearchMatchHighlighter(editor: Editor, start: Int, end: Int, tooltip: String): RangeHighlighter {
val highlighter = editor.markupModel.addRangeHighlighter(
EditorColors.TEXT_SEARCH_RESULT_ATTRIBUTES,
start,
end,
HighlighterLayer.SELECTION - 1,
HighlighterTargetArea.EXACT_RANGE,
)
highlighter.errorStripeTooltip = tooltip
return highlighter
}
/**
@@ -366,7 +448,60 @@ private fun shouldAddAllSearchHighlights(newPattern: String?, hlSearch: Boolean)
*/
fun clearCurrentSearchMatchHighlight(editor: Editor) {
if (editor.isDisposed) return
editor.vimSearchHighlights.setCurrentMatch(editor.vimIncsearchCurrentMatch)
setCurrentSearchMatchHighlight(editor, editor.vimIncsearchCurrentMatchOffset ?: -1)
}
/**
* Pass -1 to clear the current match highlight. Restyles the existing highlighters in place, so it does not repeat the
* search, and does nothing if the current match hasn't moved.
*/
private fun setCurrentSearchMatchHighlight(editor: Editor, currentMatchOffset: Int) {
val highlighters = editor.vimLastHighlighters ?: return
val previous = highlighters.firstOrNull { it.isVimCurrentSearchMatch }
val current = if (currentMatchOffset == -1) {
null
} else {
highlighters.firstOrNull { it.startOffset == currentMatchOffset }
}
if (previous === current) return
previous?.clearVimCurrentSearchMatch()
current?.setAsVimCurrentSearchMatch(editor)
}
/** True if this highlighter is for the current match, i.e. it is styled as Vim's `hl-CurSearch`. */
val RangeHighlighter.isVimCurrentSearchMatch: Boolean
get() = getUserData(CURRENT_SEARCH_MATCH) == true
/** Marks the highlighter of the current match, so we can find and restyle it as the caret moves. */
private val CURRENT_SEARCH_MATCH = Key.create<Boolean>("ideavim.search.currentMatch")
/** Apply the current match style - Vim's `hl-CurSearch`, as opposed to `hl-Search` for the other matches. */
private fun RangeHighlighter.setAsVimCurrentSearchMatch(editor: Editor) {
val highlighter = this as? RangeHighlighterEx ?: return
highlighter.setTextAttributes(currentSearchMatchAttributes(editor))
putUserData(CURRENT_SEARCH_MATCH, true)
}
/** Remove the current match style, falling back to the text attribute key the highlighter was created with. */
private fun RangeHighlighter.clearVimCurrentSearchMatch() {
val highlighter = this as? RangeHighlighterEx ?: return
highlighter.setTextAttributes(null)
putUserData(CURRENT_SEARCH_MATCH, null)
}
/**
* The attributes of the current match - Vim's `hl-CurSearch`. These are the same modifications that the Find live
* preview makes to the search result attributes.
*
* There is no text attribute key for the current match, so unlike the other matches, it won't follow a change to the
* editor's colour scheme until it's restyled by the next caret move or search.
*/
private fun currentSearchMatchAttributes(editor: Editor): TextAttributes {
return editor.colorsScheme.getAttributes(EditorColors.TEXT_SEARCH_RESULT_ATTRIBUTES).clone().apply {
effectType = EffectType.ROUNDED_BOX
effectColor = editor.colorsScheme.getColor(EditorColors.CARET_COLOR)
}
}
/**
@@ -15,6 +15,7 @@ import com.intellij.openapi.editor.Caret
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.RangeMarker
import com.intellij.openapi.editor.VisualPosition
import com.intellij.openapi.editor.markup.RangeHighlighter
import com.intellij.openapi.util.Key
import com.intellij.openapi.util.UserDataHolder
import com.intellij.openapi.util.UserDataHolderBase
@@ -23,7 +24,6 @@ import com.maddyhome.idea.vim.api.LocalMarkStorage
import com.maddyhome.idea.vim.api.SelectionInfo
import com.maddyhome.idea.vim.api.VimOutputPanel
import com.maddyhome.idea.vim.common.InsertSequence
import com.maddyhome.idea.vim.common.TextRange
import com.maddyhome.idea.vim.common.VimEditorReplaceMask
import com.maddyhome.idea.vim.group.visual.VisualChange
import com.maddyhome.idea.vim.group.visual.vimLeadSelectionOffset
@@ -108,20 +108,13 @@ var Editor.vimInitialised: Boolean by userDataOr { false }
fun unInitializeEditor(editor: Editor) {
editor.vimLastSelectionType = null
editor.vimMorePanel = null
editor.vimSearchHighlights.clear()
editor.vimLastHighlighters = null
editor.vimInitialised = false
}
var Editor.vimLastSearch: String? by userData()
/** The match the in-progress `'incsearch'` preview is showing, or null when there is no search in progress. */
var Editor.vimIncsearchCurrentMatch: TextRange? by userData()
/**
* The editor's search highlights. Created on first use - see [SearchHighlights], which owns every search highlighter
* in this editor.
*/
internal var Editor.vimSearchHighlights: SearchHighlights by userDataOr { SearchHighlights(this as Editor) }
var Editor.vimLastHighlighters: MutableCollection<RangeHighlighter>? by userData()
var Editor.vimIncsearchCurrentMatchOffset: Int? by userData()
/***
* @see :help visualmode()
@@ -8,6 +8,7 @@
package com.maddyhome.idea.vim.listener
import com.intellij.execution.impl.ConsoleViewImpl
import com.intellij.openapi.application.ApplicationManager
import com.maddyhome.idea.vim.KeyHandler
import com.maddyhome.idea.vim.LastUsedEditorInfo
@@ -27,8 +28,6 @@ import com.maddyhome.idea.vim.state.mode.Mode
* However, in IJ we would like to start editing in some editors in INSERT mode (e.g., consoles)
* It is different to we had previously. Now we go to INSERT mode not only when we focus on the console the first time, but every time.
* Going to INSERT on every focus is easier to implement and more consistent (behavior is always the same, you don't have to remember if you are focusing a console the first time or not)
*
* The run/debug configuration console is the exception - see [enterNormalModeInRunConsole].
*/
class IJEditorFocusListener : EditorListener {
override fun focusGained(editor: VimEditor) {
@@ -78,30 +77,17 @@ class IJEditorFocusListener : EditorListener {
else -> {}
}
}
enterNormalModeInRunConsole(editor)
ApplicationManager.getApplication().invokeLater {
if (ijEditor.isDisposed) return@invokeLater
// The Python console keeps the user's current Vim mode on focus, so don't force Insert mode here either.
if (EditorHelper.isPythonConsole(ijEditor)) return@invokeLater
val consoleView: ConsoleViewImpl? = ijEditor.getUserData(ConsoleViewImpl.CONSOLE_VIEW_IN_EDITOR_VIEW)
if (consoleView != null && consoleView.isRunning && !ijEditor.inInsertMode) {
// Switch to Insert mode, but make sure we reset the editor to actually make it apply
switchToInsertMode.run()
KeyHandler.getInstance().reset(editor)
}
}
KeyHandler.getInstance().reset(editor)
}
/**
* The run/debug configuration console is entered in NORMAL mode, so that Vim navigation works as soon as it is
* focused, and so that Escape defocuses the tool window in two steps - the first Escape leaves Insert or Visual mode,
* the next one falls through to the platform (see `VimShortcutKeyAction.isEnabledForEscape`). Typing into the
* process' stdin is an explicit `i`/`a`.
*
* The mode is switched asynchronously because the console view this check relies on is only put into the editor's
* user data once the editor has finished initialising.
*/
private fun enterNormalModeInRunConsole(editor: VimEditor) {
ApplicationManager.getApplication().invokeLater {
val ijEditor = editor.ij
if (ijEditor.isDisposed || !ijEditor.inInsertMode) return@invokeLater
if (!EditorHelper.isRunConsole(ijEditor)) return@invokeLater
val context: ExecutionContext = injector.executionContextManager.getEditorExecutionContext(editor)
editor.exitInsertMode(context)
KeyHandler.getInstance().lastUsedEditorInfo = LastUsedEditorInfo(ijEditor.hashCode(), false)
// Reset the key handler to make leaving Insert mode actually apply
KeyHandler.getInstance().reset(editor)
}
}
}
@@ -34,10 +34,7 @@ import com.intellij.openapi.editor.event.EditorMouseListener
import com.intellij.openapi.editor.event.EditorMouseMotionListener
import com.intellij.openapi.editor.event.SelectionEvent
import com.intellij.openapi.editor.event.SelectionListener
import com.intellij.openapi.editor.event.VisibleAreaEvent
import com.intellij.openapi.editor.event.VisibleAreaListener
import com.intellij.openapi.editor.ex.DocumentEx
import com.intellij.openapi.editor.ex.FoldingListener
import com.intellij.openapi.editor.ex.EditorEx
import com.intellij.openapi.editor.ex.FocusChangeListener
import com.intellij.openapi.editor.impl.EditorComponentImpl
@@ -65,7 +62,6 @@ import com.intellij.openapi.vfs.newvfs.BulkFileListener
import com.intellij.openapi.vfs.newvfs.events.VFileCreateEvent
import com.intellij.openapi.vfs.newvfs.events.VFileEvent
import com.intellij.testFramework.LightVirtualFile
import com.intellij.util.Alarm
import com.intellij.util.ExceptionUtil
import com.intellij.util.SlowOperations
import com.maddyhome.idea.vim.EventFacade
@@ -89,7 +85,6 @@ import com.maddyhome.idea.vim.autocmd.IjFileTypeMapping
import com.maddyhome.idea.vim.common.ModeChangeListener
import com.maddyhome.idea.vim.common.ModeWillChangeListener
import com.maddyhome.idea.vim.group.CommentsOptionInitializer
import com.maddyhome.idea.vim.group.ConsoleOptionChangeListener
import com.maddyhome.idea.vim.group.ControlCharsEditorHighlighter
import com.maddyhome.idea.vim.group.FileGroupHelper
import com.maddyhome.idea.vim.group.IjOptions
@@ -99,6 +94,7 @@ import com.maddyhome.idea.vim.group.MarkUpdater
import com.maddyhome.idea.vim.group.MotionGroup
import com.maddyhome.idea.vim.group.NumberChangeListener
import com.maddyhome.idea.vim.group.OptionGroup
import com.maddyhome.idea.vim.group.PythonConsoleOptionChangeListener
import com.maddyhome.idea.vim.group.ScrollOptionsChangeListener
import com.maddyhome.idea.vim.group.WindowJumpsChangeListener
import com.maddyhome.idea.vim.group.visual.IdeaSelectionControl
@@ -121,7 +117,6 @@ import com.maddyhome.idea.vim.helper.resetVimLastColumn
import com.maddyhome.idea.vim.helper.updateCaretsVisualAttributes
import com.maddyhome.idea.vim.helper.vimDisabled
import com.maddyhome.idea.vim.helper.vimInitialised
import com.maddyhome.idea.vim.helper.vimSearchHighlights
import com.maddyhome.idea.vim.key.noteCaretMoveInInsertSession
import com.maddyhome.idea.vim.key.resetAbbreviationSession
import com.maddyhome.idea.vim.newapi.IjVimEditor
@@ -252,7 +247,7 @@ object VimListenerManager {
optionGroup.addGlobalOptionChangeListener(Options.showmode, macroWidgetOptionListener)
optionGroup.addEffectiveOptionValueChangeListener(Options.keymap, KeymapChangeListener)
optionGroup.addGlobalOptionChangeListener(Options.ideawindowjumps, WindowJumpsChangeListener)
optionGroup.addGlobalOptionChangeListener(IjOptions.ideaeditor, ConsoleOptionChangeListener)
optionGroup.addGlobalOptionChangeListener(IjOptions.ideapythonconsole, PythonConsoleOptionChangeListener)
// The listeners are registered _after_ ideavimrc has been evaluated, so trigger these listeners to ensure we're
// up to date
@@ -302,7 +297,7 @@ object VimListenerManager {
optionGroup.removeGlobalOptionChangeListener(Options.showmode, modeWidgetOptionListener)
optionGroup.removeGlobalOptionChangeListener(Options.showmode, macroWidgetOptionListener)
optionGroup.removeGlobalOptionChangeListener(Options.ideawindowjumps, WindowJumpsChangeListener)
optionGroup.removeGlobalOptionChangeListener(IjOptions.ideaeditor, ConsoleOptionChangeListener)
optionGroup.removeGlobalOptionChangeListener(IjOptions.ideapythonconsole, PythonConsoleOptionChangeListener)
BufNewFileTracker.clear()
}
@@ -391,13 +386,6 @@ object VimListenerManager {
eventFacade.addComponentMouseListener(editor.contentComponent, ComponentMouseListener, perEditorDisposable)
eventFacade.addCaretListener(editor, EditorCaretHandler, perEditorDisposable)
// Search highlights only cover the part of the document that is on screen, so they have to follow it. Scrolling
// and resizing both arrive as a visible area change; collapsing a fold does not, unless it happens to move the
// scrollbar, but it does change which offsets are under those pixels
val searchHighlightsViewportHandler = SearchHighlightsViewportHandler(editor, perEditorDisposable)
eventFacade.addVisibleAreaListener(editor, searchHighlightsViewportHandler, perEditorDisposable)
eventFacade.addFoldingListener(editor, searchHighlightsViewportHandler, perEditorDisposable)
injector.editorGroup.editorCreated(IjVimEditor(editor))
VimPlugin.getChange().editorCreated(IjVimEditor(editor), perEditorDisposable)
CommentsOptionInitializer.initializeForEditor(editor)
@@ -635,15 +623,21 @@ object VimListenerManager {
/**
* Retries initialisation for editors that can only be recognised as supported after they've been created.
*
* Consoles mark themselves only after creating their editor: `PythonConsoleView` sets `PYDEV_CONSOLE_KEY` on the
* virtual file after `LanguageConsoleImpl`'s constructor has created the input editor, and
* `ConsoleViewImpl.createConsoleEditor` sets `CONSOLE_VIEW_IN_EDITOR_VIEW` after `EditorFactory.createViewer` - the
* very call that triggers [editorCreated]. Both are fully constructed within a single EDT runnable, so re-checking
* in `invokeLater` sees the markers. Anything still unsupported on the second look is dropped, as before.
* The Python console's input editor is created inside `LanguageConsoleImpl`'s constructor, before
* `PythonConsoleView` marks the console's virtual file with `PYDEV_CONSOLE_KEY`. Consoles started by
* "Run file in Python Console" are named after the run configuration rather than "Python Console" (see
* `PydevConsoleRunnerFactory.createConsoleRunnerWithFile`), so at [editorCreated] time there is nothing to identify
* them by and [vimDisabled] rejects them. The console is fully constructed within a single EDT runnable, so
* re-checking in `invokeLater` sees the marker.
*
* Only light Python files are considered, to keep this from queueing work for every unsupported editor. Anything
* that still isn't supported on the second look (e.g. a Python code fragment in the debugger's evaluate window) is
* dropped, exactly as before.
*/
private fun scheduleDeferredInitialisation(editor: Editor) {
if (editor.isDisposed) return
if (!mayBecomeSupported(editor)) return
val file = EditorHelper.getVirtualFile(editor) ?: return
if (file !is LightVirtualFile || file.extension != PYTHON_EXTENSION) return
// Capture the opening editor now rather than in the callback - by then the selected editor might have changed
val openingEditor = getOpeningEditor(editor)
@@ -654,16 +648,6 @@ object VimListenerManager {
}
}
/**
* Keeps [scheduleDeferredInitialisation] from queueing work for every unsupported editor. Consoles have no virtual
* file to recognise them by at creation time, so [EditorKind.CONSOLE] is all we have to go on here.
*/
private fun mayBecomeSupported(editor: Editor): Boolean {
if (editor.editorKind == EditorKind.CONSOLE) return true
val file = EditorHelper.getVirtualFile(editor) ?: return false
return file is LightVirtualFile && file.extension == PYTHON_EXTENSION
}
override fun editorReleased(event: EditorFactoryEvent) {
if (vimDisabled(event.editor)) return
val vimEditor = event.editor.vim
@@ -1103,59 +1087,6 @@ object VimListenerManager {
}
}
/**
* Keeps the viewport-scoped search highlights in step with what the editor is actually showing.
*
* Both events are per editor and both arrive on the EDT, which is what reading the visible area and writing to the
* markup model need.
*/
private class SearchHighlightsViewportHandler(
private val editor: Editor,
parentDisposable: Disposable,
) : VisibleAreaListener, FoldingListener {
private val alarm = Alarm(Alarm.ThreadToUse.SWING_THREAD, parentDisposable)
override fun visibleAreaChanged(event: VisibleAreaEvent) {
// The viewport fires this for horizontal scrolling too, which doesn't change which lines are on screen.
// Width is part of the test because with soft wrap it does: a narrower editor wraps more, so fewer buffer
// lines fit on screen
val old = event.oldRectangle
val new = event.newRectangle
if (old != null && old.y == new.y && old.height == new.height && old.width == new.width) return
scheduleRefresh()
}
override fun onFoldProcessingEnd() {
scheduleRefresh()
}
/**
* VisibleAreaEvent comes straight from the viewport's own Swing ChangeListener, synchronously on the EDT - once
* per frame of an animated scroll, once per trackpad tick. Rebuilding the highlights inside that dispatch is
* what makes the scrollbar stick, so the work is coalesced instead. This is what the platform does for its own
* viewport-driven work in EditorMarkupModelImpl.
*
* The delay costs nothing visible: the highlights already extend a screen beyond the viewport, so you would have
* to scroll further than that within the delay to see unhighlighted text.
*/
private fun scheduleRefresh() {
if (injector.application.isUnitTest()) {
// Tests assert the markup model directly after typing, and an alarm firing afterwards would be a race
editor.vimSearchHighlights.refreshForViewport()
return
}
alarm.cancelAllRequests()
alarm.addRequest({
if (!editor.isDisposed) editor.vimSearchHighlights.refreshForViewport()
}, COALESCE_DELAY_MS)
}
private companion object {
private const val COALESCE_DELAY_MS = 50
}
}
/**
* Caret listener registered only for editors that we're interested in. Used to update caret shapes when carets are
* added/removed. Also tracks the expected last column location of the caret.
@@ -29,11 +29,13 @@ import com.maddyhome.idea.vim.common.TextRange
import com.maddyhome.idea.vim.diagnostic.vimLogger
import com.maddyhome.idea.vim.group.XMLGroup
import com.maddyhome.idea.vim.helper.addSubstitutionConfirmationHighlight
import com.maddyhome.idea.vim.helper.highlightSearchResults
import com.maddyhome.idea.vim.helper.shouldIgnoreCase
import com.maddyhome.idea.vim.helper.clearCurrentSearchMatchHighlight
import com.maddyhome.idea.vim.helper.updateSearchCount
import com.maddyhome.idea.vim.helper.updateSearchHighlights
import com.maddyhome.idea.vim.helper.vimIncsearchCurrentMatch
import com.maddyhome.idea.vim.helper.vimSearchHighlights
import com.maddyhome.idea.vim.helper.vimIncsearchCurrentMatchOffset
import com.maddyhome.idea.vim.helper.vimLastHighlighters
import com.maddyhome.idea.vim.options.GlobalOptionChangeListener
import org.jdom.Element
import org.jetbrains.annotations.Contract
@@ -67,6 +69,21 @@ open class IjVimSearchGroup : VimSearchGroupBase(), PersistentStateComponent<Ele
private var showSearchHighlight: Boolean = injector.globalOptions().hlsearch
override fun highlightSearchLines(
editor: VimEditor,
startLine: Int,
endLine: Int,
) {
val pattern = getLastUsedPattern()
if (pattern != null) {
val results = injector.searchHelper.findAll(
editor, pattern, startLine, endLine,
shouldIgnoreCase(pattern, lastIgnoreSmartCase)
)
highlightSearchResults(editor.ij, pattern, results)
}
}
override fun updateSearchHighlights(force: Boolean) {
updateSearchHighlights(getLastUsedPattern(), lastIgnoreSmartCase, showSearchHighlight, force)
}
@@ -106,14 +123,20 @@ open class IjVimSearchGroup : VimSearchGroupBase(), PersistentStateComponent<Ele
}
for (vimEditor in vimEditors) {
val editor = vimEditor.ij
if (editor.vimSearchHighlights.hasMatches()) {
if (editor.vimLastHighlighters != null) {
return true
}
}
return false
}
override fun getCurrentIncsearchResultRange(editor: VimEditor): TextRange? = editor.ij.vimIncsearchCurrentMatch
override fun getCurrentIncsearchResultRange(editor: VimEditor): TextRange? {
val ijEditor = editor.ij
val incsearchHighlighters = ijEditor.vimLastHighlighters ?: return null
val currentOffset = ijEditor.vimIncsearchCurrentMatchOffset ?: return null
val currentHighlighter = incsearchHighlighters.find { it.startOffset == currentOffset }
return currentHighlighter?.textRange?.vim
}
override fun setShouldShowSearchHighlights() {
showSearchHighlight = injector.globalOptions().hlsearch
@@ -231,7 +254,7 @@ open class IjVimSearchGroup : VimSearchGroupBase(), PersistentStateComponent<Ele
/**
* Rebuilds the search highlights of the changed document, since the edit may have created or destroyed matches
* Removes and adds highlights for current search pattern when the document is edited
*/
class DocumentSearchListener @Contract(pure = true) private constructor() : DocumentListener {
override fun documentChanged(event: DocumentEvent) {
@@ -239,15 +262,52 @@ open class IjVimSearchGroup : VimSearchGroupBase(), PersistentStateComponent<Ele
// Note that the change may have come from a remote guest in Code With Me scenarios (in which case
// ClientId.current will be a guest ID), but we don't care - we still need to add/remove highlights for the
// changed text. Make sure we only update local editors, though.
for (vimEditor in injector.editorGroup.getEditors(IjVimDocument(event.document))) {
val document = event.document
for (vimEditor in injector.editorGroup.getEditors(IjVimDocument(document))) {
val editor = vimEditor.ij
if (!editor.vimSearchHighlights.isActive) continue
var existingHighlighters = editor.vimLastHighlighters ?: continue
editor.vimSearchHighlights.refreshAfterDocumentChange()
if (logger.isDebug()) {
logger.debug("hls=$existingHighlighters")
logger.debug("event=$event")
}
// The rebuilt highlights are all normal matches. An edit never makes a match current - only an in-progress
// 'incsearch' has one, and this puts its highlight back if the rebuild removed it
// We can only re-highlight whole lines, so clear any highlights in the affected lines.
// If we're deleting lines, this will clear + re-highlight the new current line, which hasn't been modified.
// However, we still want to re-highlight this line in case any highlights cross the line boundaries.
// If we're adding lines, this will clear + re-highlight all new lines.
val startPosition = editor.offsetToLogicalPosition(event.offset)
val endPosition = editor.offsetToLogicalPosition(event.offset + event.newLength)
val startLineOffset = document.getLineStartOffset(startPosition.line)
val endLineOffset = document.getLineEndOffset(endPosition.line)
// Remove any highlights that have already been deleted, and remove + clear those that intersect with the change
val iter = existingHighlighters.iterator()
while (iter.hasNext()) {
val highlighter = iter.next()
if (!highlighter.isValid) {
iter.remove()
} else if (highlighter.textRange.intersects(startLineOffset, endLineOffset)) {
iter.remove()
editor.markupModel.removeHighlighter(highlighter)
}
}
(injector.searchGroup as VimSearchGroupBase).highlightSearchLines(
editor.vim,
startPosition.line,
endPosition.line
)
// The re-highlighted lines are all added as normal matches. An edit never makes a match current - only an
// in-progress 'incsearch' has one, and this puts its highlight back if the edit removed it
clearCurrentSearchMatchHighlight(editor)
if (logger.isDebug()) {
existingHighlighters = editor.vimLastHighlighters!!
logger.debug("sl=" + startPosition.line + ", el=" + endPosition.line)
logger.debug("hls=$existingHighlighters")
}
}
}
@@ -198,6 +198,13 @@ private object VimActionsPopup {
null,
),
)
actionGroup.add(
HelpLink(
"Take Survey ↗",
"https://jb.gg/axootm",
AllIcons.Actions.IntentionBulb,
),
)
actionGroup.add(WhatsNewIdeaVimAction())
actionGroup.addSeparator(MessageHelper.message("widget.vim.actions.popup.eap.choice.active.text"))
@@ -43,6 +43,6 @@ internal class IjVimrcPathService :
// IDE macros must be expanded first: they use the `$NAME$` syntax, and the Vim expansion would otherwise treat
// `$USER_HOME` as an unknown environment variable and replace it with an empty string
val withMacrosExpanded = PathMacroManager.getInstance(ApplicationManager.getApplication()).expandPath(trimmed)
return Path(injector.pathExpansion.expandPath(withMacrosExpanded ?: trimmed))
return Path(injector.pathExpansion.expandPath(withMacrosExpanded))
}
}
@@ -16,7 +16,6 @@ import com.maddyhome.idea.vim.api.keys
import com.maddyhome.idea.vim.command.MappingMode
import com.maddyhome.idea.vim.history.VimHistory
import com.maddyhome.idea.vim.state.mode.Mode
import com.maddyhome.idea.vim.state.mode.SelectionType
import org.jetbrains.plugins.ideavim.ExceptionHandler
import org.jetbrains.plugins.ideavim.OnlyThrowLoggedErrorProcessor
import org.jetbrains.plugins.ideavim.SkipNeovimReason
@@ -328,44 +327,6 @@ class MapCommandTest : VimTestCase() {
)
}
@TestWithoutNeovim(reason = SkipNeovimReason.SELECT_MODE)
@Test
fun `test mapping for Visual mode is executed in Select mode as if Visual mode was used`() {
// See `:help Select-mode-mapping`. A mapping defined for Visual mode (`:map`, `:vmap`) also applies in Select
// mode, but its right-hand side is executed as if Visual mode was used. Select mode is restored afterwards.
// The right-hand side must NOT be treated as printable characters replacing the selection.
configureByText("Lorem ${c}ipsum dolor ipsum amet")
enterCommand("noremap j nzz")
enterSearch("ipsum") // Set the last search pattern, so `n` has something to jump to
typeText("0w")
typeText("ve", "<C-G>")
assertState("Lorem ${s}ipsum$se$c dolor ipsum amet")
assertMode(Mode.SELECT(SelectionType.CHARACTER_WISE))
// `n` is executed as a Visual mode motion, extending the selection to the start of the next match. `zz` scrolls.
// Select mode is restored afterwards.
typeText("j")
assertState("Lorem ${s}ipsum dolor i$se${c}psum amet")
assertMode(Mode.SELECT(SelectionType.CHARACTER_WISE))
}
@TestWithoutNeovim(reason = SkipNeovimReason.SELECT_MODE)
@Test
fun `test mapping that ends the selection in Select mode returns to Normal mode`() {
// The right-hand side is executed as a Visual mode command, so `d` deletes the selection. Because the selection is
// gone, Select mode is not restored. See `:help Select-mode-mapping`
configureByText("Lorem ${c}ipsum dolor ipsum amet")
enterCommand("noremap j d")
typeText("ve", "<C-G>")
assertMode(Mode.SELECT(SelectionType.CHARACTER_WISE))
typeText("j")
assertState("Lorem $c dolor ipsum amet")
assertMode(Mode.NORMAL())
}
@Test
fun `test output of map shows correct modes after unmapping from vmap and map`() {
configureByText("\n")
@@ -8,16 +8,11 @@
package org.jetbrains.plugins.ideavim.extension.nerdtree
import com.intellij.openapi.actionSystem.ActionUiKind
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.DataContext
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.options.advanced.AdvancedSettings
import com.intellij.testFramework.PlatformTestUtil
import com.intellij.ui.treeStructure.Tree
import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.extension.nerdtree.armSelectionRestoreOnEscape
import com.maddyhome.idea.vim.extension.nerdtree.navigationMappings
import org.jetbrains.plugins.ideavim.VimTestCase
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
@@ -79,71 +74,6 @@ class NerdTreeTest : VimTestCase() {
}
}
// `<C-D>` and `<C-U>` scroll half a page, like in Vim: the selection moves by half the number of rows that fit
// into the visible part of the tree.
@Test
fun `test ctrl-d moves the selection half a page down`() {
onEdt {
val tree = createTallTree(childCount = 40, visibleRows = 10)
tree.setSelectionRow(1)
tree.perform("<C-D>")
assertEquals(6, tree.selectedRow, "<C-D> should move the selection five rows down")
}
}
@Test
fun `test ctrl-u moves the selection half a page up`() {
onEdt {
val tree = createTallTree(childCount = 40, visibleRows = 10)
tree.setSelectionRow(20)
tree.perform("<C-U>")
assertEquals(15, tree.selectedRow, "<C-U> should move the selection five rows up")
}
}
@Test
fun `test half page scrolling stops at the last row`() {
onEdt {
val tree = createTallTree(childCount = 40, visibleRows = 10)
val lastRow = tree.rowCount - 1
tree.setSelectionRow(lastRow - 2)
tree.perform("<C-D>")
assertEquals(lastRow, tree.selectedRow, "<C-D> should not move the selection past the last row")
}
}
@Test
fun `test half page scrolling stops at the first row`() {
onEdt {
val tree = createTallTree(childCount = 40, visibleRows = 10)
tree.setSelectionRow(2)
tree.perform("<C-U>")
assertEquals(0, tree.selectedRow, "<C-U> should not move the selection above the first row")
}
}
// Nothing is selected yet when the tree has just been opened - the scroll should start from the topmost visible row
// instead of throwing.
@Test
fun `test half page scrolling without a selection`() {
onEdt {
val tree = createTallTree(childCount = 40, visibleRows = 10)
tree.clearSelection()
tree.perform("<C-D>")
assertEquals(5, tree.selectedRow, "<C-D> without a selection should select half a page below the first row")
}
}
private fun onEdt(block: () -> Unit) {
ApplicationManager.getApplication().invokeAndWait(block)
}
@@ -174,31 +104,4 @@ class NerdTreeTest : VimTestCase() {
val event = KeyEvent(this, KeyEvent.KEY_PRESSED, System.currentTimeMillis(), 0, keyCode, keyChar)
keyListeners.forEach { it.keyPressed(event) }
}
/**
* A tree with more rows than fit on the screen. Without a scroll pane the tree's visible rectangle is its own
* bounds, so sizing it to [visibleRows] rows is what makes `TreeUtil.getVisibleRowCount` report a page.
*/
private fun createTallTree(childCount: Int, visibleRows: Int): Tree {
val root = DefaultMutableTreeNode("root")
repeat(childCount) { root.add(DefaultMutableTreeNode("file$it.txt")) }
val tree = Tree(DefaultTreeModel(root))
tree.rowHeight = ROW_HEIGHT
tree.expandRow(0)
tree.setSize(200, ROW_HEIGHT * visibleRows)
assertEquals(childCount + 1, tree.rowCount, "The whole tree should be expanded")
return tree
}
private val Tree.selectedRow: Int
get() = selectionRows?.single() ?: -1
private fun Tree.perform(keys: String) {
val action = navigationMappings[injector.parser.parseKeys(keys)] ?: error("No NERDTree mapping for `$keys`")
action.action(AnActionEvent.createEvent(DataContext.EMPTY_CONTEXT, null, "test", ActionUiKind.NONE, null), this)
}
companion object {
private const val ROW_HEIGHT = 20
}
}
@@ -8,13 +8,8 @@
package org.jetbrains.plugins.ideavim.group.search
import com.intellij.openapi.application.ApplicationManager
import org.jetbrains.plugins.ideavim.SkipNeovimReason
import org.jetbrains.plugins.ideavim.TestWithoutNeovim
import org.jetbrains.plugins.ideavim.VimTestCase
import org.junit.jupiter.api.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Search highlights are asserted as `«…»` for a normal match ([com.intellij.openapi.editor.colors.EditorColors.TEXT_SEARCH_RESULT_ATTRIBUTES])
@@ -655,55 +650,4 @@ class SearchHighlightsTest : VimTestCase() {
assertSearchHighlights(pattern, "«lorem» ipsum «lorem» ipsum")
}
/**
* Vim computes 'hlsearch' during redraw, per screen cell, and keeps no highlight objects at all. IdeaVim has to
* materialise a RangeHighlighter per match, so it only materialises the ones that could be on screen - see
* [com.maddyhome.idea.vim.helper.SearchHighlights].
*
* The viewport is [screenWidth] x [screenHeight] characters in the test fixture, so 200 lines is several screens.
*/
@TestWithoutNeovim(SkipNeovimReason.NOT_VIM_TESTING)
@Test
fun `test search highlights do not cover matches far off screen`() {
configureByLines(200, "lorem ipsum dolor sit amet,")
enterCommand("set hlsearch")
enterSearch("lorem")
val highlightedLines = highlightedLines()
assertTrue(0 in highlightedLines, "The top of the file is on screen, so its match should be highlighted")
assertFalse(
199 in highlightedLines,
"The last line is several screens away and should not be highlighted, but lines $highlightedLines are",
)
}
@TestWithoutNeovim(SkipNeovimReason.NOT_VIM_TESTING)
@Test
fun `test scrolling highlights the matches that come into view`() {
configureByLines(200, "lorem ipsum dolor sit amet,")
enterCommand("set hlsearch")
enterSearch("lorem")
assertFalse(199 in highlightedLines(), "The last line starts off screen")
// Scroll to the end of the file, putting line 180 at the top of the screen
typeText("181Gzt")
assertTrue(199 in highlightedLines(), "The last line is now on screen, so its match should be highlighted")
// ...and back to the top
typeText("1G")
assertTrue(0 in highlightedLines(), "The first line is on screen again, so its match should be highlighted")
}
/** The lines that have a search highlight on them, as the markup model actually has it. */
private fun highlightedLines(): Set<Int> {
lateinit var lines: Set<Int>
ApplicationManager.getApplication().invokeAndWait {
val editor = fixture.editor
lines = editor.markupModel.allHighlighters
.map { editor.offsetToLogicalPosition(it.startOffset).line }
.toSet()
}
return lines
}
}
@@ -1,107 +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 org.jetbrains.plugins.ideavim.helper
import com.intellij.openapi.application.ApplicationManager
import com.maddyhome.idea.vim.helper.EditorHelper
import org.jetbrains.plugins.ideavim.SkipNeovimReason
import org.jetbrains.plugins.ideavim.TestWithoutNeovim
import org.jetbrains.plugins.ideavim.VimTestCase
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Characterisation tests for the platform APIs that report which part of the document is on screen.
*
* These are written to answer a design question rather than to guard IdeaVim behaviour: if search highlighting is to
* be scoped to the viewport, it needs a visible-range source that works in the headless test fixture. The fixture sets
* an artificial viewport of [screenWidth] x [screenHeight] characters via `setEditorVisibleSize`, so the question is
* whether `Editor.calculateVisibleRange()` honours it, or whether only IdeaVim's own
* `EditorHelper.getVisualLineAtTopOfScreen`/`getVisualLineAtBottomOfScreen` can be trusted here.
*/
class VisibleRangeApiTest : VimTestCase() {
@TestWithoutNeovim(SkipNeovimReason.NOT_VIM_TESTING)
@Test
fun `test calculateVisibleRange covers whole document when it fits on screen`() {
// Four lines against a 35 line viewport - everything is visible
configureByText(
"""I found it in a legendary land
|all rocks and lavender and tufted grass,
|where it was settled on some sodden sand
|hard by the torrent of a mountain pass.
""".trimMargin(),
)
ApplicationManager.getApplication().invokeAndWait {
val visibleRange = fixture.editor.calculateVisibleRange()
assertEquals(0, visibleRange.startOffset, "Visible range should start at the top of the document")
assertEquals(
fixture.editor.document.textLength,
visibleRange.endOffset,
"Visible range should cover the whole document when it is shorter than the viewport",
)
}
}
@TestWithoutNeovim(SkipNeovimReason.NOT_VIM_TESTING)
@Test
fun `test calculateVisibleRange is limited to the viewport for a long document`() {
// 200 lines against a 35 line viewport - only a fraction is visible
configureByLines(200, "lorem ipsum dolor sit amet,")
ApplicationManager.getApplication().invokeAndWait {
val editor = fixture.editor
val visibleRange = editor.calculateVisibleRange()
assertEquals(0, visibleRange.startOffset, "Not scrolled, so the visible range starts at the top")
assertTrue(
visibleRange.endOffset < editor.document.textLength,
"Visible range (${visibleRange.startOffset}, ${visibleRange.endOffset}) should not cover the whole " +
"${editor.document.textLength} char document - the viewport is only $screenHeight lines",
)
// The platform's range should agree with the visible line range IdeaVim already computes for scroll commands
val bottomVisualLine = EditorHelper.getVisualLineAtBottomOfScreen(editor)
val endLine = editor.offsetToLogicalPosition(visibleRange.endOffset).line
assertTrue(
endLine >= bottomVisualLine,
"calculateVisibleRange ends on line $endLine, but IdeaVim thinks the bottom of the screen is " +
"visual line $bottomVisualLine",
)
}
}
@TestWithoutNeovim(SkipNeovimReason.NOT_VIM_TESTING)
@Test
fun `test calculateVisibleRange follows the scroll position`() {
configureByLines(200, "lorem ipsum dolor sit amet,")
// Scroll so that line 100 is at the top of the screen
typeText("100Gzt")
ApplicationManager.getApplication().invokeAndWait {
val editor = fixture.editor
val visibleRange = editor.calculateVisibleRange()
val startLine = editor.offsetToLogicalPosition(visibleRange.startOffset).line
assertTrue(
startLine > 0,
"After scrolling to line 100 the visible range should no longer start at the top of the document, " +
"but it starts on line $startLine",
)
assertEquals(
EditorHelper.getVisualLineAtTopOfScreen(editor),
startLine,
"calculateVisibleRange should agree with IdeaVim's own top-of-screen calculation",
)
}
}
}
@@ -1,305 +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 org.jetbrains.plugins.ideavim.extension.commentary
import com.intellij.ide.highlighter.JavaFileType
import com.intellij.openapi.application.ApplicationManager
import com.intellij.testFramework.PlatformTestUtil
import com.maddyhome.idea.vim.state.mode.Mode
import org.jetbrains.plugins.ideavim.VimJavaTestCase
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInfo
/**
* Tests for `g:commentary_block_comments` (VIM-2629).
*
* vim-commentary always comments whole lines, even when `commentstring` describes a block comment.
* IdeaVim additionally emits a real block comment whenever the range is not linewise, which is what
* makes `gciw` able to comment out a single argument. That is useful, but it is not what
* vim-commentary does.
*
* The variable picks between the two behaviours. It defaults to 1, i.e. the existing IdeaVim
* behaviour; setting it to 0 makes every range comment whole lines, like vim-commentary.
*
* The variable is read per command, not when the extension is initialised, so it can be changed
* from `:let` mid-session.
*/
class CommentaryBlockCommentsVariableTest : VimJavaTestCase() {
@BeforeEach
override fun setUp(testInfo: TestInfo) {
super.setUp(testInfo)
enableExtensions("commentary")
}
// Default: block comments are on, i.e. nothing about the current behaviour changes
@Test
fun `test block comments are enabled by default`() {
doCommentaryTest(
"gciw",
"<caret>if (condition) {\n" + "}\n",
"/*if*/ (condition) {\n" + "}\n",
)
}
@Test
fun `test explicitly enabling the variable keeps block comments`() {
doCommentaryTest(
"gciw",
"<caret>if (condition) {\n" + "}\n",
"/*if*/ (condition) {\n" + "}\n",
blockComments = 1,
)
}
/**
* Vimscript treats every non-zero Number as true, so only `0` may turn block comments off. A
* config that flips the variable with something other than a literal `1` must still enable them.
*/
@Test
fun `test a non-zero value enables block comments`() {
doCommentaryTest(
"gciw",
"<caret>if (condition) {\n" + "}\n",
"/*if*/ (condition) {\n" + "}\n",
blockComments = 2,
)
}
@Test
fun `test a negative value enables block comments`() {
doCommentaryTest(
"gciw",
"<caret>if (condition) {\n" + "}\n",
"/*if*/ (condition) {\n" + "}\n",
blockComments = -1,
)
}
@Test
fun `test enabled block comments comment out a single argument`() {
doCommentaryTest(
"gciw",
"foo(a, <caret>bar, c);\n",
"foo(a, /*bar*/, c);\n",
blockComments = 1,
)
}
// Disabled: a charwise range comments the whole line, like vim-commentary
@Test
fun `test inner word comments the whole line when block comments are disabled`() {
doCommentaryTest(
"gciw",
"<caret>if (condition) {\n" + "}\n",
"//if (condition) {\n" + "}\n",
blockComments = 0,
)
}
@Test
fun `test charwise motion comments the whole line when block comments are disabled`() {
doCommentaryTest(
"gcll",
"<caret>if (condition) {\n" + "}\n",
"//if (condition) {\n" + "}\n",
blockComments = 0,
)
}
@Test
fun `test till motion comments the whole line when block comments are disabled`() {
doCommentaryTest(
"gct{",
"<caret>if (condition) {\n" + "}\n",
"//if (condition) {\n" + "}\n",
blockComments = 0,
)
}
@Test
fun `test argument is not block commented when block comments are disabled`() {
doCommentaryTest(
"gciw",
"foo(a, <caret>bar, c);\n",
"//foo(a, bar, c);\n",
blockComments = 0,
)
}
@Test
fun `test charwise motion spanning lines comments every line when block comments are disabled`() {
doCommentaryTest(
"gcj",
"<caret>if (condition) {\n" + "}\n",
"//if (condition) {\n" + "//}\n",
blockComments = 0,
)
}
@Test
fun `test charwise visual selection spanning lines comments every line when block comments are disabled`() {
doCommentaryTest(
"vjgc",
"<caret>if (condition) {\n" + "}\n",
"//if (condition) {\n" + "//}\n",
blockComments = 0,
)
}
@Test
fun `test charwise visual selection within a line comments the whole line when block comments are disabled`() {
doCommentaryTest(
"vllgc",
"<caret>if (condition) {\n" + "}\n",
"//if (condition) {\n" + "}\n",
blockComments = 0,
)
}
@Test
fun `test blockwise visual selection comments every line when block comments are disabled`() {
doCommentaryTest(
"<C-V>jgc",
"<caret>if (condition) {\n" + "}\n",
"//if (condition) {\n" + "//}\n",
blockComments = 0,
)
}
// Disabled: linewise ranges are unchanged, they were already line comments
@Test
fun `test gcc is unchanged when block comments are disabled`() {
doCommentaryTest(
"gcc",
"<caret>if (condition) {\n" + "}\n",
"//if (condition) {\n" + "}\n",
blockComments = 0,
)
}
@Test
fun `test linewise visual selection is unchanged when block comments are disabled`() {
doCommentaryTest(
"Vjgc",
"<caret>if (condition) {\n" + "}\n",
"//if (condition) {\n" + "//}\n",
blockComments = 0,
)
}
@Test
fun `test inner paragraph is unchanged when block comments are disabled`() {
doCommentaryTest(
"gcip",
"<caret>if (condition) {\n" + "}\n",
"//if (condition) {\n" + "//}\n",
blockComments = 0,
)
}
// Disabled: toggling back off, repeating and the gc text object
@Test
fun `test a charwise motion uncomments the line when block comments are disabled`() {
doCommentaryTest(
"gciw" + "gciw",
"<caret>if (condition) {\n" + "}\n",
"if (condition) {\n" + "}\n",
blockComments = 0,
)
}
@Test
fun `test repeat uses line comments when block comments are disabled`() {
doCommentaryTest(
"gciw" + "jj.",
"""
<caret>if (condition) {
}
if (condition) {
}
""".trimIndent(),
"""
//if (condition) {
}
//if (condition) {
}
""".trimIndent(),
blockComments = 0,
)
}
@Test
fun `test gc text object is unaffected when block comments are disabled`() {
doCommentaryTest(
"dgc",
"""
// <caret>Comment 1
// Comment 2
final Int value = 42;
""".trimIndent(),
"""
final Int value = 42;
""".trimIndent(),
blockComments = 0,
)
}
@Test
fun `test Commentary command is unaffected when block comments are disabled`() {
configureByJavaText("<caret>if (condition) {\n" + "}\n")
enterCommand("let g:commentary_block_comments = 0")
enterCommand("1,2Commentary")
dispatchEvents()
assertState("//if (condition) {\n" + "//}\n")
}
// The variable is read per command, so it can be flipped mid-session
@Test
fun `test the variable is re-read for every command`() {
configureByJavaText("<caret>if (condition) {\n" + "while (other) {\n")
enterCommand("let g:commentary_block_comments = 0")
typeText("gciw")
dispatchEvents()
enterCommand("let g:commentary_block_comments = 1")
typeText("j", "gciw")
dispatchEvents()
assertState("//if (condition) {\n" + "/*while*/ (other) {\n")
}
private fun doCommentaryTest(keys: String, before: String, after: String, blockComments: Int? = null) {
doTest(
keys,
before,
after,
Mode.NORMAL(),
JavaFileType.INSTANCE,
afterEditorInitialized = {
if (blockComments != null) {
enterCommand("let g:commentary_block_comments = $blockComments")
}
},
)
}
private fun dispatchEvents() {
ApplicationManager.getApplication().invokeAndWait {
PlatformTestUtil.dispatchAllInvocationEventsInIdeEventQueue()
}
}
}
@@ -34,9 +34,6 @@ dependencies {
create(ideaType, ideaVersion) { this.useInstaller = false }
testFramework(TestFrameworkType.Platform)
testFramework(TestFrameworkType.JUnit5)
// Provides the BenchmarkTestInfo implementation that PlatformTestUtil.newBenchmark() loads via ServiceLoader.
// Without it, the benchmark tests fail with "No implementations of BenchmarkTestInfo found".
testFramework(TestFrameworkType.Metrics)
}
}
@@ -70,7 +70,7 @@ open class CaretRegisterStorageBase(override var caret: ImmutableVimCaret) : Car
override fun getRegister(editor: VimEditor, context: ExecutionContext, r: Char): Register? {
val multiCaretJoinRegister = injector.registerGroup.getRegister(editor, context, RegisterConstants.MULTICARET_JOIN_REGISTER)
val multiCaretJoinedText = multiCaretJoinRegister?.copiedText as? MultiCaretJoinedText
if (multiCaretJoinedText != null && multiCaretJoinedText.carets.let { it > 0 && it != editor.carets().size }) {
if (multiCaretJoinedText != null && multiCaretJoinedText.carets != editor.carets().size) {
return multiCaretJoinRegister
}
if (caret.isPrimary || !RegisterConstants.RECORDABLE_REGISTERS.contains(r)) {
@@ -100,7 +100,8 @@ abstract class VimChangeGroupBase : VimChangeGroup {
* Vim's `did_ai`: the indent on the caret's line was inserted automatically and nothing has been typed since, so it
* is removed again when Insert mode is left. Only valid for the current insert session
*/
protected var didAutoIndent: Boolean get() = false; set(_) = Unit
@JvmField
protected var didAutoIndent: Boolean = false
/**
* The value [didAutoIndent] gets when the next insert session starts.
@@ -388,10 +389,8 @@ abstract class VimChangeGroupBase : VimChangeGroup {
caret.moveToOffset(position)
} else {
repeatInsertText(editor, context, count)
if (!(editor.charAt(caret.offset) == '\n' && editor.charAt(caret.offset - 1).let { it == ' ' || it == '\t' })) {
val position = injector.motion.getHorizontalMotion(editor, caret, -1, false)
caret.moveToMotion(position)
}
val position = injector.motion.getHorizontalMotion(editor, caret, -1, false)
caret.moveToMotion(position)
}
}
}
@@ -447,39 +447,22 @@ abstract class VimMarkServiceBase : VimMarkService {
newFragment: CharSequence,
) {
val changeEndOffset = changeOffset + oldLength
val changeStart = editor.offsetToBufferPosition(changeOffset)
val changeEnd = editor.offsetToBufferPosition(changeEndOffset)
val newLineCount = newFragment.count { it == '\n' }
val newEndLine = changeStart.line + newLineCount
// A change that leaves the line structure alone (e.g. replacing or inserting text within one line) does not move
// the Visual marks - Vim only adjusts them when lines are added or removed
if (changeEnd.line == newEndLine) return
// The column that the text following the change ends up in. A single line replacement pushes it along the same
// line, a multi line one moves it to the end of the fragment's last line.
val tailStartColumn = if (newLineCount == 0) {
changeStart.column + newFragment.length
} else {
newFragment.length - newFragment.lastIndexOf('\n') - 1
}
// Whether anything is left of the change's last line. If the change swallows it whole (e.g. deleting entire lines)
// nothing is merged in behind the new text, and columns stay where they are
val tailSurvives = changeEndOffset < editor.getLineEndOffset(changeEnd.line)
val changeStartLine = editor.offsetToBufferPosition(changeOffset).line
val oldEndLine = editor.offsetToBufferPosition(changeEndOffset).line
val newEndLine = changeStartLine + newFragment.count { it == '\n' }
if (oldEndLine == newEndLine) return
for (caret in editor.carets()) {
val selectionInfo = caret.lastSelectionInfo
val startPosition = selectionInfo.start
val newStartPosition = startPosition?.let {
adjustVisualMark(
editor, it, changeOffset, changeEndOffset, changeEnd, newEndLine, tailStartColumn, tailSurvives,
)
adjustVisualMark(editor, it, changeOffset, changeEndOffset, oldEndLine, newEndLine)
}
val endPosition = selectionInfo.end
val newEndPosition = endPosition?.let {
adjustVisualMark(
editor, it, changeOffset, changeEndOffset, changeEnd, newEndLine, tailStartColumn, tailSurvives,
)
adjustVisualMark(editor, it, changeOffset, changeEndOffset, oldEndLine, newEndLine)
}
if (newStartPosition != startPosition || newEndPosition != endPosition) {
@@ -495,41 +478,21 @@ abstract class VimMarkServiceBase : VimMarkService {
position: BufferPosition,
changeOffset: Int,
changeEndOffset: Int,
changeEnd: BufferPosition,
oldEndLine: Int,
newEndLine: Int,
tailStartColumn: Int,
tailSurvives: Boolean,
): BufferPosition {
val offset = editor.bufferPositionToOffset(position)
return when {
offset < changeOffset -> position
// Note that text inserted at the mark (an empty range, i.e. changeOffset == changeEndOffset) pushes the mark
// down, while text replacing a range that starts at the mark leaves the mark at the start of the new text
offset >= changeEndOffset -> if (position.line == changeEnd.line) {
// The mark sits on the change's last line, so it moves with that line's tail onto the line the new text ends
// on, keeping its distance from the end of the change
BufferPosition(
newEndLine,
tailStartColumn + (position.column - changeEnd.column),
position.leansForward,
)
} else {
BufferPosition(
position.line + (newEndLine - changeEnd.line),
position.column,
position.leansForward,
)
}
// Inside the replaced range Vim keeps the mark, but a line that no longer exists collapses onto the line the
// new text ends on. The column only moves if the rest of that line survives and is merged in behind the new
// text - if the whole line went away, there is nothing to merge into and the column is kept
position.line > newEndLine -> BufferPosition(
newEndLine,
if (position.line == changeEnd.line && tailSurvives) tailStartColumn + position.column else position.column,
offset >= changeEndOffset -> BufferPosition(
position.line + (newEndLine - oldEndLine),
position.column,
position.leansForward,
)
position.line > newEndLine -> BufferPosition(newEndLine, position.column, position.leansForward)
else -> position
}
}
@@ -104,6 +104,19 @@ abstract class VimSearchGroupBase : VimSearchGroup {
)
}
/**
* Highlights lines startLine to endLine (inclusive), using the last used pattern.
*
* @param editor The editor to highlight.
* @param startLine The number of the line where to start highlighting (inclusive)
* @param endLine The number of the line where to stop highlighting (inclusive)
*/
abstract fun highlightSearchLines(
editor: VimEditor,
startLine: Int,
endLine: Int,
)
/**
* Updates the current search highlights.
*
@@ -25,7 +25,6 @@ import com.maddyhome.idea.vim.group.visual.VimSelection
import com.maddyhome.idea.vim.group.visual.VimSelection.Companion.create
import com.maddyhome.idea.vim.handler.ExternalActionHandler
import com.maddyhome.idea.vim.helper.VimNlsSafe
import com.maddyhome.idea.vim.impl.state.toMappingMode
import com.maddyhome.idea.vim.state.KeyHandlerState
import com.maddyhome.idea.vim.state.mode.Mode
import com.maddyhome.idea.vim.state.mode.SelectionType.CHARACTER_WISE
@@ -51,25 +50,6 @@ sealed class MappingInfo(
abstract fun execute(editor: VimEditor, context: ExecutionContext, keyState: KeyHandlerState)
protected fun <T> withSelectModeAsVisual(editor: VimEditor, keyState: KeyHandlerState, action: () -> T): T {
if (editor.mode !is Mode.SELECT || MappingMode.VISUAL !in originalModes) return action()
toggleSelectVisual(editor, keyState)
try {
return action()
} finally {
// The right-hand side might have ended the selection, e.g. `:noremap j d`. There is no Select mode to return to
if (editor.mode is Mode.VISUAL) toggleSelectVisual(editor, keyState)
}
}
private fun toggleSelectVisual(editor: VimEditor, keyState: KeyHandlerState) {
injector.visualMotionGroup.toggleSelectVisual(editor)
// The command builder caches the key stroke trie of the mode it was reset for, and changing the mode does not
// update it. Without this, the keys of the right-hand side would not be recognised as commands
keyState.commandBuilder.resetCommandTrie(injector.keyGroup.getBuiltinCommandsTrie(editor.mode.toMappingMode()))
}
override fun compareTo(other: MappingInfo): Int {
val size = fromKeys.size
val otherSize = other.fromKeys.size
@@ -118,20 +98,18 @@ class ToKeysMappingInfo(
LOG.trace { "Adding new keys to keyStack as toKeys of mapping. State before adding keys: ${keyHandler.keyStack.dump()}" }
keyHandler.keyStack.addKeys(toKeys)
try {
withSelectModeAsVisual(editor, keyState) {
var first = true
while (keyHandler.keyStack.hasStroke()) {
val keySource = if (!isRecursive || (first && lhsIsPrefixOfRhs)) {
KeySource.MAPPED_NON_RECURSIVE
}
else {
KeySource.MAPPED
}
val keyStroke = keyHandler.keyStack.feedStroke()
keyHandler.handleKey(editor, keyStroke, keySource, context, keyState)
first = false
if (keyHandler.maxMapDepthReached) break
var first = true
while (keyHandler.keyStack.hasStroke()) {
val keySource = if (!isRecursive || (first && lhsIsPrefixOfRhs)) {
KeySource.MAPPED_NON_RECURSIVE
}
else {
KeySource.MAPPED
}
val keyStroke = keyHandler.keyStack.feedStroke()
keyHandler.handleKey(editor, keyStroke, keySource, context, keyState)
first = false
if (keyHandler.maxMapDepthReached) break
}
} finally {
keyHandler.keyStack.removeFirst()