mirror of
https://github.com/chylex/IntelliJ-IdeaVim.git
synced 2026-10-17 05:33:52 +02:00
Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d7dada898
|
||
|
|
649e6aac8c
|
||
|
|
8e5bee5eb3
|
||
|
|
19a1ad698c
|
||
|
|
5d79c93e4e
|
||
|
|
08cb48682d
|
||
|
|
d0ccad28c9
|
||
|
|
d0f80d611f
|
||
|
|
1f12a2de45
|
||
|
|
b541dfbc5f
|
||
|
|
a7ce4a7574
|
||
|
|
156c3fcf2d
|
||
|
|
ab55e5d31c
|
||
|
|
040e1932fb
|
||
|
|
9418e05e1c
|
||
|
|
a753181274
|
||
|
|
d8302a623a
|
||
|
|
cd0dd0dfbd
|
||
|
|
2bf466cfc8
|
||
|
|
b8f1a5451c
|
||
|
|
99259210a6
|
||
|
|
d9474abad3
|
||
|
|
eea61ab539
|
||
|
|
b6e5aa9586
|
||
|
|
ffac6e23ef
|
||
|
|
e685c6a84f
|
||
|
|
a550983e5d
|
||
|
|
447a8e3f04
|
||
|
|
cb3a01f150
|
||
|
|
362fe6be71
|
||
|
|
62a14dcad8
|
||
|
|
c08b6f5b27
|
||
|
|
719335d4cb | ||
|
|
33d0e5b3ef | ||
|
|
d682906047 | ||
|
|
a087a508b5 | ||
|
|
154f81d1d6 | ||
|
|
c7149c3129 | ||
|
|
46cf018d32 | ||
|
|
e552a2ba51 | ||
|
|
f13c3e333c | ||
|
|
03d4d7ab31 | ||
|
|
8a8ada7bcb | ||
|
|
e465c9c962 | ||
|
|
bf210ba468 | ||
|
|
f9d455073e | ||
|
|
0ae4b2698a |
@@ -0,0 +1,156 @@
|
||||
name: Claude run post-mortem
|
||||
description: >
|
||||
Unpacks the execution log that claude-code-action writes, so a failed run shows the
|
||||
real cause instead of just "Claude result reported subtype success with is_error:true".
|
||||
|
||||
inputs:
|
||||
execution_file:
|
||||
description: The execution_file output of the claude-code-action step.
|
||||
required: true
|
||||
label:
|
||||
description: Shown in the log/summary to identify which Claude step this belongs to.
|
||||
required: false
|
||||
default: Claude run
|
||||
|
||||
outputs:
|
||||
verdict:
|
||||
description: One of ok, credentials, quota, timeout, tool_error, unknown, no_log.
|
||||
value: ${{ steps.postmortem.outputs.verdict }}
|
||||
detail:
|
||||
description: One-line human-readable explanation of the verdict.
|
||||
value: ${{ steps.postmortem.outputs.detail }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- id: postmortem
|
||||
shell: bash
|
||||
env:
|
||||
EXEC_FILE: ${{ inputs.execution_file }}
|
||||
LABEL: ${{ inputs.label }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
|
||||
VERDICT="unknown"
|
||||
DETAIL=""
|
||||
|
||||
if [ -z "$EXEC_FILE" ] || [ ! -f "$EXEC_FILE" ]; then
|
||||
# No log at all means the CLI died before the first turn - usually auth or a
|
||||
# bad base URL, because a quota error still produces a session log.
|
||||
echo "::warning title=No Claude execution log::claude-code-action produced no execution_file; the CLI failed before it started a session (bad base URL or rejected credentials are the usual causes)."
|
||||
VERDICT="no_log"
|
||||
DETAIL="no execution log was produced - the CLI never started a session"
|
||||
{
|
||||
echo "### $LABEL post-mortem"
|
||||
echo ""
|
||||
echo "- Verdict: \`no_log\`"
|
||||
echo "- $DETAIL"
|
||||
echo ""
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
{
|
||||
echo "verdict=$VERDICT"
|
||||
echo "detail=$DETAIL"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "::group::$LABEL - result entry"
|
||||
# The log is a JSON array of session entries; the last `result` entry is the verdict.
|
||||
RESULT=$(jq -c '[.[] | select(.type == "result")] | last // {}' "$EXEC_FILE" 2>/dev/null || echo '{}')
|
||||
jq -r '
|
||||
"subtype: \(.subtype // "<none>")",
|
||||
"is_error: \(.is_error // false)",
|
||||
"num_turns: \(.num_turns // "<none>")",
|
||||
"duration_ms: \(.duration_ms // "<none>")",
|
||||
"total_cost_usd: \(.total_cost_usd // "<none>")",
|
||||
"usage: \(.usage // {} | tojson)",
|
||||
"denials: \((.permission_denials // []) | map(.tool_name) | unique | join(", "))"
|
||||
' <<<"$RESULT"
|
||||
echo "::endgroup::"
|
||||
|
||||
echo "::group::$LABEL - result text"
|
||||
RESULT_TEXT=$(jq -r '.result // .error // ""' <<<"$RESULT")
|
||||
echo "${RESULT_TEXT:-<empty>}"
|
||||
echo "::endgroup::"
|
||||
|
||||
# Anything in the whole transcript that looks like an API-level failure. The API
|
||||
# error that killed the run is usually in an assistant/system entry, not the result.
|
||||
echo "::group::$LABEL - API error lines in the transcript"
|
||||
# Walk every string in the log rather than regexing the raw JSON: API errors
|
||||
# arrive as JSON embedded in a string, so their quotes are escaped.
|
||||
API_ERRORS=$(jq -r '[.. | strings] | .[]' "$EXEC_FILE" 2>/dev/null \
|
||||
| grep -iE 'rate.?limit|quota|credit balance|insufficient|authenticat|unauthori|forbidden|expired|revoked|invalid.?(api.?key|x-api-key|token|bearer)|overloaded|api error|\b(401|402|403|429|500|502|503|529)\b' \
|
||||
| cut -c1-300 | sort -u | head -20 || true)
|
||||
echo "${API_ERRORS:-<none found>}"
|
||||
echo "::endgroup::"
|
||||
|
||||
HAYSTACK=$(printf '%s\n%s' "$RESULT_TEXT" "$API_ERRORS" | tr '[:upper:]' '[:lower:]')
|
||||
IS_ERROR=$(jq -r '.is_error // false' <<<"$RESULT")
|
||||
SUBTYPE=$(jq -r '.subtype // ""' <<<"$RESULT")
|
||||
|
||||
case "$HAYSTACK" in
|
||||
*authentication_error*|*unauthorized*|*"401"*|*"invalid api key"*|*"invalid x-api-key"*|*"token has expired"*|*"jwt expired"*)
|
||||
VERDICT="credentials"
|
||||
DETAIL="the transcript contains an authentication failure - the token is expired or revoked"
|
||||
;;
|
||||
*rate_limit_error*|*"rate limit"*|*quota*|*"credit balance"*|*"insufficient"*|*"429"*|*"402"*)
|
||||
VERDICT="quota"
|
||||
DETAIL="the transcript contains a rate-limit/quota failure"
|
||||
;;
|
||||
*permission_error*|*forbidden*|*"403"*)
|
||||
VERDICT="credentials"
|
||||
DETAIL="the transcript contains a 403 - the token is not authorized for this model/service"
|
||||
;;
|
||||
*overloaded*|*"500"*|*"502"*|*"503"*|*"529"*)
|
||||
VERDICT="proxy"
|
||||
DETAIL="the transcript contains upstream 5xx/overloaded errors"
|
||||
;;
|
||||
*)
|
||||
if [ "$SUBTYPE" = "error_max_turns" ]; then
|
||||
VERDICT="timeout"
|
||||
DETAIL="the run hit the max-turns limit"
|
||||
elif [ "$IS_ERROR" = "true" ]; then
|
||||
VERDICT="tool_error"
|
||||
DETAIL="Claude finished with is_error=true but no API-level error - the prompt or a tool failed, see the result text above"
|
||||
else
|
||||
VERDICT="ok"
|
||||
DETAIL="the run completed without an error result"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "$LABEL post-mortem verdict: $(printf '%s' "$VERDICT" | tr '[:lower:]' '[:upper:]') - $DETAIL"
|
||||
|
||||
{
|
||||
echo "### $LABEL post-mortem"
|
||||
echo ""
|
||||
echo "- Verdict: \`$VERDICT\`"
|
||||
echo "- \`subtype\`: \`${SUBTYPE:-<none>}\`, \`is_error\`: \`$IS_ERROR\`"
|
||||
echo "- Detail: $DETAIL"
|
||||
if [ -n "$RESULT_TEXT" ]; then
|
||||
echo ""
|
||||
echo "<details><summary>Result text</summary>"
|
||||
echo ""
|
||||
echo '```'
|
||||
printf '%s\n' "$RESULT_TEXT" | head -c 4000
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "</details>"
|
||||
fi
|
||||
if [ -n "$API_ERRORS" ]; then
|
||||
echo ""
|
||||
echo "<details><summary>API error lines</summary>"
|
||||
echo ""
|
||||
echo '```'
|
||||
printf '%s\n' "$API_ERRORS" | head -c 4000
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "</details>"
|
||||
fi
|
||||
echo ""
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
{
|
||||
echo "verdict=$VERDICT"
|
||||
echo "detail=$DETAIL"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
@@ -0,0 +1,240 @@
|
||||
name: Claude pre-flight check
|
||||
description: >
|
||||
Probes the Claude proxy before (or after) running claude-code-action and tells you
|
||||
whether the credentials are dead or the quota is gone. claude-code-action only ever
|
||||
reports "result is_error:true", which hides the actual HTTP failure.
|
||||
|
||||
inputs:
|
||||
base_url:
|
||||
description: Proxy base URL (the value passed as ANTHROPIC_BASE_URL, i.e. secrets.PROXY_URL).
|
||||
required: true
|
||||
jwt:
|
||||
description: The Grazie JWT / API key (secrets.ANTHROPIC_API_KEY).
|
||||
required: true
|
||||
model:
|
||||
description: Model id used for the probe request. Must be a model the proxy accepts.
|
||||
required: false
|
||||
default: claude-haiku-3-5-20241022
|
||||
label:
|
||||
description: Shown in the log/summary so a "before" and an "after" probe can be told apart.
|
||||
required: false
|
||||
default: pre-flight
|
||||
fail_on_error:
|
||||
description: Fail the step when the probe does not come back healthy. Diagnostics only by default.
|
||||
required: false
|
||||
default: 'false'
|
||||
|
||||
outputs:
|
||||
verdict:
|
||||
description: One of ok, credentials, quota, proxy, network, unknown.
|
||||
value: ${{ steps.probe.outputs.verdict }}
|
||||
http_status:
|
||||
description: HTTP status returned by the proxy (000 when the request never completed).
|
||||
value: ${{ steps.probe.outputs.http_status }}
|
||||
detail:
|
||||
description: One-line human-readable explanation of the verdict.
|
||||
value: ${{ steps.probe.outputs.detail }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- id: probe
|
||||
shell: bash
|
||||
env:
|
||||
BASE_URL: ${{ inputs.base_url }}
|
||||
JWT: ${{ inputs.jwt }}
|
||||
MODEL: ${{ inputs.model }}
|
||||
LABEL: ${{ inputs.label }}
|
||||
FAIL_ON_ERROR: ${{ inputs.fail_on_error }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
# The secret is masked by Actions already, but this step is also used with
|
||||
# values that did not come from `secrets.` in every caller.
|
||||
[ -n "$JWT" ] && echo "::add-mask::$JWT"
|
||||
|
||||
echo "::group::Claude $LABEL check"
|
||||
|
||||
VERDICT="unknown"
|
||||
DETAIL=""
|
||||
HTTP_STATUS="000"
|
||||
JWT_EXPIRED="false"
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 1. Are the inputs even present?
|
||||
# ---------------------------------------------------------------
|
||||
if [ -z "$BASE_URL" ]; then
|
||||
echo "ANTHROPIC_BASE_URL / PROXY_URL is EMPTY - the secret is missing or not exposed to this workflow."
|
||||
VERDICT="credentials"
|
||||
DETAIL="PROXY_URL secret is empty"
|
||||
fi
|
||||
if [ -z "$JWT" ]; then
|
||||
echo "ANTHROPIC_API_KEY is EMPTY - the secret is missing or not exposed to this workflow."
|
||||
VERDICT="credentials"
|
||||
DETAIL="ANTHROPIC_API_KEY secret is empty"
|
||||
fi
|
||||
echo "Base URL host: $(printf '%s' "$BASE_URL" | sed -E 's#^[a-z]+://([^/]+).*#\1#')"
|
||||
echo "Token length: ${#JWT} chars"
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 2. If the token is a JWT, has it simply expired?
|
||||
# Only timestamps and claim NAMES are printed - never claim values,
|
||||
# which may carry user identity.
|
||||
# ---------------------------------------------------------------
|
||||
NOW=$(date -u +%s)
|
||||
# GNU date on the runner, BSD date when this is run locally.
|
||||
fmt_ts() { date -u -d "@$1" '+%Y-%m-%d %H:%M:%S UTC' 2>/dev/null || date -u -r "$1" '+%Y-%m-%d %H:%M:%S UTC' 2>/dev/null || printf 'epoch %s' "$1"; }
|
||||
if [ "$(printf '%s' "$JWT" | awk -F. '{print NF}')" -eq 3 ]; then
|
||||
PAYLOAD=$(printf '%s' "$JWT" | cut -d. -f2)
|
||||
PAD=$(( (4 - ${#PAYLOAD} % 4) % 4 ))
|
||||
[ "$PAD" -gt 0 ] && PAYLOAD="${PAYLOAD}$(printf '=%.0s' $(seq 1 "$PAD"))"
|
||||
CLAIMS=$(printf '%s' "$PAYLOAD" | tr '_-' '/+' | base64 -d 2>/dev/null || true)
|
||||
if printf '%s' "$CLAIMS" | jq -e . >/dev/null 2>&1; then
|
||||
echo "Token is a JWT. Claims present: $(printf '%s' "$CLAIMS" | jq -r 'keys | join(", ")')"
|
||||
IAT=$(printf '%s' "$CLAIMS" | jq -r '.iat // empty')
|
||||
EXP=$(printf '%s' "$CLAIMS" | jq -r '.exp // empty')
|
||||
[ -n "$IAT" ] && echo " issued at: $(fmt_ts "$IAT")"
|
||||
if [ -n "$EXP" ]; then
|
||||
echo " expires at: $(fmt_ts "$EXP")"
|
||||
if [ "$EXP" -le "$NOW" ]; then
|
||||
echo " >>> TOKEN IS EXPIRED (by $(( (NOW - EXP) / 86400 )) day(s)). Rotate the ANTHROPIC_API_KEY secret."
|
||||
VERDICT="credentials"
|
||||
DETAIL="JWT expired $(( (NOW - EXP) / 86400 )) day(s) ago"
|
||||
JWT_EXPIRED="true"
|
||||
else
|
||||
echo " valid for another $(( (EXP - NOW) / 86400 )) day(s)."
|
||||
fi
|
||||
else
|
||||
echo " no exp claim - the token does not expire on its own."
|
||||
fi
|
||||
else
|
||||
echo "Token has three dot-separated parts but the payload is not JSON; treating it as an opaque key."
|
||||
fi
|
||||
else
|
||||
echo "Token is not a JWT (no exp claim to inspect); relying on the live probe below."
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 3. Live probe: one 1-token request through the same proxy and the
|
||||
# same headers claude-code-action uses. The HTTP status is the
|
||||
# answer the action swallows.
|
||||
# ---------------------------------------------------------------
|
||||
URL="${BASE_URL%/}/v1/messages"
|
||||
BODY_FILE="$RUNNER_TEMP/claude-probe-body.json"
|
||||
HDR_FILE="$RUNNER_TEMP/claude-probe-headers.txt"
|
||||
|
||||
if [ -n "$BASE_URL" ] && [ -n "$JWT" ]; then
|
||||
HTTP_STATUS=$(curl -sS --max-time 60 -o "$BODY_FILE" -D "$HDR_FILE" -w '%{http_code}' \
|
||||
-X POST "$URL" \
|
||||
-H 'content-type: application/json' \
|
||||
-H 'anthropic-version: 2023-06-01' \
|
||||
-H "x-api-key: $JWT" \
|
||||
-H "authorization: Bearer $JWT" \
|
||||
-H "Grazie-Authenticate-JWT: $JWT" \
|
||||
-H 'Grazie-Agent: {"name":"ideavim-claude-code-action","version":"github-actions-preflight"}' \
|
||||
-d "{\"model\":\"$MODEL\",\"max_tokens\":1,\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}]}" \
|
||||
2>"$RUNNER_TEMP/claude-probe-curl.err") || true
|
||||
# A curl that never connected prints nothing usable; normalise it to 000.
|
||||
case "$HTTP_STATUS" in [0-9][0-9][0-9]) ;; *) HTTP_STATUS="000" ;; esac
|
||||
|
||||
echo "Probe: POST \$BASE_URL/v1/messages (model $MODEL) -> HTTP $HTTP_STATUS"
|
||||
[ -s "$RUNNER_TEMP/claude-probe-curl.err" ] && sed 's/^/ curl: /' "$RUNNER_TEMP/claude-probe-curl.err"
|
||||
|
||||
# Rate-limit and retry headers say "quota" far more clearly than the body does.
|
||||
grep -iE '^(retry-after|anthropic-ratelimit|x-ratelimit|x-should-retry|grazie-)' "$HDR_FILE" \
|
||||
| sed 's/^/ header: /' || true
|
||||
|
||||
ERR_TYPE=$(jq -r '.error.type // .type // empty' "$BODY_FILE" 2>/dev/null || true)
|
||||
ERR_MSG=$(jq -r '.error.message // .message // empty' "$BODY_FILE" 2>/dev/null || true)
|
||||
if [ -n "$ERR_TYPE$ERR_MSG" ]; then
|
||||
echo " error.type: ${ERR_TYPE:-<none>}"
|
||||
echo " error.message: ${ERR_MSG:-<none>}"
|
||||
else
|
||||
# Not an Anthropic-shaped error - the proxy answered in its own format.
|
||||
echo " raw response (first 400 chars): $(head -c 400 "$BODY_FILE" | tr -d '\n')"
|
||||
fi
|
||||
|
||||
LOWER=$(printf '%s %s' "$ERR_TYPE" "$ERR_MSG" | tr '[:upper:]' '[:lower:]')
|
||||
case "$HTTP_STATUS" in
|
||||
200|201)
|
||||
VERDICT="ok"
|
||||
DETAIL="proxy accepted a live request"
|
||||
;;
|
||||
401)
|
||||
VERDICT="credentials"
|
||||
DETAIL="HTTP 401 - the token is rejected (expired or revoked)"
|
||||
;;
|
||||
403)
|
||||
# Grazie returns 403 both for a revoked grant and for an exhausted licence quota.
|
||||
case "$LOWER" in
|
||||
*quota*|*limit*|*balance*|*credit*)
|
||||
VERDICT="quota"
|
||||
DETAIL="HTTP 403 mentioning quota/limit - the licence quota is exhausted"
|
||||
;;
|
||||
*)
|
||||
VERDICT="credentials"
|
||||
DETAIL="HTTP 403 - the token is valid but no longer authorized for this model/service"
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
402)
|
||||
VERDICT="quota"
|
||||
DETAIL="HTTP 402 - billing/credit balance exhausted"
|
||||
;;
|
||||
429)
|
||||
VERDICT="quota"
|
||||
DETAIL="HTTP 429 - rate limit or quota exhausted"
|
||||
;;
|
||||
400|404|405|422)
|
||||
VERDICT="proxy"
|
||||
DETAIL="HTTP $HTTP_STATUS - request rejected by the proxy (likely an unsupported model id: $MODEL)"
|
||||
;;
|
||||
5*)
|
||||
VERDICT="proxy"
|
||||
DETAIL="HTTP $HTTP_STATUS - the proxy or upstream is failing"
|
||||
;;
|
||||
000)
|
||||
VERDICT="network"
|
||||
DETAIL="the request never completed (DNS, TLS or timeout)"
|
||||
;;
|
||||
*)
|
||||
VERDICT="unknown"
|
||||
DETAIL="unexpected HTTP $HTTP_STATUS"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# A live 200 outranks the local exp claim (the proxy may not enforce it), but the
|
||||
# stale token still deserves to show up in the verdict line.
|
||||
if [ "$VERDICT" = "ok" ] && [ "$JWT_EXPIRED" = "true" ]; then
|
||||
DETAIL="$DETAIL (note: the JWT's own exp claim is in the past)"
|
||||
fi
|
||||
|
||||
echo "::endgroup::"
|
||||
|
||||
case "$VERDICT" in
|
||||
ok) ICON="OK" ;;
|
||||
credentials) ICON="CREDENTIALS" ;;
|
||||
quota) ICON="QUOTA" ;;
|
||||
*) ICON="$(printf '%s' "$VERDICT" | tr '[:lower:]' '[:upper:]')" ;;
|
||||
esac
|
||||
echo "Claude $LABEL verdict: $ICON - $DETAIL"
|
||||
|
||||
{
|
||||
echo "### Claude $LABEL check"
|
||||
echo ""
|
||||
echo "- Verdict: \`$VERDICT\`"
|
||||
echo "- HTTP status: \`$HTTP_STATUS\`"
|
||||
echo "- Detail: $DETAIL"
|
||||
echo ""
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
{
|
||||
echo "verdict=$VERDICT"
|
||||
echo "http_status=$HTTP_STATUS"
|
||||
echo "detail=$DETAIL"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
if [ "$FAIL_ON_ERROR" = "true" ] && [ "$VERDICT" != "ok" ]; then
|
||||
echo "::error title=Claude $LABEL check failed::$DETAIL"
|
||||
exit 1
|
||||
fi
|
||||
@@ -21,6 +21,16 @@ 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
|
||||
@@ -113,6 +123,34 @@ 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
|
||||
|
||||
@@ -21,7 +21,7 @@ repositories {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly("com.google.devtools.ksp:symbol-processing-api:2.3.11")
|
||||
compileOnly("com.google.devtools.ksp:symbol-processing-api:2.3.12")
|
||||
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
@@ -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.7.1.202607240634-r")
|
||||
classpath("org.eclipse.jgit:org.eclipse.jgit.ssh.apache:7.8.0.202609011348-r")
|
||||
classpath("org.kohsuke:github-api:1.305")
|
||||
|
||||
classpath("io.ktor:ktor-client-core:3.5.2")
|
||||
|
||||
@@ -255,6 +255,9 @@ That's it.
|
||||
|
||||
https://github.com/tpope/vim-commentary/blob/master/doc/commentary.txt
|
||||
|
||||
Unlike vim-commentary, IdeaVim uses a block comment when the range is not linewise, so `gciw` can
|
||||
comment out a single argument. Set `let g:commentary_block_comments = 0` to always comment whole lines.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
@@ -59,6 +59,8 @@ 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` |
|
||||
|
||||
+20
-14
@@ -238,6 +238,26 @@ 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>")
|
||||
@@ -254,20 +274,6 @@ 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
@@ -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.59-chylex
|
||||
version=9999.60-chylex
|
||||
javaVersion=21
|
||||
remoteRobotVersion=0.11.23
|
||||
antlrVersion=4.10.1
|
||||
|
||||
@@ -21,7 +21,7 @@ repositories {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly("org.jetbrains.kotlin:kotlin-stdlib:2.4.10")
|
||||
compileOnly("org.jetbrains.kotlin:kotlin-stdlib:2.4.20")
|
||||
|
||||
testImplementation("org.junit.jupiter:junit-jupiter:6.1.3")
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||
@@ -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.7.1.202607240634-r")
|
||||
implementation("org.eclipse.jgit:org.eclipse.jgit.ssh.apache:7.8.0.202609011348-r")
|
||||
implementation("com.vdurmont:semver4j:3.1.0")
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ import com.intellij.openapi.editor.EditorFactory;
|
||||
import com.intellij.openapi.editor.actionSystem.TypedAction;
|
||||
import com.intellij.openapi.editor.actionSystem.TypedActionHandler;
|
||||
import com.intellij.openapi.editor.event.*;
|
||||
import com.intellij.openapi.editor.ex.EditorEx;
|
||||
import com.intellij.openapi.editor.ex.FoldingListener;
|
||||
import com.intellij.openapi.util.Disposer;
|
||||
import com.maddyhome.idea.vim.helper.HandlerInjector;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
@@ -89,6 +91,18 @@ public class EventFacade {
|
||||
editor.getCaretModel().addCaretListener(listener, disposable);
|
||||
}
|
||||
|
||||
public void addVisibleAreaListener(@NotNull Editor editor,
|
||||
@NotNull VisibleAreaListener listener,
|
||||
@NotNull Disposable disposable) {
|
||||
editor.getScrollingModel().addVisibleAreaListener(listener, disposable);
|
||||
}
|
||||
|
||||
public void addFoldingListener(@NotNull Editor editor,
|
||||
@NotNull FoldingListener listener,
|
||||
@NotNull Disposable disposable) {
|
||||
((EditorEx)editor).getFoldingModel().addListener(listener, disposable);
|
||||
}
|
||||
|
||||
public void addEditorMouseListener(@NotNull Editor editor,
|
||||
@NotNull EditorMouseListener listener,
|
||||
@NotNull Disposable disposable) {
|
||||
|
||||
@@ -8,11 +8,6 @@
|
||||
|
||||
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
|
||||
@@ -52,21 +47,5 @@ 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,13 +284,17 @@ class VimShortcutKeyAction : AnAction(), DumbAware/*, LightEditCompatible*/ {
|
||||
}
|
||||
|
||||
private fun isEnabledForEscape(editor: Editor): Boolean {
|
||||
val ideaVimSupportDialog =
|
||||
injector.globalIjOptions().ideavimsupport.contains(IjOptionConstants.ideavimsupport_dialog)
|
||||
return editor.isPrimaryEditor() ||
|
||||
EditorHelper.isFileEditor(editor) && !editor.vim.mode.inNormalMode ||
|
||||
// The Python console has full Vim support, so Escape must leave Insert/Visual mode instead of defocusing it.
|
||||
EditorHelper.isPythonConsole(editor) && !editor.vim.mode.inNormalMode ||
|
||||
ideaVimSupportDialog && !editor.vim.mode.inNormalMode
|
||||
if (editor.isPrimaryEditor()) return true
|
||||
// Outside the main editing area, Escape is Vim's only while there is a mode to leave. In Normal mode we hand it
|
||||
// back to the platform, which closes the popup or defocuses the tool window hosting the editor.
|
||||
return !editor.vim.mode.inNormalMode && escapeIsHandledByVim(editor)
|
||||
}
|
||||
|
||||
private fun escapeIsHandledByVim(editor: Editor): Boolean {
|
||||
return EditorHelper.isFileEditor(editor)
|
||||
|| EditorHelper.isPythonConsole(editor)
|
||||
|| EditorHelper.isRunConsole(editor)
|
||||
|| injector.globalIjOptions().ideavimsupport.contains(IjOptionConstants.ideavimsupport_dialog)
|
||||
}
|
||||
|
||||
private fun isShortcutConflict(keyStroke: KeyStroke): Boolean {
|
||||
|
||||
@@ -10,6 +10,7 @@ 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
|
||||
@@ -46,13 +47,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
|
||||
if (selectionType === SelectionType.LINE_WISE) {
|
||||
val blockCommentEnabled = areBlockCommentsEnabled()
|
||||
if (selectionType === SelectionType.LINE_WISE || !blockCommentEnabled) {
|
||||
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)
|
||||
@@ -71,6 +72,12 @@ 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,8 +13,11 @@ 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,
|
||||
@@ -61,6 +64,12 @@ 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"))
|
||||
@@ -141,3 +150,16 @@ 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) }
|
||||
|
||||
+12
-12
@@ -8,33 +8,31 @@
|
||||
|
||||
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 `ideapythonconsole` toggle option and enables or disables Vim in all currently open
|
||||
* Python console editors without requiring a restart or plugin toggle.
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
internal object PythonConsoleOptionChangeListener : GlobalOptionChangeListener {
|
||||
internal object ConsoleOptionChangeListener : GlobalOptionChangeListener {
|
||||
override fun onGlobalOptionChanged() {
|
||||
val enabled = injector.globalIjOptions().ideapythonconsole
|
||||
for (editor in EditorFactory.getInstance().allEditors) {
|
||||
if (editor.isDisposed) continue
|
||||
if (!EditorHelper.isPythonConsole(editor)) continue
|
||||
if (enabled) {
|
||||
if (!editor.isConsole()) continue
|
||||
if (editor.isEnabledConsole()) {
|
||||
VimListenerManager.EditorListeners.add(editor, injector.fallbackWindow, LocalOptionInitialisationScenario.NEW)
|
||||
editor.updateCaretsVisualAttributes()
|
||||
} else {
|
||||
@@ -43,4 +41,6 @@ internal object PythonConsoleOptionChangeListener : GlobalOptionChangeListener {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Editor.isConsole() = EditorHelper.isPythonConsole(this) || EditorHelper.isRunConsole(this)
|
||||
}
|
||||
@@ -16,6 +16,9 @@ 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"
|
||||
@@ -27,6 +30,7 @@ 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,6 +91,18 @@ 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(
|
||||
@@ -102,8 +114,7 @@ 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,6 +8,8 @@
|
||||
|
||||
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;
|
||||
@@ -434,7 +436,7 @@ public class EditorHelper {
|
||||
|
||||
private static int getStickyLinesPanelHeight(final @NotNull Editor editor) {
|
||||
if (editor instanceof EditorImpl editorImpl) {
|
||||
return editorImpl.getStickyLinesPanelHeight();
|
||||
return 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -727,6 +729,17 @@ 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,9 +67,12 @@ 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, 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).
|
||||
* 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.
|
||||
*/
|
||||
private fun Editor.isAllowedFileEditor(): Boolean {
|
||||
if (EditorHelper.getVirtualFile(this)?.name?.contains("Dummy.txt") == true) {
|
||||
@@ -81,7 +84,16 @@ private fun Editor.isAllowedFileEditor(): Boolean {
|
||||
|| EditorHelper.isDiffEditor(this)
|
||||
|| EditorHelper.isFileEditor(this)
|
||||
|| EditorHelper.isCommandHistoryWindow(this)
|
||||
|| (EditorHelper.isPythonConsole(this) && injector.globalIjOptions().ideapythonconsole)
|
||||
|| 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))
|
||||
}
|
||||
|
||||
private fun ideaVimDisabledInDialog(ideaVimSupportValue: StringListOptionValue): Boolean {
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
/*
|
||||
* 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,13 +12,11 @@ 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
|
||||
@@ -110,11 +108,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 [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.
|
||||
* 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.
|
||||
*/
|
||||
fun highlightPreviewMatch(editor: Editor, start: Int, end: Int, tooltip: String): RangeHighlighter {
|
||||
return addSearchMatchHighlighter(editor, start, end, tooltip)
|
||||
return createSearchMatchHighlighter(editor, start, end, tooltip)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -160,30 +158,32 @@ private fun updateSearchHighlights(
|
||||
val searchStartLine = searchRange?.startLine ?: 0
|
||||
val searchEndLine = (searchRange?.endLine ?: -1).coerceAtMost(vimEditor.lineCount() - 1)
|
||||
|
||||
var incsearchMatchOffset = -1
|
||||
if (shouldAddAllSearchHighlights(editor, pattern, showHighlights)) {
|
||||
// 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)) {
|
||||
// hlsearch (+ incsearch/noincsearch)
|
||||
addAllSearchHighlights(vimEditor, pattern, searchStartLine, searchEndLine, shouldIgnoreSmartCase)
|
||||
editor.vimLastSearch = pattern
|
||||
} else if (isCurrentEditor && shouldAddCurrentMatchSearchHighlight(pattern, showHighlights, initialOffset)) {
|
||||
} else if (currentMatch != null && shouldAddCurrentMatchSearchHighlight(pattern, showHighlights, initialOffset)) {
|
||||
// nohlsearch + incsearch. Even though search highlights are disabled, we still show a highlight (current editor
|
||||
// only), because 'incsearch' is active
|
||||
incsearchMatchOffset =
|
||||
addIncsearchMatchHighlight(vimEditor, pattern, initialOffset, count1, forwards, shouldIgnoreSmartCase)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// Remember the incsearch match, so that `c_CTRL-R_CTRL-W` can insert the word after it
|
||||
editor.vimIncsearchCurrentMatch = currentMatch
|
||||
|
||||
if (isCurrentEditor) {
|
||||
currentEditorCurrentMatchOffset = currentMatchOffset
|
||||
@@ -219,42 +219,36 @@ 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
|
||||
|
||||
val results = injector.searchHelper.findAll(
|
||||
editor,
|
||||
editor.ij.vimSearchHighlights.ensureUpToDate(
|
||||
pattern,
|
||||
shouldIgnoreCase(pattern, shouldIgnoreSmartCase),
|
||||
searchStartLine,
|
||||
searchEndLine,
|
||||
shouldIgnoreCase(pattern, shouldIgnoreSmartCase)
|
||||
)
|
||||
|
||||
highlightSearchResults(editor.ij, pattern, results)
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight the single match that the in-progress search would move to, returning its start offset, or -1 if the
|
||||
* pattern doesn't match.
|
||||
* 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.
|
||||
*/
|
||||
private fun addIncsearchMatchHighlight(
|
||||
private fun findIncsearchMatch(
|
||||
editor: VimEditor,
|
||||
pattern: String,
|
||||
initialOffset: Int,
|
||||
count1: Int,
|
||||
forwards: Boolean,
|
||||
shouldIgnoreSmartCase: Boolean,
|
||||
): Int {
|
||||
): TextRange? {
|
||||
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)
|
||||
|
||||
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
|
||||
return injector.searchHelper.findPattern(editor, pattern, initialOffset, count1, searchOptions)
|
||||
}
|
||||
|
||||
fun updateSearchCount(
|
||||
@@ -348,94 +342,18 @@ private fun shouldRemoveSearchHighlights(editor: Editor, newPattern: String?, hl
|
||||
|
||||
private fun removeSearchHighlights(editor: Editor) {
|
||||
editor.vimLastSearch = null
|
||||
val ehl = editor.vimLastHighlighters ?: return
|
||||
for (rh in ehl) {
|
||||
editor.markupModel.removeHighlighter(rh)
|
||||
}
|
||||
editor.vimLastHighlighters = null
|
||||
editor.vimSearchHighlights.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Add search highlights if hlSearch is true and the pattern is changed
|
||||
* 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.
|
||||
*/
|
||||
@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
|
||||
@Contract("_, false -> false; null, true -> false")
|
||||
private fun shouldAddAllSearchHighlights(newPattern: String?, hlSearch: Boolean): Boolean {
|
||||
return hlSearch && !newPattern.isNullOrEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -448,60 +366,7 @@ private fun addSearchMatchHighlighter(editor: Editor, start: Int, end: Int, tool
|
||||
*/
|
||||
fun clearCurrentSearchMatchHighlight(editor: Editor) {
|
||||
if (editor.isDisposed) return
|
||||
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)
|
||||
}
|
||||
editor.vimSearchHighlights.setCurrentMatch(editor.vimIncsearchCurrentMatch)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,7 +15,6 @@ 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
|
||||
@@ -24,6 +23,7 @@ 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,13 +108,20 @@ var Editor.vimInitialised: Boolean by userDataOr { false }
|
||||
fun unInitializeEditor(editor: Editor) {
|
||||
editor.vimLastSelectionType = null
|
||||
editor.vimMorePanel = null
|
||||
editor.vimLastHighlighters = null
|
||||
editor.vimSearchHighlights.clear()
|
||||
editor.vimInitialised = false
|
||||
}
|
||||
|
||||
var Editor.vimLastSearch: String? by userData()
|
||||
var Editor.vimLastHighlighters: MutableCollection<RangeHighlighter>? by userData()
|
||||
var Editor.vimIncsearchCurrentMatchOffset: Int? 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) }
|
||||
|
||||
/***
|
||||
* @see :help visualmode()
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
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
|
||||
@@ -28,6 +27,8 @@ 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) {
|
||||
@@ -77,17 +78,30 @@ class IJEditorFocusListener : EditorListener {
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
enterNormalModeInRunConsole(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,7 +34,10 @@ 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
|
||||
@@ -62,6 +65,7 @@ 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
|
||||
@@ -85,6 +89,7 @@ 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
|
||||
@@ -94,7 +99,6 @@ 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
|
||||
@@ -117,6 +121,7 @@ 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
|
||||
@@ -247,7 +252,7 @@ object VimListenerManager {
|
||||
optionGroup.addGlobalOptionChangeListener(Options.showmode, macroWidgetOptionListener)
|
||||
optionGroup.addEffectiveOptionValueChangeListener(Options.keymap, KeymapChangeListener)
|
||||
optionGroup.addGlobalOptionChangeListener(Options.ideawindowjumps, WindowJumpsChangeListener)
|
||||
optionGroup.addGlobalOptionChangeListener(IjOptions.ideapythonconsole, PythonConsoleOptionChangeListener)
|
||||
optionGroup.addGlobalOptionChangeListener(IjOptions.ideaeditor, ConsoleOptionChangeListener)
|
||||
|
||||
// The listeners are registered _after_ ideavimrc has been evaluated, so trigger these listeners to ensure we're
|
||||
// up to date
|
||||
@@ -297,7 +302,7 @@ object VimListenerManager {
|
||||
optionGroup.removeGlobalOptionChangeListener(Options.showmode, modeWidgetOptionListener)
|
||||
optionGroup.removeGlobalOptionChangeListener(Options.showmode, macroWidgetOptionListener)
|
||||
optionGroup.removeGlobalOptionChangeListener(Options.ideawindowjumps, WindowJumpsChangeListener)
|
||||
optionGroup.removeGlobalOptionChangeListener(IjOptions.ideapythonconsole, PythonConsoleOptionChangeListener)
|
||||
optionGroup.removeGlobalOptionChangeListener(IjOptions.ideaeditor, ConsoleOptionChangeListener)
|
||||
|
||||
BufNewFileTracker.clear()
|
||||
}
|
||||
@@ -386,6 +391,13 @@ 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)
|
||||
@@ -623,21 +635,15 @@ object VimListenerManager {
|
||||
/**
|
||||
* Retries initialisation for editors that can only be recognised as supported after they've been created.
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
private fun scheduleDeferredInitialisation(editor: Editor) {
|
||||
if (editor.isDisposed) return
|
||||
val file = EditorHelper.getVirtualFile(editor) ?: return
|
||||
if (file !is LightVirtualFile || file.extension != PYTHON_EXTENSION) return
|
||||
if (!mayBecomeSupported(editor)) return
|
||||
|
||||
// Capture the opening editor now rather than in the callback - by then the selected editor might have changed
|
||||
val openingEditor = getOpeningEditor(editor)
|
||||
@@ -648,6 +654,16 @@ 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
|
||||
@@ -1087,6 +1103,59 @@ 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,13 +29,11 @@ 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.vimIncsearchCurrentMatchOffset
|
||||
import com.maddyhome.idea.vim.helper.vimLastHighlighters
|
||||
import com.maddyhome.idea.vim.helper.vimIncsearchCurrentMatch
|
||||
import com.maddyhome.idea.vim.helper.vimSearchHighlights
|
||||
import com.maddyhome.idea.vim.options.GlobalOptionChangeListener
|
||||
import org.jdom.Element
|
||||
import org.jetbrains.annotations.Contract
|
||||
@@ -69,21 +67,6 @@ 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)
|
||||
}
|
||||
@@ -123,20 +106,14 @@ open class IjVimSearchGroup : VimSearchGroupBase(), PersistentStateComponent<Ele
|
||||
}
|
||||
for (vimEditor in vimEditors) {
|
||||
val editor = vimEditor.ij
|
||||
if (editor.vimLastHighlighters != null) {
|
||||
if (editor.vimSearchHighlights.hasMatches()) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
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 getCurrentIncsearchResultRange(editor: VimEditor): TextRange? = editor.ij.vimIncsearchCurrentMatch
|
||||
|
||||
override fun setShouldShowSearchHighlights() {
|
||||
showSearchHighlight = injector.globalOptions().hlsearch
|
||||
@@ -254,7 +231,7 @@ open class IjVimSearchGroup : VimSearchGroupBase(), PersistentStateComponent<Ele
|
||||
|
||||
|
||||
/**
|
||||
* Removes and adds highlights for current search pattern when the document is edited
|
||||
* Rebuilds the search highlights of the changed document, since the edit may have created or destroyed matches
|
||||
*/
|
||||
class DocumentSearchListener @Contract(pure = true) private constructor() : DocumentListener {
|
||||
override fun documentChanged(event: DocumentEvent) {
|
||||
@@ -262,52 +239,15 @@ 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.
|
||||
val document = event.document
|
||||
for (vimEditor in injector.editorGroup.getEditors(IjVimDocument(document))) {
|
||||
for (vimEditor in injector.editorGroup.getEditors(IjVimDocument(event.document))) {
|
||||
val editor = vimEditor.ij
|
||||
var existingHighlighters = editor.vimLastHighlighters ?: continue
|
||||
if (!editor.vimSearchHighlights.isActive) continue
|
||||
|
||||
if (logger.isDebug()) {
|
||||
logger.debug("hls=$existingHighlighters")
|
||||
logger.debug("event=$event")
|
||||
}
|
||||
editor.vimSearchHighlights.refreshAfterDocumentChange()
|
||||
|
||||
// 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
|
||||
// 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
|
||||
clearCurrentSearchMatchHighlight(editor)
|
||||
|
||||
if (logger.isDebug()) {
|
||||
existingHighlighters = editor.vimLastHighlighters!!
|
||||
logger.debug("sl=" + startPosition.line + ", el=" + endPosition.line)
|
||||
logger.debug("hls=$existingHighlighters")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -198,13 +198,6 @@ 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))
|
||||
return Path(injector.pathExpansion.expandPath(withMacrosExpanded ?: trimmed))
|
||||
}
|
||||
}
|
||||
|
||||
+39
@@ -16,6 +16,7 @@ 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
|
||||
@@ -327,6 +328,44 @@ 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,11 +8,16 @@
|
||||
|
||||
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
|
||||
@@ -74,6 +79,71 @@ 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)
|
||||
}
|
||||
@@ -104,4 +174,31 @@ 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,8 +8,13 @@
|
||||
|
||||
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])
|
||||
@@ -650,4 +655,55 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* 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",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+305
@@ -0,0 +1,305 @@
|
||||
/*
|
||||
* 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,6 +34,9 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -100,8 +100,7 @@ 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
|
||||
*/
|
||||
@JvmField
|
||||
protected var didAutoIndent: Boolean = false
|
||||
protected var didAutoIndent: Boolean get() = false; set(_) = Unit
|
||||
|
||||
/**
|
||||
* The value [didAutoIndent] gets when the next insert session starts.
|
||||
@@ -389,8 +388,10 @@ abstract class VimChangeGroupBase : VimChangeGroup {
|
||||
caret.moveToOffset(position)
|
||||
} else {
|
||||
repeatInsertText(editor, context, count)
|
||||
val position = injector.motion.getHorizontalMotion(editor, caret, -1, false)
|
||||
caret.moveToMotion(position)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -447,22 +447,39 @@ abstract class VimMarkServiceBase : VimMarkService {
|
||||
newFragment: CharSequence,
|
||||
) {
|
||||
val changeEndOffset = changeOffset + oldLength
|
||||
val changeStartLine = editor.offsetToBufferPosition(changeOffset).line
|
||||
val oldEndLine = editor.offsetToBufferPosition(changeEndOffset).line
|
||||
val newEndLine = changeStartLine + newFragment.count { it == '\n' }
|
||||
if (oldEndLine == newEndLine) return
|
||||
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)
|
||||
|
||||
for (caret in editor.carets()) {
|
||||
val selectionInfo = caret.lastSelectionInfo
|
||||
|
||||
val startPosition = selectionInfo.start
|
||||
val newStartPosition = startPosition?.let {
|
||||
adjustVisualMark(editor, it, changeOffset, changeEndOffset, oldEndLine, newEndLine)
|
||||
adjustVisualMark(
|
||||
editor, it, changeOffset, changeEndOffset, changeEnd, newEndLine, tailStartColumn, tailSurvives,
|
||||
)
|
||||
}
|
||||
|
||||
val endPosition = selectionInfo.end
|
||||
val newEndPosition = endPosition?.let {
|
||||
adjustVisualMark(editor, it, changeOffset, changeEndOffset, oldEndLine, newEndLine)
|
||||
adjustVisualMark(
|
||||
editor, it, changeOffset, changeEndOffset, changeEnd, newEndLine, tailStartColumn, tailSurvives,
|
||||
)
|
||||
}
|
||||
|
||||
if (newStartPosition != startPosition || newEndPosition != endPosition) {
|
||||
@@ -478,21 +495,41 @@ abstract class VimMarkServiceBase : VimMarkService {
|
||||
position: BufferPosition,
|
||||
changeOffset: Int,
|
||||
changeEndOffset: Int,
|
||||
oldEndLine: Int,
|
||||
changeEnd: BufferPosition,
|
||||
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 -> BufferPosition(
|
||||
position.line + (newEndLine - oldEndLine),
|
||||
position.column,
|
||||
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,
|
||||
position.leansForward,
|
||||
)
|
||||
|
||||
position.line > newEndLine -> BufferPosition(newEndLine, position.column, position.leansForward)
|
||||
else -> position
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,19 +104,6 @@ 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,6 +25,7 @@ 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
|
||||
@@ -50,6 +51,25 @@ 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
|
||||
@@ -98,18 +118,20 @@ 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 {
|
||||
var first = true
|
||||
while (keyHandler.keyStack.hasStroke()) {
|
||||
val keySource = if (!isRecursive || (first && lhsIsPrefixOfRhs)) {
|
||||
KeySource.MAPPED_NON_RECURSIVE
|
||||
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
|
||||
}
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user