mirror of
https://github.com/chylex/IntelliJ-IdeaVim.git
synced 2026-10-17 05:33:52 +02:00
Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9b8a4fb13
|
||
|
|
75bfa61925
|
||
|
|
200ec346d0
|
||
|
|
372a105edc
|
||
|
|
898c8f5fab
|
||
|
|
a772fd0b05
|
||
|
|
6c4c4d1050
|
||
|
|
b28c2690e7
|
||
|
|
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:
|
with:
|
||||||
fetch-depth: 0 # Full history so the changelog can be analyzed and the PR branch pushed
|
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
|
- name: Run Claude Code to update the changelog and prepare the What's New page
|
||||||
id: claude
|
id: claude
|
||||||
uses: anthropics/claude-code-action@v1
|
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.
|
# Changelog needs the skill + git/gh/web; the page generation needs file read/write.
|
||||||
claude_args: '--allowed-tools "Skill,Read,Write,Edit,Glob,Grep,Bash(git:*),Bash(gh:*),WebSearch,WebFetch(domain:plugins.jetbrains.com),WebFetch(domain:youtrack.jetbrains.com),WebFetch(domain:github.com)"'
|
claude_args: '--allowed-tools "Skill,Read,Write,Edit,Glob,Grep,Bash(git:*),Bash(gh:*),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
|
- name: Comment rendered preview link on the PR
|
||||||
if: ${{ always() }}
|
if: ${{ always() }}
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v7
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ repositories {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
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") {
|
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
|
// kotlin stdlib is provided by IJ, so there is no need to include it into the distribution
|
||||||
exclude("org.jetbrains.kotlin", "kotlin-stdlib")
|
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")
|
classpath("org.eclipse.jgit:org.eclipse.jgit:6.6.0.202305301015-r")
|
||||||
|
|
||||||
// This is needed for jgit to connect to ssh
|
// This is needed for jgit to connect to ssh
|
||||||
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("org.kohsuke:github-api:1.305")
|
||||||
|
|
||||||
classpath("io.ktor:ktor-client-core:3.5.2")
|
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
|
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>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ Plug 'nerdtree'
|
|||||||
| `J` | Jump down inside directories at the current tree depth | `g:NERDTreeMapJumpLastChild` |
|
| `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-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-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 directory | `g:NERDTreeMapRefresh` |
|
||||||
| `R` | Recursively refresh the current root | `g:NERDTreeMapRefreshRoot` |
|
| `R` | Recursively refresh the current root | `g:NERDTreeMapRefreshRoot` |
|
||||||
| `m` | Display the NERDTree menu | `g:NERDTreeMapMenu` |
|
| `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
|
string literals or if statements. See the wiki for more examples. Not
|
||||||
all languages support smart join functionality.
|
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>,
|
'idealookupkeys' string (default "<Tab>,<Down>,<Up>,<Enter>,
|
||||||
<Left>,<Right>,<C-Down>,<C-Up>,
|
<Left>,<Right>,<C-Down>,<C-Up>,
|
||||||
<PageUp>,<PageDown>, <C-J>,<C-Q>")
|
<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
|
global
|
||||||
Maps Vim's global marks to IDE bookmarks.
|
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")
|
'idearefactormode' string (default "select")
|
||||||
global or local to buffer
|
global or local to buffer
|
||||||
Specifies the mode to be used when a refactoring selects text to be
|
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
|
# Values for type: https://plugins.jetbrains.com/docs/intellij/tools-gradle-intellij-plugin.html#intellij-extension-type
|
||||||
ideaType=IU
|
ideaType=IU
|
||||||
instrumentPluginCode=true
|
instrumentPluginCode=true
|
||||||
version=9999.59-chylex
|
version=9999.61-chylex
|
||||||
javaVersion=21
|
javaVersion=21
|
||||||
remoteRobotVersion=0.11.23
|
remoteRobotVersion=0.11.23
|
||||||
antlrVersion=4.10.1
|
antlrVersion=4.10.1
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ repositories {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
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")
|
testImplementation("org.junit.jupiter:junit-jupiter:6.1.3")
|
||||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||||
@@ -34,7 +34,7 @@ dependencies {
|
|||||||
implementation("org.eclipse.jgit:org.eclipse.jgit:6.6.0.202305301015-r")
|
implementation("org.eclipse.jgit:org.eclipse.jgit:6.6.0.202305301015-r")
|
||||||
|
|
||||||
// This is needed for jgit to connect to ssh
|
// This is needed for jgit to connect to ssh
|
||||||
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")
|
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.TypedAction;
|
||||||
import com.intellij.openapi.editor.actionSystem.TypedActionHandler;
|
import com.intellij.openapi.editor.actionSystem.TypedActionHandler;
|
||||||
import com.intellij.openapi.editor.event.*;
|
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.intellij.openapi.util.Disposer;
|
||||||
import com.maddyhome.idea.vim.helper.HandlerInjector;
|
import com.maddyhome.idea.vim.helper.HandlerInjector;
|
||||||
import org.jetbrains.annotations.NotNull;
|
import org.jetbrains.annotations.NotNull;
|
||||||
@@ -89,6 +91,18 @@ public class EventFacade {
|
|||||||
editor.getCaretModel().addCaretListener(listener, disposable);
|
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,
|
public void addEditorMouseListener(@NotNull Editor editor,
|
||||||
@NotNull EditorMouseListener listener,
|
@NotNull EditorMouseListener listener,
|
||||||
@NotNull Disposable disposable) {
|
@NotNull Disposable disposable) {
|
||||||
|
|||||||
@@ -8,11 +8,6 @@
|
|||||||
|
|
||||||
package com.maddyhome.idea.vim
|
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.ApplicationManager
|
||||||
import com.intellij.openapi.application.EDT
|
import com.intellij.openapi.application.EDT
|
||||||
import com.intellij.openapi.project.Project
|
import com.intellij.openapi.project.Project
|
||||||
@@ -52,21 +47,5 @@ internal class PluginStartup : ProjectActivity/*, LightEditCompatible*/ {
|
|||||||
VimPlugin.getInstance().initialize()
|
VimPlugin.getInstance().initialize()
|
||||||
|
|
||||||
(injector.enabler as IjVimEnabler).ideOpened()
|
(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 {
|
private fun isEnabledForEscape(editor: Editor): Boolean {
|
||||||
val ideaVimSupportDialog =
|
if (editor.isPrimaryEditor()) return true
|
||||||
injector.globalIjOptions().ideavimsupport.contains(IjOptionConstants.ideavimsupport_dialog)
|
// Outside the main editing area, Escape is Vim's only while there is a mode to leave. In Normal mode we hand it
|
||||||
return editor.isPrimaryEditor() ||
|
// back to the platform, which closes the popup or defocuses the tool window hosting the editor.
|
||||||
EditorHelper.isFileEditor(editor) && !editor.vim.mode.inNormalMode ||
|
return !editor.vim.mode.inNormalMode && escapeIsHandledByVim(editor)
|
||||||
// The Python console has full Vim support, so Escape must leave Insert/Visual mode instead of defocusing it.
|
}
|
||||||
EditorHelper.isPythonConsole(editor) && !editor.vim.mode.inNormalMode ||
|
|
||||||
ideaVimSupportDialog && !editor.vim.mode.inNormalMode
|
private fun 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 {
|
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.openapi.editor.impl.editorId
|
||||||
import com.intellij.vim.api.VimInitApi
|
import com.intellij.vim.api.VimInitApi
|
||||||
import com.maddyhome.idea.vim.KeyHandler
|
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.ExecutionContext
|
||||||
import com.maddyhome.idea.vim.api.ImmutableVimCaret
|
import com.maddyhome.idea.vim.api.ImmutableVimCaret
|
||||||
import com.maddyhome.idea.vim.api.VimEditor
|
import com.maddyhome.idea.vim.api.VimEditor
|
||||||
@@ -46,13 +47,13 @@ internal class CommentaryExtension : VimExtension {
|
|||||||
selectionType: SelectionType,
|
selectionType: SelectionType,
|
||||||
resetCaret: Boolean = true,
|
resetCaret: Boolean = true,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val mode = editor.mode
|
|
||||||
val ijEditor = editor.ij
|
val ijEditor = editor.ij
|
||||||
val document = ijEditor.document
|
val document = ijEditor.document
|
||||||
|
|
||||||
val editorId = ijEditor.editorId()
|
val editorId = ijEditor.editorId()
|
||||||
val caretOffset = if (resetCaret) range.startOffset else -1
|
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)
|
val startLine = document.getLineNumber(range.startOffset)
|
||||||
var endLine = document.getLineNumber(range.endOffset)
|
var endLine = document.getLineNumber(range.endOffset)
|
||||||
// Adjust endLine if the range ends at the start of a line (don't include that line)
|
// 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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun areBlockCommentsEnabled(): Boolean {
|
||||||
|
val value =
|
||||||
|
VimPlugin.getVariableService().getGlobalVariableValue("commentary_block_comments")?.toVimNumber()?.booleanValue
|
||||||
|
return value == null || value
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -13,8 +13,11 @@ import com.intellij.util.ui.tree.TreeUtil
|
|||||||
import com.maddyhome.idea.vim.VimPlugin
|
import com.maddyhome.idea.vim.VimPlugin
|
||||||
import com.maddyhome.idea.vim.api.injector
|
import com.maddyhome.idea.vim.api.injector
|
||||||
import com.maddyhome.idea.vim.vimscript.model.datatypes.VimString
|
import com.maddyhome.idea.vim.vimscript.model.datatypes.VimString
|
||||||
|
import javax.swing.JTree
|
||||||
import javax.swing.KeyStroke
|
import javax.swing.KeyStroke
|
||||||
import javax.swing.tree.TreeNode
|
import javax.swing.tree.TreeNode
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
fun MutableMap<List<KeyStroke>, NerdTreeAction>.register(
|
fun MutableMap<List<KeyStroke>, NerdTreeAction>.register(
|
||||||
variable: String,
|
variable: String,
|
||||||
@@ -61,6 +64,12 @@ val navigationMappings: Map<List<KeyStroke>, NerdTreeAction> = mutableMapOf<List
|
|||||||
register("j", NerdTreeAction.swing("selectNext"))
|
register("j", NerdTreeAction.swing("selectNext"))
|
||||||
register("G", NerdTreeAction.swing("selectLast"))
|
register("G", NerdTreeAction.swing("selectLast"))
|
||||||
register("gg", NerdTreeAction.swing("selectFirst"))
|
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
|
// FIXME lazy loaded tree nodes are not expanded
|
||||||
register("NERDTreeMapOpenRecursively", "O", NerdTreeAction.ij("FullyExpandTreeNode"))
|
register("NERDTreeMapOpenRecursively", "O", NerdTreeAction.ij("FullyExpandTreeNode"))
|
||||||
@@ -141,3 +150,16 @@ val navigationMappings: Map<List<KeyStroke>, NerdTreeAction> = mutableMapOf<List
|
|||||||
})
|
})
|
||||||
register("<ESC>", NerdTreeAction { _, _ -> })
|
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
|
package com.maddyhome.idea.vim.group
|
||||||
|
|
||||||
|
import com.intellij.openapi.editor.Editor
|
||||||
import com.intellij.openapi.editor.EditorFactory
|
import com.intellij.openapi.editor.EditorFactory
|
||||||
import com.maddyhome.idea.vim.api.LocalOptionInitialisationScenario
|
import com.maddyhome.idea.vim.api.LocalOptionInitialisationScenario
|
||||||
import com.maddyhome.idea.vim.api.injector
|
import com.maddyhome.idea.vim.api.injector
|
||||||
import com.maddyhome.idea.vim.helper.EditorHelper
|
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.removeCaretsVisualAttributes
|
||||||
import com.maddyhome.idea.vim.helper.updateCaretsVisualAttributes
|
import com.maddyhome.idea.vim.helper.updateCaretsVisualAttributes
|
||||||
import com.maddyhome.idea.vim.listener.VimListenerManager
|
import com.maddyhome.idea.vim.listener.VimListenerManager
|
||||||
import com.maddyhome.idea.vim.newapi.globalIjOptions
|
|
||||||
import com.maddyhome.idea.vim.options.GlobalOptionChangeListener
|
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
|
* Reacts to runtime changes of the 'ideaeditor' option and enables or disables Vim in all currently open console
|
||||||
* Python console editors without requiring a restart or plugin toggle.
|
* editors without requiring a restart or plugin toggle.
|
||||||
*
|
*
|
||||||
* When `set ideapythonconsole`: initialises Vim listeners, shortcuts, and local options for every open Python console
|
* Enabling initialises Vim listeners, shortcuts and local options for every open console editor that has not yet been
|
||||||
* editor that has not yet been set up (e.g. consoles that were opened while the option was off).
|
* set up, e.g. consoles that were opened while the option didn't list them. Disabling tears Vim down again and resets
|
||||||
*
|
* the caret shape to the IDE default.
|
||||||
* When `set noideapythonconsole`: tears down Vim from every open Python console editor and resets the caret shape to the
|
|
||||||
* IDE default.
|
|
||||||
*/
|
*/
|
||||||
internal object PythonConsoleOptionChangeListener : GlobalOptionChangeListener {
|
internal object ConsoleOptionChangeListener : GlobalOptionChangeListener {
|
||||||
override fun onGlobalOptionChanged() {
|
override fun onGlobalOptionChanged() {
|
||||||
val enabled = injector.globalIjOptions().ideapythonconsole
|
|
||||||
for (editor in EditorFactory.getInstance().allEditors) {
|
for (editor in EditorFactory.getInstance().allEditors) {
|
||||||
if (editor.isDisposed) continue
|
if (editor.isDisposed) continue
|
||||||
if (!EditorHelper.isPythonConsole(editor)) continue
|
if (!editor.isConsole()) continue
|
||||||
if (enabled) {
|
if (editor.isEnabledConsole()) {
|
||||||
VimListenerManager.EditorListeners.add(editor, injector.fallbackWindow, LocalOptionInitialisationScenario.NEW)
|
VimListenerManager.EditorListeners.add(editor, injector.fallbackWindow, LocalOptionInitialisationScenario.NEW)
|
||||||
editor.updateCaretsVisualAttributes()
|
editor.updateCaretsVisualAttributes()
|
||||||
} else {
|
} 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_select: String = "select"
|
||||||
const val idearefactormode_visual: String = "visual"
|
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_enabled: String = "enabled"
|
||||||
const val ideastatusicon_gray: String = "gray"
|
const val ideastatusicon_gray: String = "gray"
|
||||||
const val ideastatusicon_disabled: String = "disabled"
|
const val ideastatusicon_disabled: String = "disabled"
|
||||||
@@ -27,6 +30,7 @@ class IjOptionConstants {
|
|||||||
const val ideawrite_all: String = "all"
|
const val ideawrite_all: String = "all"
|
||||||
const val ideawrite_file: String = "file"
|
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 ideaStatusIconValues: Set<String> = setOf(ideastatusicon_enabled, ideastatusicon_gray, ideastatusicon_disabled)
|
||||||
val ideaRefactorModeValues: Set<String> =
|
val ideaRefactorModeValues: Set<String> =
|
||||||
setOf(idearefactormode_keep, idearefactormode_select, idearefactormode_visual)
|
setOf(idearefactormode_keep, idearefactormode_select, idearefactormode_visual)
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ import com.maddyhome.idea.vim.options.OptionAccessScope
|
|||||||
@Suppress("SpellCheckingInspection")
|
@Suppress("SpellCheckingInspection")
|
||||||
open class GlobalIjOptions(scope: OptionAccessScope) : OptionsPropertiesBase(scope) {
|
open class GlobalIjOptions(scope: OptionAccessScope) : OptionsPropertiesBase(scope) {
|
||||||
var ide: String by optionProperty(IjOptions.ide)
|
var ide: String by optionProperty(IjOptions.ide)
|
||||||
|
val ideaeditor: StringListOptionValue by optionProperty(IjOptions.ideaeditor)
|
||||||
val idealookupkeys: StringListOptionValue by optionProperty(IjOptions.idealookupkeys)
|
val idealookupkeys: StringListOptionValue by optionProperty(IjOptions.idealookupkeys)
|
||||||
var ideamarks: Boolean by optionProperty(IjOptions.ideamarks)
|
var ideamarks: Boolean by optionProperty(IjOptions.ideamarks)
|
||||||
val ideapythonconsole: Boolean by optionProperty(IjOptions.ideapythonconsole)
|
|
||||||
var ideastatusicon: String by optionProperty(IjOptions.ideastatusicon)
|
var ideastatusicon: String by optionProperty(IjOptions.ideastatusicon)
|
||||||
var ideatrackactionids: Boolean by optionProperty(IjOptions.ideatrackactionids)
|
var ideatrackactionids: Boolean by optionProperty(IjOptions.ideatrackactionids)
|
||||||
val ideavimsupport: StringListOptionValue by optionProperty(IjOptions.ideavimsupport)
|
val ideavimsupport: StringListOptionValue by optionProperty(IjOptions.ideavimsupport)
|
||||||
|
|||||||
@@ -91,6 +91,18 @@ object IjOptions {
|
|||||||
val ideacopypreprocess: ToggleOption = addOption(
|
val ideacopypreprocess: ToggleOption = addOption(
|
||||||
ToggleOption("ideacopypreprocess", GLOBAL_OR_LOCAL_TO_BUFFER, "ideacopypreprocess", false)
|
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 ideajoin: ToggleOption = addOption(ToggleOption("ideajoin", GLOBAL_OR_LOCAL_TO_BUFFER, "ideajoin", false))
|
||||||
val idealookupkeys: StringListOption = addOption(
|
val idealookupkeys: StringListOption = addOption(
|
||||||
StringListOption(
|
StringListOption(
|
||||||
@@ -102,8 +114,7 @@ object IjOptions {
|
|||||||
"lookupkeys"
|
"lookupkeys"
|
||||||
)
|
)
|
||||||
val ideamarks: ToggleOption = addOption(ToggleOption("ideamarks", GLOBAL, "ideamarks", true))
|
val ideamarks: ToggleOption = addOption(ToggleOption("ideamarks", GLOBAL, "ideamarks", true))
|
||||||
val ideapythonconsole: ToggleOption =
|
|
||||||
addOption(ToggleOption("ideapythonconsole", GLOBAL, "ideapythonconsole", true), "pythonconsole")
|
|
||||||
val idearefactormode: StringOption = addOption(
|
val idearefactormode: StringOption = addOption(
|
||||||
StringOption(
|
StringOption(
|
||||||
"idearefactormode",
|
"idearefactormode",
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
|
|
||||||
package com.maddyhome.idea.vim.helper;
|
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.injected.editor.VirtualFileWindow;
|
||||||
import com.intellij.openapi.editor.*;
|
import com.intellij.openapi.editor.*;
|
||||||
import com.intellij.openapi.editor.ex.util.EditorUtil;
|
import com.intellij.openapi.editor.ex.util.EditorUtil;
|
||||||
@@ -434,7 +436,7 @@ public class EditorHelper {
|
|||||||
|
|
||||||
private static int getStickyLinesPanelHeight(final @NotNull Editor editor) {
|
private static int getStickyLinesPanelHeight(final @NotNull Editor editor) {
|
||||||
if (editor instanceof EditorImpl editorImpl) {
|
if (editor instanceof EditorImpl editorImpl) {
|
||||||
return editorImpl.getStickyLinesPanelHeight();
|
return 0;
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -727,6 +729,17 @@ public class EditorHelper {
|
|||||||
return editor.getEditorKind() == EditorKind.DIFF;
|
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
|
* Checks if the editor hosts a Vim command-line / search history window
|
||||||
* (the synthetic buffer opened by `q:`, `q/`, or `q?`).
|
* (the synthetic buffer opened by `q:`, `q/`, or `q?`).
|
||||||
|
|||||||
@@ -67,9 +67,12 @@ internal val Editor.isIdeaVimDisabledHere: Boolean
|
|||||||
* AI Chat – VIM-3786
|
* AI Chat – VIM-3786
|
||||||
* Debug evaluate console – VIM-3929
|
* Debug evaluate console – VIM-3929
|
||||||
*
|
*
|
||||||
* We do want to support Vim actions in some windows, such as the commit window, diff windows, decompiled Java
|
* We do want to support Vim actions in some windows, such as the commit window, diff windows and decompiled Java
|
||||||
* files, and the Python console. Note that the Python console still needs special handling so it doesn't lose the
|
* files. The consoles are opt-out via the 'ideaeditor' option. Note that the Python console still needs special
|
||||||
* Enter and arrow keys (see KeyGroup and ToolWindowNavEverywhere).
|
* 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 {
|
private fun Editor.isAllowedFileEditor(): Boolean {
|
||||||
if (EditorHelper.getVirtualFile(this)?.name?.contains("Dummy.txt") == true) {
|
if (EditorHelper.getVirtualFile(this)?.name?.contains("Dummy.txt") == true) {
|
||||||
@@ -81,7 +84,16 @@ private fun Editor.isAllowedFileEditor(): Boolean {
|
|||||||
|| EditorHelper.isDiffEditor(this)
|
|| EditorHelper.isDiffEditor(this)
|
||||||
|| EditorHelper.isFileEditor(this)
|
|| EditorHelper.isFileEditor(this)
|
||||||
|| EditorHelper.isCommandHistoryWindow(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 {
|
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.Editor
|
||||||
import com.intellij.openapi.editor.colors.EditorColors
|
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.EffectType
|
||||||
import com.intellij.openapi.editor.markup.HighlighterLayer
|
import com.intellij.openapi.editor.markup.HighlighterLayer
|
||||||
import com.intellij.openapi.editor.markup.HighlighterTargetArea
|
import com.intellij.openapi.editor.markup.HighlighterTargetArea
|
||||||
import com.intellij.openapi.editor.markup.RangeHighlighter
|
import com.intellij.openapi.editor.markup.RangeHighlighter
|
||||||
import com.intellij.openapi.editor.markup.TextAttributes
|
import com.intellij.openapi.editor.markup.TextAttributes
|
||||||
import com.intellij.openapi.util.Key
|
|
||||||
import com.maddyhome.idea.vim.api.VimEditor
|
import com.maddyhome.idea.vim.api.VimEditor
|
||||||
import com.maddyhome.idea.vim.api.globalOptions
|
import com.maddyhome.idea.vim.api.globalOptions
|
||||||
import com.maddyhome.idea.vim.api.injector
|
import com.maddyhome.idea.vim.api.injector
|
||||||
@@ -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
|
* Highlight a single range using the standard search-result attributes, returning the highlighter so the caller can
|
||||||
* remove it later.
|
* remove it later.
|
||||||
*
|
*
|
||||||
* Unlike [highlightSearchResults], this does not touch the editor's tracked incsearch highlighters, so it is suitable
|
* Unlike the search highlights, this is not tracked by [SearchHighlights], so it is suitable for transient overlays -
|
||||||
* for transient overlays - such as the `inccommand` preview - that manage their own highlighter lifecycle.
|
* such as the `inccommand` preview - that manage their own highlighter lifecycle.
|
||||||
*/
|
*/
|
||||||
fun highlightPreviewMatch(editor: Editor, start: Int, end: Int, tooltip: String): RangeHighlighter {
|
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 searchStartLine = searchRange?.startLine ?: 0
|
||||||
val searchEndLine = (searchRange?.endLine ?: -1).coerceAtMost(vimEditor.lineCount() - 1)
|
val searchEndLine = (searchRange?.endLine ?: -1).coerceAtMost(vimEditor.lineCount() - 1)
|
||||||
|
|
||||||
var incsearchMatchOffset = -1
|
// The current match belongs to the 'incsearch' preview, and only to the editor being searched. Once the search has
|
||||||
if (shouldAddAllSearchHighlights(editor, pattern, showHighlights)) {
|
// 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)
|
// hlsearch (+ incsearch/noincsearch)
|
||||||
addAllSearchHighlights(vimEditor, pattern, searchStartLine, searchEndLine, shouldIgnoreSmartCase)
|
addAllSearchHighlights(vimEditor, pattern, searchStartLine, searchEndLine, shouldIgnoreSmartCase)
|
||||||
editor.vimLastSearch = pattern
|
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
|
// nohlsearch + incsearch. Even though search highlights are disabled, we still show a highlight (current editor
|
||||||
// only), because 'incsearch' is active
|
// only), because 'incsearch' is active. We don't show one if Visual is active (behind Command-line, of course),
|
||||||
incsearchMatchOffset =
|
// because the Visual selection is enough
|
||||||
addIncsearchMatchHighlight(vimEditor, pattern, initialOffset, count1, forwards, shouldIgnoreSmartCase)
|
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
|
// Remember the incsearch match, so that `c_CTRL-R_CTRL-W` can insert the word after it
|
||||||
// search has been accepted or cancelled there is no current match any more, however the caret is then moved - the
|
editor.vimIncsearchCurrentMatch = currentMatch
|
||||||
// result is simply where the caret is, and every match looks the same again (VIM-4308)
|
|
||||||
val currentMatchOffset = when {
|
|
||||||
!isIncsearch || !isCurrentEditor -> -1
|
|
||||||
incsearchMatchOffset != -1 -> incsearchMatchOffset
|
|
||||||
else -> findClosestMatch(highlightedMatches(editor), initialOffset, count1, forwards)
|
|
||||||
}
|
|
||||||
setCurrentSearchMatchHighlight(editor, currentMatchOffset)
|
|
||||||
|
|
||||||
// Remember where the incsearch match is, so that `c_CTRL-R_CTRL-W` can insert the word after it
|
|
||||||
editor.vimIncsearchCurrentMatchOffset = currentMatchOffset
|
|
||||||
|
|
||||||
if (isCurrentEditor) {
|
if (isCurrentEditor) {
|
||||||
currentEditorCurrentMatchOffset = currentMatchOffset
|
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
|
// `: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
|
if (searchStartLine > editor.lineCount() - 1) return
|
||||||
|
|
||||||
val results = injector.searchHelper.findAll(
|
editor.ij.vimSearchHighlights.ensureUpToDate(
|
||||||
editor,
|
|
||||||
pattern,
|
pattern,
|
||||||
|
shouldIgnoreCase(pattern, shouldIgnoreSmartCase),
|
||||||
searchStartLine,
|
searchStartLine,
|
||||||
searchEndLine,
|
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
|
* Find the match that the in-progress search would move to - Vim's `hl-CurSearch`, and what `c_CTRL-G`/`c_CTRL-T`
|
||||||
* pattern doesn't match.
|
* 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,
|
editor: VimEditor,
|
||||||
pattern: String,
|
pattern: String,
|
||||||
initialOffset: Int,
|
initialOffset: Int,
|
||||||
count1: Int,
|
count1: Int,
|
||||||
forwards: Boolean,
|
forwards: Boolean,
|
||||||
shouldIgnoreSmartCase: Boolean,
|
shouldIgnoreSmartCase: Boolean,
|
||||||
): Int {
|
): TextRange? {
|
||||||
val searchOptions = EnumSet.of(SearchOptions.WHOLE_FILE)
|
val searchOptions = EnumSet.of(SearchOptions.WHOLE_FILE)
|
||||||
if (injector.globalOptions().wrapscan) searchOptions.add(SearchOptions.WRAP)
|
if (injector.globalOptions().wrapscan) searchOptions.add(SearchOptions.WRAP)
|
||||||
if (shouldIgnoreSmartCase) searchOptions.add(SearchOptions.IGNORE_SMARTCASE)
|
if (shouldIgnoreSmartCase) searchOptions.add(SearchOptions.IGNORE_SMARTCASE)
|
||||||
if (!forwards) searchOptions.add(SearchOptions.BACKWARDS)
|
if (!forwards) searchOptions.add(SearchOptions.BACKWARDS)
|
||||||
|
|
||||||
val result = injector.searchHelper.findPattern(editor, pattern, initialOffset, count1, searchOptions) ?: return -1
|
return injector.searchHelper.findPattern(editor, pattern, initialOffset, count1, searchOptions)
|
||||||
|
|
||||||
// We don't show a highlight if Visual is active (behind Command-line, of course), because the Visual selection is
|
|
||||||
// enough. We still return the offset, so the caller can update the selection
|
|
||||||
if (!editor.inVisualMode && !editor.inCommandLineModeWithVisual) {
|
|
||||||
highlightSearchResults(editor.ij, pattern, listOf(result))
|
|
||||||
}
|
|
||||||
return result.startOffset
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateSearchCount(
|
fun updateSearchCount(
|
||||||
@@ -348,94 +342,18 @@ private fun shouldRemoveSearchHighlights(editor: Editor, newPattern: String?, hl
|
|||||||
|
|
||||||
private fun removeSearchHighlights(editor: Editor) {
|
private fun removeSearchHighlights(editor: Editor) {
|
||||||
editor.vimLastSearch = null
|
editor.vimLastSearch = null
|
||||||
val ehl = editor.vimLastHighlighters ?: return
|
editor.vimSearchHighlights.clear()
|
||||||
for (rh in ehl) {
|
|
||||||
editor.markupModel.removeHighlighter(rh)
|
|
||||||
}
|
|
||||||
editor.vimLastHighlighters = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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")
|
@Contract("_, false -> false; null, true -> false")
|
||||||
private fun shouldAddAllSearchHighlights(editor: Editor, newPattern: String?, hlSearch: Boolean): Boolean {
|
private fun shouldAddAllSearchHighlights(newPattern: String?, hlSearch: Boolean): Boolean {
|
||||||
return hlSearch && newPattern != null && newPattern != editor.vimLastSearch && newPattern != ""
|
return hlSearch && !newPattern.isNullOrEmpty()
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -448,60 +366,7 @@ private fun addSearchMatchHighlighter(editor: Editor, start: Int, end: Int, tool
|
|||||||
*/
|
*/
|
||||||
fun clearCurrentSearchMatchHighlight(editor: Editor) {
|
fun clearCurrentSearchMatchHighlight(editor: Editor) {
|
||||||
if (editor.isDisposed) return
|
if (editor.isDisposed) return
|
||||||
setCurrentSearchMatchHighlight(editor, editor.vimIncsearchCurrentMatchOffset ?: -1)
|
editor.vimSearchHighlights.setCurrentMatch(editor.vimIncsearchCurrentMatch)
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pass -1 to clear the current match highlight. Restyles the existing highlighters in place, so it does not repeat the
|
|
||||||
* search, and does nothing if the current match hasn't moved.
|
|
||||||
*/
|
|
||||||
private fun setCurrentSearchMatchHighlight(editor: Editor, currentMatchOffset: Int) {
|
|
||||||
val highlighters = editor.vimLastHighlighters ?: return
|
|
||||||
val previous = highlighters.firstOrNull { it.isVimCurrentSearchMatch }
|
|
||||||
val current = if (currentMatchOffset == -1) {
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
highlighters.firstOrNull { it.startOffset == currentMatchOffset }
|
|
||||||
}
|
|
||||||
if (previous === current) return
|
|
||||||
|
|
||||||
previous?.clearVimCurrentSearchMatch()
|
|
||||||
current?.setAsVimCurrentSearchMatch(editor)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True if this highlighter is for the current match, i.e. it is styled as Vim's `hl-CurSearch`. */
|
|
||||||
val RangeHighlighter.isVimCurrentSearchMatch: Boolean
|
|
||||||
get() = getUserData(CURRENT_SEARCH_MATCH) == true
|
|
||||||
|
|
||||||
/** Marks the highlighter of the current match, so we can find and restyle it as the caret moves. */
|
|
||||||
private val CURRENT_SEARCH_MATCH = Key.create<Boolean>("ideavim.search.currentMatch")
|
|
||||||
|
|
||||||
/** Apply the current match style - Vim's `hl-CurSearch`, as opposed to `hl-Search` for the other matches. */
|
|
||||||
private fun RangeHighlighter.setAsVimCurrentSearchMatch(editor: Editor) {
|
|
||||||
val highlighter = this as? RangeHighlighterEx ?: return
|
|
||||||
highlighter.setTextAttributes(currentSearchMatchAttributes(editor))
|
|
||||||
putUserData(CURRENT_SEARCH_MATCH, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Remove the current match style, falling back to the text attribute key the highlighter was created with. */
|
|
||||||
private fun RangeHighlighter.clearVimCurrentSearchMatch() {
|
|
||||||
val highlighter = this as? RangeHighlighterEx ?: return
|
|
||||||
highlighter.setTextAttributes(null)
|
|
||||||
putUserData(CURRENT_SEARCH_MATCH, null)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The attributes of the current match - Vim's `hl-CurSearch`. These are the same modifications that the Find live
|
|
||||||
* preview makes to the search result attributes.
|
|
||||||
*
|
|
||||||
* There is no text attribute key for the current match, so unlike the other matches, it won't follow a change to the
|
|
||||||
* editor's colour scheme until it's restyled by the next caret move or search.
|
|
||||||
*/
|
|
||||||
private fun currentSearchMatchAttributes(editor: Editor): TextAttributes {
|
|
||||||
return editor.colorsScheme.getAttributes(EditorColors.TEXT_SEARCH_RESULT_ATTRIBUTES).clone().apply {
|
|
||||||
effectType = EffectType.ROUNDED_BOX
|
|
||||||
effectColor = editor.colorsScheme.getColor(EditorColors.CARET_COLOR)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import com.intellij.openapi.editor.Caret
|
|||||||
import com.intellij.openapi.editor.Editor
|
import com.intellij.openapi.editor.Editor
|
||||||
import com.intellij.openapi.editor.RangeMarker
|
import com.intellij.openapi.editor.RangeMarker
|
||||||
import com.intellij.openapi.editor.VisualPosition
|
import com.intellij.openapi.editor.VisualPosition
|
||||||
import com.intellij.openapi.editor.markup.RangeHighlighter
|
|
||||||
import com.intellij.openapi.util.Key
|
import com.intellij.openapi.util.Key
|
||||||
import com.intellij.openapi.util.UserDataHolder
|
import com.intellij.openapi.util.UserDataHolder
|
||||||
import com.intellij.openapi.util.UserDataHolderBase
|
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.SelectionInfo
|
||||||
import com.maddyhome.idea.vim.api.VimOutputPanel
|
import com.maddyhome.idea.vim.api.VimOutputPanel
|
||||||
import com.maddyhome.idea.vim.common.InsertSequence
|
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.common.VimEditorReplaceMask
|
||||||
import com.maddyhome.idea.vim.group.visual.VisualChange
|
import com.maddyhome.idea.vim.group.visual.VisualChange
|
||||||
import com.maddyhome.idea.vim.group.visual.vimLeadSelectionOffset
|
import com.maddyhome.idea.vim.group.visual.vimLeadSelectionOffset
|
||||||
@@ -108,13 +108,20 @@ var Editor.vimInitialised: Boolean by userDataOr { false }
|
|||||||
fun unInitializeEditor(editor: Editor) {
|
fun unInitializeEditor(editor: Editor) {
|
||||||
editor.vimLastSelectionType = null
|
editor.vimLastSelectionType = null
|
||||||
editor.vimMorePanel = null
|
editor.vimMorePanel = null
|
||||||
editor.vimLastHighlighters = null
|
editor.vimSearchHighlights.clear()
|
||||||
editor.vimInitialised = false
|
editor.vimInitialised = false
|
||||||
}
|
}
|
||||||
|
|
||||||
var Editor.vimLastSearch: String? by userData()
|
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()
|
* @see :help visualmode()
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
package com.maddyhome.idea.vim.listener
|
package com.maddyhome.idea.vim.listener
|
||||||
|
|
||||||
import com.intellij.execution.impl.ConsoleViewImpl
|
|
||||||
import com.intellij.openapi.application.ApplicationManager
|
import com.intellij.openapi.application.ApplicationManager
|
||||||
import com.maddyhome.idea.vim.KeyHandler
|
import com.maddyhome.idea.vim.KeyHandler
|
||||||
import com.maddyhome.idea.vim.LastUsedEditorInfo
|
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)
|
* 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.
|
* 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)
|
* 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 {
|
class IJEditorFocusListener : EditorListener {
|
||||||
override fun focusGained(editor: VimEditor) {
|
override fun focusGained(editor: VimEditor) {
|
||||||
@@ -77,17 +78,30 @@ class IJEditorFocusListener : EditorListener {
|
|||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ApplicationManager.getApplication().invokeLater {
|
enterNormalModeInRunConsole(editor)
|
||||||
if (ijEditor.isDisposed) return@invokeLater
|
|
||||||
// The Python console keeps the user's current Vim mode on focus, so don't force Insert mode here either.
|
|
||||||
if (EditorHelper.isPythonConsole(ijEditor)) return@invokeLater
|
|
||||||
val consoleView: ConsoleViewImpl? = ijEditor.getUserData(ConsoleViewImpl.CONSOLE_VIEW_IN_EDITOR_VIEW)
|
|
||||||
if (consoleView != null && consoleView.isRunning && !ijEditor.inInsertMode) {
|
|
||||||
// Switch to Insert mode, but make sure we reset the editor to actually make it apply
|
|
||||||
switchToInsertMode.run()
|
|
||||||
KeyHandler.getInstance().reset(editor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
KeyHandler.getInstance().reset(editor)
|
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.EditorMouseMotionListener
|
||||||
import com.intellij.openapi.editor.event.SelectionEvent
|
import com.intellij.openapi.editor.event.SelectionEvent
|
||||||
import com.intellij.openapi.editor.event.SelectionListener
|
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.DocumentEx
|
||||||
|
import com.intellij.openapi.editor.ex.FoldingListener
|
||||||
import com.intellij.openapi.editor.ex.EditorEx
|
import com.intellij.openapi.editor.ex.EditorEx
|
||||||
import com.intellij.openapi.editor.ex.FocusChangeListener
|
import com.intellij.openapi.editor.ex.FocusChangeListener
|
||||||
import com.intellij.openapi.editor.impl.EditorComponentImpl
|
import com.intellij.openapi.editor.impl.EditorComponentImpl
|
||||||
@@ -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.VFileCreateEvent
|
||||||
import com.intellij.openapi.vfs.newvfs.events.VFileEvent
|
import com.intellij.openapi.vfs.newvfs.events.VFileEvent
|
||||||
import com.intellij.testFramework.LightVirtualFile
|
import com.intellij.testFramework.LightVirtualFile
|
||||||
|
import com.intellij.util.Alarm
|
||||||
import com.intellij.util.ExceptionUtil
|
import com.intellij.util.ExceptionUtil
|
||||||
import com.intellij.util.SlowOperations
|
import com.intellij.util.SlowOperations
|
||||||
import com.maddyhome.idea.vim.EventFacade
|
import com.maddyhome.idea.vim.EventFacade
|
||||||
@@ -85,6 +89,7 @@ import com.maddyhome.idea.vim.autocmd.IjFileTypeMapping
|
|||||||
import com.maddyhome.idea.vim.common.ModeChangeListener
|
import com.maddyhome.idea.vim.common.ModeChangeListener
|
||||||
import com.maddyhome.idea.vim.common.ModeWillChangeListener
|
import com.maddyhome.idea.vim.common.ModeWillChangeListener
|
||||||
import com.maddyhome.idea.vim.group.CommentsOptionInitializer
|
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.ControlCharsEditorHighlighter
|
||||||
import com.maddyhome.idea.vim.group.FileGroupHelper
|
import com.maddyhome.idea.vim.group.FileGroupHelper
|
||||||
import com.maddyhome.idea.vim.group.IjOptions
|
import com.maddyhome.idea.vim.group.IjOptions
|
||||||
@@ -94,7 +99,6 @@ import com.maddyhome.idea.vim.group.MarkUpdater
|
|||||||
import com.maddyhome.idea.vim.group.MotionGroup
|
import com.maddyhome.idea.vim.group.MotionGroup
|
||||||
import com.maddyhome.idea.vim.group.NumberChangeListener
|
import com.maddyhome.idea.vim.group.NumberChangeListener
|
||||||
import com.maddyhome.idea.vim.group.OptionGroup
|
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.ScrollOptionsChangeListener
|
||||||
import com.maddyhome.idea.vim.group.WindowJumpsChangeListener
|
import com.maddyhome.idea.vim.group.WindowJumpsChangeListener
|
||||||
import com.maddyhome.idea.vim.group.visual.IdeaSelectionControl
|
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.updateCaretsVisualAttributes
|
||||||
import com.maddyhome.idea.vim.helper.vimDisabled
|
import com.maddyhome.idea.vim.helper.vimDisabled
|
||||||
import com.maddyhome.idea.vim.helper.vimInitialised
|
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.noteCaretMoveInInsertSession
|
||||||
import com.maddyhome.idea.vim.key.resetAbbreviationSession
|
import com.maddyhome.idea.vim.key.resetAbbreviationSession
|
||||||
import com.maddyhome.idea.vim.newapi.IjVimEditor
|
import com.maddyhome.idea.vim.newapi.IjVimEditor
|
||||||
@@ -247,7 +252,7 @@ object VimListenerManager {
|
|||||||
optionGroup.addGlobalOptionChangeListener(Options.showmode, macroWidgetOptionListener)
|
optionGroup.addGlobalOptionChangeListener(Options.showmode, macroWidgetOptionListener)
|
||||||
optionGroup.addEffectiveOptionValueChangeListener(Options.keymap, KeymapChangeListener)
|
optionGroup.addEffectiveOptionValueChangeListener(Options.keymap, KeymapChangeListener)
|
||||||
optionGroup.addGlobalOptionChangeListener(Options.ideawindowjumps, WindowJumpsChangeListener)
|
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
|
// The listeners are registered _after_ ideavimrc has been evaluated, so trigger these listeners to ensure we're
|
||||||
// up to date
|
// up to date
|
||||||
@@ -297,7 +302,7 @@ object VimListenerManager {
|
|||||||
optionGroup.removeGlobalOptionChangeListener(Options.showmode, modeWidgetOptionListener)
|
optionGroup.removeGlobalOptionChangeListener(Options.showmode, modeWidgetOptionListener)
|
||||||
optionGroup.removeGlobalOptionChangeListener(Options.showmode, macroWidgetOptionListener)
|
optionGroup.removeGlobalOptionChangeListener(Options.showmode, macroWidgetOptionListener)
|
||||||
optionGroup.removeGlobalOptionChangeListener(Options.ideawindowjumps, WindowJumpsChangeListener)
|
optionGroup.removeGlobalOptionChangeListener(Options.ideawindowjumps, WindowJumpsChangeListener)
|
||||||
optionGroup.removeGlobalOptionChangeListener(IjOptions.ideapythonconsole, PythonConsoleOptionChangeListener)
|
optionGroup.removeGlobalOptionChangeListener(IjOptions.ideaeditor, ConsoleOptionChangeListener)
|
||||||
|
|
||||||
BufNewFileTracker.clear()
|
BufNewFileTracker.clear()
|
||||||
}
|
}
|
||||||
@@ -386,6 +391,13 @@ object VimListenerManager {
|
|||||||
eventFacade.addComponentMouseListener(editor.contentComponent, ComponentMouseListener, perEditorDisposable)
|
eventFacade.addComponentMouseListener(editor.contentComponent, ComponentMouseListener, perEditorDisposable)
|
||||||
eventFacade.addCaretListener(editor, EditorCaretHandler, 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))
|
injector.editorGroup.editorCreated(IjVimEditor(editor))
|
||||||
VimPlugin.getChange().editorCreated(IjVimEditor(editor), perEditorDisposable)
|
VimPlugin.getChange().editorCreated(IjVimEditor(editor), perEditorDisposable)
|
||||||
CommentsOptionInitializer.initializeForEditor(editor)
|
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.
|
* 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
|
* Consoles mark themselves only after creating their editor: `PythonConsoleView` sets `PYDEV_CONSOLE_KEY` on the
|
||||||
* `PythonConsoleView` marks the console's virtual file with `PYDEV_CONSOLE_KEY`. Consoles started by
|
* virtual file after `LanguageConsoleImpl`'s constructor has created the input editor, and
|
||||||
* "Run file in Python Console" are named after the run configuration rather than "Python Console" (see
|
* `ConsoleViewImpl.createConsoleEditor` sets `CONSOLE_VIEW_IN_EDITOR_VIEW` after `EditorFactory.createViewer` - the
|
||||||
* `PydevConsoleRunnerFactory.createConsoleRunnerWithFile`), so at [editorCreated] time there is nothing to identify
|
* very call that triggers [editorCreated]. Both are fully constructed within a single EDT runnable, so re-checking
|
||||||
* them by and [vimDisabled] rejects them. The console is fully constructed within a single EDT runnable, so
|
* in `invokeLater` sees the markers. Anything still unsupported on the second look is dropped, as before.
|
||||||
* re-checking in `invokeLater` sees the marker.
|
|
||||||
*
|
|
||||||
* Only light Python files are considered, to keep this from queueing work for every unsupported editor. Anything
|
|
||||||
* that still isn't supported on the second look (e.g. a Python code fragment in the debugger's evaluate window) is
|
|
||||||
* dropped, exactly as before.
|
|
||||||
*/
|
*/
|
||||||
private fun scheduleDeferredInitialisation(editor: Editor) {
|
private fun scheduleDeferredInitialisation(editor: Editor) {
|
||||||
if (editor.isDisposed) return
|
if (editor.isDisposed) return
|
||||||
val file = EditorHelper.getVirtualFile(editor) ?: return
|
if (!mayBecomeSupported(editor)) return
|
||||||
if (file !is LightVirtualFile || file.extension != PYTHON_EXTENSION) return
|
|
||||||
|
|
||||||
// Capture the opening editor now rather than in the callback - by then the selected editor might have changed
|
// Capture the opening editor now rather than in the callback - by then the selected editor might have changed
|
||||||
val openingEditor = getOpeningEditor(editor)
|
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) {
|
override fun editorReleased(event: EditorFactoryEvent) {
|
||||||
if (vimDisabled(event.editor)) return
|
if (vimDisabled(event.editor)) return
|
||||||
val vimEditor = event.editor.vim
|
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
|
* 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.
|
* 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.diagnostic.vimLogger
|
||||||
import com.maddyhome.idea.vim.group.XMLGroup
|
import com.maddyhome.idea.vim.group.XMLGroup
|
||||||
import com.maddyhome.idea.vim.helper.addSubstitutionConfirmationHighlight
|
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.clearCurrentSearchMatchHighlight
|
||||||
import com.maddyhome.idea.vim.helper.updateSearchCount
|
import com.maddyhome.idea.vim.helper.updateSearchCount
|
||||||
import com.maddyhome.idea.vim.helper.updateSearchHighlights
|
import com.maddyhome.idea.vim.helper.updateSearchHighlights
|
||||||
import com.maddyhome.idea.vim.helper.vimIncsearchCurrentMatchOffset
|
import com.maddyhome.idea.vim.helper.vimIncsearchCurrentMatch
|
||||||
import com.maddyhome.idea.vim.helper.vimLastHighlighters
|
import com.maddyhome.idea.vim.helper.vimSearchHighlights
|
||||||
import com.maddyhome.idea.vim.options.GlobalOptionChangeListener
|
import com.maddyhome.idea.vim.options.GlobalOptionChangeListener
|
||||||
import org.jdom.Element
|
import org.jdom.Element
|
||||||
import org.jetbrains.annotations.Contract
|
import org.jetbrains.annotations.Contract
|
||||||
@@ -69,21 +67,6 @@ open class IjVimSearchGroup : VimSearchGroupBase(), PersistentStateComponent<Ele
|
|||||||
|
|
||||||
private var showSearchHighlight: Boolean = injector.globalOptions().hlsearch
|
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) {
|
override fun updateSearchHighlights(force: Boolean) {
|
||||||
updateSearchHighlights(getLastUsedPattern(), lastIgnoreSmartCase, showSearchHighlight, force)
|
updateSearchHighlights(getLastUsedPattern(), lastIgnoreSmartCase, showSearchHighlight, force)
|
||||||
}
|
}
|
||||||
@@ -123,20 +106,14 @@ open class IjVimSearchGroup : VimSearchGroupBase(), PersistentStateComponent<Ele
|
|||||||
}
|
}
|
||||||
for (vimEditor in vimEditors) {
|
for (vimEditor in vimEditors) {
|
||||||
val editor = vimEditor.ij
|
val editor = vimEditor.ij
|
||||||
if (editor.vimLastHighlighters != null) {
|
if (editor.vimSearchHighlights.hasMatches()) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getCurrentIncsearchResultRange(editor: VimEditor): TextRange? {
|
override fun getCurrentIncsearchResultRange(editor: VimEditor): TextRange? = editor.ij.vimIncsearchCurrentMatch
|
||||||
val ijEditor = editor.ij
|
|
||||||
val incsearchHighlighters = ijEditor.vimLastHighlighters ?: return null
|
|
||||||
val currentOffset = ijEditor.vimIncsearchCurrentMatchOffset ?: return null
|
|
||||||
val currentHighlighter = incsearchHighlighters.find { it.startOffset == currentOffset }
|
|
||||||
return currentHighlighter?.textRange?.vim
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setShouldShowSearchHighlights() {
|
override fun setShouldShowSearchHighlights() {
|
||||||
showSearchHighlight = injector.globalOptions().hlsearch
|
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 {
|
class DocumentSearchListener @Contract(pure = true) private constructor() : DocumentListener {
|
||||||
override fun documentChanged(event: DocumentEvent) {
|
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
|
// 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
|
// 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.
|
// changed text. Make sure we only update local editors, though.
|
||||||
val document = event.document
|
for (vimEditor in injector.editorGroup.getEditors(IjVimDocument(event.document))) {
|
||||||
for (vimEditor in injector.editorGroup.getEditors(IjVimDocument(document))) {
|
|
||||||
val editor = vimEditor.ij
|
val editor = vimEditor.ij
|
||||||
var existingHighlighters = editor.vimLastHighlighters ?: continue
|
if (!editor.vimSearchHighlights.isActive) continue
|
||||||
|
|
||||||
if (logger.isDebug()) {
|
editor.vimSearchHighlights.refreshAfterDocumentChange()
|
||||||
logger.debug("hls=$existingHighlighters")
|
|
||||||
logger.debug("event=$event")
|
|
||||||
}
|
|
||||||
|
|
||||||
// We can only re-highlight whole lines, so clear any highlights in the affected lines.
|
// The rebuilt highlights are all normal matches. An edit never makes a match current - only an in-progress
|
||||||
// If we're deleting lines, this will clear + re-highlight the new current line, which hasn't been modified.
|
// 'incsearch' has one, and this puts its highlight back if the rebuild removed it
|
||||||
// However, we still want to re-highlight this line in case any highlights cross the line boundaries.
|
|
||||||
// If we're adding lines, this will clear + re-highlight all new lines.
|
|
||||||
val startPosition = editor.offsetToLogicalPosition(event.offset)
|
|
||||||
val endPosition = editor.offsetToLogicalPosition(event.offset + event.newLength)
|
|
||||||
val startLineOffset = document.getLineStartOffset(startPosition.line)
|
|
||||||
val endLineOffset = document.getLineEndOffset(endPosition.line)
|
|
||||||
|
|
||||||
// Remove any highlights that have already been deleted, and remove + clear those that intersect with the change
|
|
||||||
val iter = existingHighlighters.iterator()
|
|
||||||
while (iter.hasNext()) {
|
|
||||||
val highlighter = iter.next()
|
|
||||||
if (!highlighter.isValid) {
|
|
||||||
iter.remove()
|
|
||||||
} else if (highlighter.textRange.intersects(startLineOffset, endLineOffset)) {
|
|
||||||
iter.remove()
|
|
||||||
editor.markupModel.removeHighlighter(highlighter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
(injector.searchGroup as VimSearchGroupBase).highlightSearchLines(
|
|
||||||
editor.vim,
|
|
||||||
startPosition.line,
|
|
||||||
endPosition.line
|
|
||||||
)
|
|
||||||
|
|
||||||
// The re-highlighted lines are all added as normal matches. An edit never makes a match current - only an
|
|
||||||
// in-progress 'incsearch' has one, and this puts its highlight back if the edit removed it
|
|
||||||
clearCurrentSearchMatchHighlight(editor)
|
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,
|
null,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
actionGroup.add(
|
|
||||||
HelpLink(
|
|
||||||
"Take Survey ↗",
|
|
||||||
"https://jb.gg/axootm",
|
|
||||||
AllIcons.Actions.IntentionBulb,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
actionGroup.add(WhatsNewIdeaVimAction())
|
actionGroup.add(WhatsNewIdeaVimAction())
|
||||||
actionGroup.addSeparator(MessageHelper.message("widget.vim.actions.popup.eap.choice.active.text"))
|
actionGroup.addSeparator(MessageHelper.message("widget.vim.actions.popup.eap.choice.active.text"))
|
||||||
|
|
||||||
|
|||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2003-2023 The IdeaVim authors
|
||||||
|
*
|
||||||
|
* Use of this source code is governed by an MIT-style
|
||||||
|
* license that can be found in the LICENSE.txt file or at
|
||||||
|
* https://opensource.org/licenses/MIT.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.maddyhome.idea.vim.vimscript.model.functions.handlers
|
||||||
|
|
||||||
|
import com.intellij.refactoring.rename.inplace.InplaceRefactoring
|
||||||
|
import com.intellij.vim.annotations.VimscriptFunction
|
||||||
|
import com.maddyhome.idea.vim.api.ExecutionContext
|
||||||
|
import com.maddyhome.idea.vim.api.VimEditor
|
||||||
|
import com.maddyhome.idea.vim.newapi.ij
|
||||||
|
import com.maddyhome.idea.vim.vimscript.model.VimLContext
|
||||||
|
import com.maddyhome.idea.vim.vimscript.model.datatypes.VimInt
|
||||||
|
import com.maddyhome.idea.vim.vimscript.model.datatypes.asVimInt
|
||||||
|
import com.maddyhome.idea.vim.vimscript.model.functions.BuiltinFunctionHandler
|
||||||
|
|
||||||
|
@VimscriptFunction(name = "isinplacerenaming")
|
||||||
|
internal class IsInPlaceRenaming : BuiltinFunctionHandler<VimInt>() {
|
||||||
|
override fun doFunction(arguments: Arguments, editor: VimEditor, context: ExecutionContext, vimContext: VimLContext) =
|
||||||
|
(InplaceRefactoring.getActiveInplaceRenamer(editor.ij) != null).asVimInt()
|
||||||
|
}
|
||||||
@@ -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
|
// 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
|
// `$USER_HOME` as an unknown environment variable and replace it with an empty string
|
||||||
val withMacrosExpanded = PathMacroManager.getInstance(ApplicationManager.getApplication()).expandPath(trimmed)
|
val withMacrosExpanded = PathMacroManager.getInstance(ApplicationManager.getApplication()).expandPath(trimmed)
|
||||||
return Path(injector.pathExpansion.expandPath(withMacrosExpanded))
|
return Path(injector.pathExpansion.expandPath(withMacrosExpanded ?: trimmed))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"has": "com.maddyhome.idea.vim.vimscript.model.functions.handlers.HasFunctionHandler",
|
"has": "com.maddyhome.idea.vim.vimscript.model.functions.handlers.HasFunctionHandler",
|
||||||
"isactionenabled": "com.maddyhome.idea.vim.vimscript.model.functions.handlers.IsActionEnabled",
|
"isactionenabled": "com.maddyhome.idea.vim.vimscript.model.functions.handlers.IsActionEnabled",
|
||||||
|
"isinplacerenaming": "com.maddyhome.idea.vim.vimscript.model.functions.handlers.IsInPlaceRenaming",
|
||||||
"pumvisible": "com.maddyhome.idea.vim.vimscript.model.functions.handlers.PopupMenuVisibleFunctionHandler"
|
"pumvisible": "com.maddyhome.idea.vim.vimscript.model.functions.handlers.PopupMenuVisibleFunctionHandler"
|
||||||
}
|
}
|
||||||
+39
@@ -16,6 +16,7 @@ import com.maddyhome.idea.vim.api.keys
|
|||||||
import com.maddyhome.idea.vim.command.MappingMode
|
import com.maddyhome.idea.vim.command.MappingMode
|
||||||
import com.maddyhome.idea.vim.history.VimHistory
|
import com.maddyhome.idea.vim.history.VimHistory
|
||||||
import com.maddyhome.idea.vim.state.mode.Mode
|
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.ExceptionHandler
|
||||||
import org.jetbrains.plugins.ideavim.OnlyThrowLoggedErrorProcessor
|
import org.jetbrains.plugins.ideavim.OnlyThrowLoggedErrorProcessor
|
||||||
import org.jetbrains.plugins.ideavim.SkipNeovimReason
|
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
|
@Test
|
||||||
fun `test output of map shows correct modes after unmapping from vmap and map`() {
|
fun `test output of map shows correct modes after unmapping from vmap and map`() {
|
||||||
configureByText("\n")
|
configureByText("\n")
|
||||||
|
|||||||
@@ -8,11 +8,16 @@
|
|||||||
|
|
||||||
package org.jetbrains.plugins.ideavim.extension.nerdtree
|
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.application.ApplicationManager
|
||||||
import com.intellij.openapi.options.advanced.AdvancedSettings
|
import com.intellij.openapi.options.advanced.AdvancedSettings
|
||||||
import com.intellij.testFramework.PlatformTestUtil
|
import com.intellij.testFramework.PlatformTestUtil
|
||||||
import com.intellij.ui.treeStructure.Tree
|
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.armSelectionRestoreOnEscape
|
||||||
|
import com.maddyhome.idea.vim.extension.nerdtree.navigationMappings
|
||||||
import org.jetbrains.plugins.ideavim.VimTestCase
|
import org.jetbrains.plugins.ideavim.VimTestCase
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Test
|
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) {
|
private fun onEdt(block: () -> Unit) {
|
||||||
ApplicationManager.getApplication().invokeAndWait(block)
|
ApplicationManager.getApplication().invokeAndWait(block)
|
||||||
}
|
}
|
||||||
@@ -104,4 +174,31 @@ class NerdTreeTest : VimTestCase() {
|
|||||||
val event = KeyEvent(this, KeyEvent.KEY_PRESSED, System.currentTimeMillis(), 0, keyCode, keyChar)
|
val event = KeyEvent(this, KeyEvent.KEY_PRESSED, System.currentTimeMillis(), 0, keyCode, keyChar)
|
||||||
keyListeners.forEach { it.keyPressed(event) }
|
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
|
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.jetbrains.plugins.ideavim.VimTestCase
|
||||||
import org.junit.jupiter.api.Test
|
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])
|
* 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")
|
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 }
|
create(ideaType, ideaVersion) { this.useInstaller = false }
|
||||||
testFramework(TestFrameworkType.Platform)
|
testFramework(TestFrameworkType.Platform)
|
||||||
testFramework(TestFrameworkType.JUnit5)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import com.maddyhome.idea.vim.helper.CharacterHelper.charType
|
|||||||
import com.maddyhome.idea.vim.helper.NumberType
|
import com.maddyhome.idea.vim.helper.NumberType
|
||||||
import com.maddyhome.idea.vim.helper.StrictMode
|
import com.maddyhome.idea.vim.helper.StrictMode
|
||||||
import com.maddyhome.idea.vim.helper.endOffsetInclusive
|
import com.maddyhome.idea.vim.helper.endOffsetInclusive
|
||||||
|
import com.maddyhome.idea.vim.helper.isCaretAtLineEnd
|
||||||
import com.maddyhome.idea.vim.helper.usesVirtualSpace
|
import com.maddyhome.idea.vim.helper.usesVirtualSpace
|
||||||
import com.maddyhome.idea.vim.key.AbbreviationContext
|
import com.maddyhome.idea.vim.key.AbbreviationContext
|
||||||
import com.maddyhome.idea.vim.key.findAndResolveAbbreviation
|
import com.maddyhome.idea.vim.key.findAndResolveAbbreviation
|
||||||
@@ -100,8 +101,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
|
* 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
|
* is removed again when Insert mode is left. Only valid for the current insert session
|
||||||
*/
|
*/
|
||||||
@JvmField
|
protected var didAutoIndent: Boolean get() = false; set(_) = Unit
|
||||||
protected var didAutoIndent: Boolean = false
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The value [didAutoIndent] gets when the next insert session starts.
|
* The value [didAutoIndent] gets when the next insert session starts.
|
||||||
@@ -389,8 +389,10 @@ abstract class VimChangeGroupBase : VimChangeGroup {
|
|||||||
caret.moveToOffset(position)
|
caret.moveToOffset(position)
|
||||||
} else {
|
} else {
|
||||||
repeatInsertText(editor, context, count)
|
repeatInsertText(editor, context, count)
|
||||||
val position = injector.motion.getHorizontalMotion(editor, caret, -1, false)
|
if (!(editor.isCaretAtLineEnd(caret, true) && editor.charAt(caret.offset - 1).let { it == ' ' || it == '\t' })) {
|
||||||
caret.moveToMotion(position)
|
val position = injector.motion.getHorizontalMotion(editor, caret, -1, false)
|
||||||
|
caret.moveToMotion(position)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -447,22 +447,39 @@ abstract class VimMarkServiceBase : VimMarkService {
|
|||||||
newFragment: CharSequence,
|
newFragment: CharSequence,
|
||||||
) {
|
) {
|
||||||
val changeEndOffset = changeOffset + oldLength
|
val changeEndOffset = changeOffset + oldLength
|
||||||
val changeStartLine = editor.offsetToBufferPosition(changeOffset).line
|
val changeStart = editor.offsetToBufferPosition(changeOffset)
|
||||||
val oldEndLine = editor.offsetToBufferPosition(changeEndOffset).line
|
val changeEnd = editor.offsetToBufferPosition(changeEndOffset)
|
||||||
val newEndLine = changeStartLine + newFragment.count { it == '\n' }
|
val newLineCount = newFragment.count { it == '\n' }
|
||||||
if (oldEndLine == newEndLine) return
|
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()) {
|
for (caret in editor.carets()) {
|
||||||
val selectionInfo = caret.lastSelectionInfo
|
val selectionInfo = caret.lastSelectionInfo
|
||||||
|
|
||||||
val startPosition = selectionInfo.start
|
val startPosition = selectionInfo.start
|
||||||
val newStartPosition = startPosition?.let {
|
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 endPosition = selectionInfo.end
|
||||||
val newEndPosition = endPosition?.let {
|
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) {
|
if (newStartPosition != startPosition || newEndPosition != endPosition) {
|
||||||
@@ -478,21 +495,41 @@ abstract class VimMarkServiceBase : VimMarkService {
|
|||||||
position: BufferPosition,
|
position: BufferPosition,
|
||||||
changeOffset: Int,
|
changeOffset: Int,
|
||||||
changeEndOffset: Int,
|
changeEndOffset: Int,
|
||||||
oldEndLine: Int,
|
changeEnd: BufferPosition,
|
||||||
newEndLine: Int,
|
newEndLine: Int,
|
||||||
|
tailStartColumn: Int,
|
||||||
|
tailSurvives: Boolean,
|
||||||
): BufferPosition {
|
): BufferPosition {
|
||||||
val offset = editor.bufferPositionToOffset(position)
|
val offset = editor.bufferPositionToOffset(position)
|
||||||
return when {
|
return when {
|
||||||
offset < changeOffset -> position
|
offset < changeOffset -> position
|
||||||
// Note that text inserted at the mark (an empty range, i.e. changeOffset == changeEndOffset) pushes the mark
|
// 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
|
// down, while text replacing a range that starts at the mark leaves the mark at the start of the new text
|
||||||
offset >= changeEndOffset -> BufferPosition(
|
offset >= changeEndOffset -> if (position.line == changeEnd.line) {
|
||||||
position.line + (newEndLine - oldEndLine),
|
// The mark sits on the change's last line, so it moves with that line's tail onto the line the new text ends
|
||||||
position.column,
|
// 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.leansForward,
|
||||||
)
|
)
|
||||||
|
|
||||||
position.line > newEndLine -> BufferPosition(newEndLine, position.column, position.leansForward)
|
|
||||||
else -> position
|
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.
|
* 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.group.visual.VimSelection.Companion.create
|
||||||
import com.maddyhome.idea.vim.handler.ExternalActionHandler
|
import com.maddyhome.idea.vim.handler.ExternalActionHandler
|
||||||
import com.maddyhome.idea.vim.helper.VimNlsSafe
|
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.KeyHandlerState
|
||||||
import com.maddyhome.idea.vim.state.mode.Mode
|
import com.maddyhome.idea.vim.state.mode.Mode
|
||||||
import com.maddyhome.idea.vim.state.mode.SelectionType.CHARACTER_WISE
|
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)
|
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 {
|
override fun compareTo(other: MappingInfo): Int {
|
||||||
val size = fromKeys.size
|
val size = fromKeys.size
|
||||||
val otherSize = other.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()}" }
|
LOG.trace { "Adding new keys to keyStack as toKeys of mapping. State before adding keys: ${keyHandler.keyStack.dump()}" }
|
||||||
keyHandler.keyStack.addKeys(toKeys)
|
keyHandler.keyStack.addKeys(toKeys)
|
||||||
try {
|
try {
|
||||||
var first = true
|
withSelectModeAsVisual(editor, keyState) {
|
||||||
while (keyHandler.keyStack.hasStroke()) {
|
var first = true
|
||||||
val keySource = if (!isRecursive || (first && lhsIsPrefixOfRhs)) {
|
while (keyHandler.keyStack.hasStroke()) {
|
||||||
KeySource.MAPPED_NON_RECURSIVE
|
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 {
|
} finally {
|
||||||
keyHandler.keyStack.removeFirst()
|
keyHandler.keyStack.removeFirst()
|
||||||
|
|||||||
Reference in New Issue
Block a user