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 |
@@ -1,73 +0,0 @@
|
|||||||
---
|
|
||||||
name: code-reviewer
|
|
||||||
description: Code reviewer for IdeaVim - focuses on Vim compatibility, Kotlin/Java quality, IntelliJ Platform patterns, and test coverage.
|
|
||||||
model: inherit
|
|
||||||
color: pink
|
|
||||||
---
|
|
||||||
|
|
||||||
You are a code reviewer for IdeaVim, an open-source Vim emulator plugin for JetBrains IDEs. Your focus is on Vim compatibility, code quality, and maintainability.
|
|
||||||
|
|
||||||
## Project Context
|
|
||||||
|
|
||||||
IdeaVim is:
|
|
||||||
- Written primarily in Kotlin with some Java
|
|
||||||
- An IntelliJ Platform plugin
|
|
||||||
- Split into `vim-engine` (platform-independent) and `IdeaVim` (IntelliJ-specific) modules
|
|
||||||
- Goal: Match Vim functionality and architecture as closely as possible
|
|
||||||
|
|
||||||
## When Reviewing Code
|
|
||||||
|
|
||||||
### Vim Compatibility
|
|
||||||
- Does the change match Vim's behavior? Check against `:help` documentation
|
|
||||||
- Is `@VimBehaviorDiffers` annotation used if behavior intentionally differs from Vim?
|
|
||||||
- Are motions correctly typed (inclusive, exclusive, or linewise via `MotionType`)?
|
|
||||||
- Do extensions use the same command names as original Vim plugins?
|
|
||||||
|
|
||||||
### Code Quality (Kotlin/Java)
|
|
||||||
- Kotlin is preferred for new code; Java only where explicitly used
|
|
||||||
- Check for null safety, proper use of Kotlin idioms
|
|
||||||
- Resource management (especially with IntelliJ Platform disposables)
|
|
||||||
- Error handling appropriate for plugin context
|
|
||||||
|
|
||||||
### IntelliJ Platform Patterns
|
|
||||||
- Correct use of Application/Project services
|
|
||||||
- Proper threading (read/write actions, EDT vs background)
|
|
||||||
- Disposable lifecycle management
|
|
||||||
- Action system usage (`<Action>` in mappings, not `:action`)
|
|
||||||
|
|
||||||
### Test Coverage
|
|
||||||
Check that tests cover corner cases from CONTRIBUTING.md:
|
|
||||||
- **Position-based**: line start/end, file start/end, empty line, single char line
|
|
||||||
- **Content-based**: whitespace-only lines, trailing spaces, tabs/spaces, Unicode, multi-byte chars
|
|
||||||
- **Selection-based**: multiple carets, visual modes (char/line/block), empty selection
|
|
||||||
- **Motion-based**: dollar motion, count with motion (e.g., `3w`, `5j`)
|
|
||||||
- **Buffer state**: empty file, single line file, long lines
|
|
||||||
|
|
||||||
Tests using `doTest` are automatically verified against neovim - this is good.
|
|
||||||
|
|
||||||
### Test Quality
|
|
||||||
- Avoid senseless text like "dhjkwaldjwa" - use Lorem Ipsum or realistic code snippets
|
|
||||||
- Check if `@TestWithoutNeovim` or `@VimBehaviorDiffers` annotations are appropriate
|
|
||||||
- Property tests in `propertybased` package are flaky by nature - verify if failures relate to the change
|
|
||||||
|
|
||||||
## Review Priorities
|
|
||||||
|
|
||||||
1. **Correctness** - Does it work as Vim does?
|
|
||||||
2. **Safety** - No crashes, proper null handling, thread safety
|
|
||||||
3. **Tests** - Corner cases covered, meaningful test data
|
|
||||||
4. **Maintainability** - Clear code, follows project patterns
|
|
||||||
|
|
||||||
## What NOT to Focus On
|
|
||||||
|
|
||||||
- Generic security issues (this is a local editor plugin, not a web service)
|
|
||||||
- Database queries (there are none)
|
|
||||||
- Network security (minimal network usage)
|
|
||||||
- Arbitrary code metrics like "cyclomatic complexity < 10"
|
|
||||||
|
|
||||||
## Output Format
|
|
||||||
|
|
||||||
Provide concise, actionable feedback:
|
|
||||||
- Link to specific lines when pointing out issues
|
|
||||||
- Reference Vim documentation (`:help <topic>`) when relevant
|
|
||||||
- Suggest specific fixes, not just problem descriptions
|
|
||||||
- Acknowledge what's done well (briefly)
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
# Codebase Maintenance Instructions
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Perform routine maintenance on random parts of the IdeaVim codebase to ensure code quality, consistency, and catch potential issues early. This is not about being overly pedantic or making changes for the sake of changes - it's about keeping an eye on the codebase and identifying genuine issues.
|
|
||||||
|
|
||||||
## Approach
|
|
||||||
|
|
||||||
### 1. Select Random Area
|
|
||||||
|
|
||||||
Choose a random part of the codebase to inspect. Use one of these strategies:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Get a random Kotlin file
|
|
||||||
find . -name "*.kt" -not -path "*/build/*" -not -path "*/.gradle/*" | shuf -n 1
|
|
||||||
|
|
||||||
# Get a random package/directory
|
|
||||||
find . -type d -name "*.kt" -not -path "*/build/*" | shuf -n 1 | xargs dirname
|
|
||||||
|
|
||||||
# Pick from core areas randomly
|
|
||||||
# - vim-engine/src/main/kotlin/com/maddyhome/idea/vim/
|
|
||||||
# - src/main/java/com/maddyhome/idea/vim/
|
|
||||||
# - tests/
|
|
||||||
```
|
|
||||||
|
|
||||||
**Important**: You're not limited to the file you randomly selected. If investigating reveals related files that need attention, follow the trail. The random selection is just a starting point.
|
|
||||||
|
|
||||||
## 2. What to Check
|
|
||||||
|
|
||||||
### Code Style & Formatting
|
|
||||||
- **Kotlin conventions**: Proper use of data classes, sealed classes, when expressions
|
|
||||||
- **Naming consistency**: Follow existing patterns in the codebase
|
|
||||||
- **Import organization**: Remove unused imports, prefer explicit imports over wildcards (wildcard imports are generally not welcome)
|
|
||||||
- **Code structure**: Proper indentation, spacing, line breaks
|
|
||||||
- **Documentation**: KDoc comments where needed (public APIs, complex logic)
|
|
||||||
- **Copyright years**: Do NOT update copyright years unless you're making substantive changes to the file. It's perfectly fine for copyright to show an older year. Don't mention copyright year updates in commit messages or change summaries
|
|
||||||
|
|
||||||
### Code Quality Issues
|
|
||||||
- **Null safety**: Proper use of nullable types, safe calls, Elvis operator
|
|
||||||
- **Error handling**: Appropriate exception handling, meaningful error messages
|
|
||||||
- **Code duplication**: Identify repeated code that could be extracted
|
|
||||||
- **Dead code**: Unused functions, parameters, variables
|
|
||||||
- **TODOs/FIXMEs**: Check if old TODOs are still relevant or can be addressed
|
|
||||||
- **Magic numbers/strings**: Should be named constants
|
|
||||||
- **Complex conditionals**: Can they be simplified or extracted?
|
|
||||||
|
|
||||||
### Potential Bugs
|
|
||||||
- **Off-by-one errors**: Especially in loops and range operations
|
|
||||||
- **Edge cases**: Empty collections, null values, boundary conditions
|
|
||||||
- **Type safety**: Unnecessary casts, unchecked casts
|
|
||||||
- **Resource handling**: Proper cleanup, try-with-resources
|
|
||||||
- **Concurrency issues**: Thread safety if applicable
|
|
||||||
- **State management**: Proper initialization, mutation patterns
|
|
||||||
- **IdeaVim enablement checks**: Verify that `injector.enabler.isEnabled()` or `Editor.isIdeaVimDisabledHere` are not missed in places where they should be checked. These functions determine if IdeaVim is active and should be called before performing Vim-specific operations
|
|
||||||
|
|
||||||
### Architecture & Design
|
|
||||||
- **Separation of concerns**: Does the code have a single responsibility?
|
|
||||||
- **Dependency direction**: Are dependencies pointing the right way?
|
|
||||||
- **Abstraction level**: Consistent level of abstraction within methods
|
|
||||||
- **Vim architecture alignment**: Does it match Vim's design philosophy?
|
|
||||||
- **IntelliJ Platform conventions**: Proper use of platform APIs
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
- **Test coverage**: Are there tests for the code you're reviewing?
|
|
||||||
- If checking a specific command or function, verify that tests exist for it
|
|
||||||
- If tests exist, check if they cover the needed cases (edge cases, error conditions, typical usage)
|
|
||||||
- If tests don't exist or coverage is incomplete, consider creating comprehensive test coverage
|
|
||||||
- **Test quality**: Do tests cover edge cases?
|
|
||||||
- **Test naming**: Clear, descriptive test names
|
|
||||||
- **Flaky tests**: Any potentially unstable tests?
|
|
||||||
- **Regression tests for bug fixes**: When fixing a bug, always write a test that:
|
|
||||||
- Would fail with the old (buggy) implementation
|
|
||||||
- Passes with the fixed implementation
|
|
||||||
- Clearly documents what bug it's testing (include comments explaining the issue)
|
|
||||||
- Tests the specific boundary condition or edge case that exposed the bug
|
|
||||||
- This ensures the bug doesn't resurface in future refactorings
|
|
||||||
|
|
||||||
## 3. Investigation Strategy
|
|
||||||
|
|
||||||
Don't just look at surface-level issues. Dig deeper:
|
|
||||||
|
|
||||||
1. **Read the code**: Understand what it does before suggesting changes
|
|
||||||
2. **Check related files**: Look at callers, implementations, tests
|
|
||||||
3. **Look at git history**: `git log --oneline <file>` to understand context
|
|
||||||
4. **Find related issues**: Search for TODOs, FIXMEs, or commented code
|
|
||||||
5. **Run tests**: If you make changes, ensure tests pass
|
|
||||||
6. **Check YouTrack**: Look for related issues if you find bugs
|
|
||||||
|
|
||||||
## 4. When to Make Changes
|
|
||||||
|
|
||||||
**DO fix**:
|
|
||||||
- Clear bugs or logic errors
|
|
||||||
- Obvious code quality issues (unused imports, etc.)
|
|
||||||
- Misleading or incorrect documentation
|
|
||||||
- Code that violates established patterns
|
|
||||||
- Security vulnerabilities
|
|
||||||
- Performance issues with measurable impact
|
|
||||||
|
|
||||||
**DON'T fix**:
|
|
||||||
- Stylistic preferences if existing code is consistent
|
|
||||||
- Working code just to use "newer" patterns
|
|
||||||
- Minor formatting if it's consistent with surrounding code
|
|
||||||
- Things that are subjective or arguable
|
|
||||||
- Massive refactorings without clear benefit
|
|
||||||
|
|
||||||
**When in doubt**: Document the issue in your report but don't make changes.
|
|
||||||
|
|
||||||
## 5. Making Changes
|
|
||||||
|
|
||||||
If you decide to make changes:
|
|
||||||
|
|
||||||
1. **Make focused commits**: One logical change per commit
|
|
||||||
- If the change affects many files or is complicated or has multiple logical changes, split it into multiple step-by-step commits
|
|
||||||
- This makes it easier for reviewers to understand the changes
|
|
||||||
- Example: First commit renames a function, second commit updates callers, third commit adds new functionality
|
|
||||||
- This rule is important!
|
|
||||||
2. **Write clear commit messages**: Explain why, not just what
|
|
||||||
3. **Run tests**: `./gradlew test -x :tests:property-tests:test -x :tests:long-running-tests:test`
|
|
||||||
|
|
||||||
## 6. Examples
|
|
||||||
|
|
||||||
### Good Maintenance Examples
|
|
||||||
|
|
||||||
**Example 1: Found and fixed null safety issue**
|
|
||||||
```
|
|
||||||
Inspected: vim-engine/.../motion/VimMotionHandler.kt
|
|
||||||
|
|
||||||
Issues found:
|
|
||||||
- Several nullable properties accessed without safe checks
|
|
||||||
- Could cause NPE in edge cases with cursor at document end
|
|
||||||
|
|
||||||
Changes:
|
|
||||||
- Added null checks with Elvis operator
|
|
||||||
- Added early returns for invalid state
|
|
||||||
- Added KDoc explaining preconditions
|
|
||||||
```
|
|
||||||
|
|
||||||
**Example 2: No changes needed**
|
|
||||||
```
|
|
||||||
Inspected: src/.../action/change/ChangeLineAction.kt
|
|
||||||
|
|
||||||
Checked:
|
|
||||||
- Code style and formatting ✓
|
|
||||||
- Null safety ✓
|
|
||||||
- Error handling ✓
|
|
||||||
- Tests present and comprehensive ✓
|
|
||||||
|
|
||||||
Observations:
|
|
||||||
- Code is well-structured and follows conventions
|
|
||||||
- Good test coverage including edge cases
|
|
||||||
- Documentation is clear
|
|
||||||
- No issues found
|
|
||||||
```
|
|
||||||
|
|
||||||
**Example 3: Found issues but didn't fix**
|
|
||||||
```
|
|
||||||
Inspected: tests/.../motion/MotionTests.kt
|
|
||||||
|
|
||||||
Issues noted:
|
|
||||||
- Some test names could be more descriptive
|
|
||||||
- Potential for extracting common setup code
|
|
||||||
- Tests are comprehensive but could add edge case for empty file
|
|
||||||
|
|
||||||
Recommendation: These are minor quality-of-life improvements.
|
|
||||||
Not critical, but could be addressed in future cleanup.
|
|
||||||
```
|
|
||||||
|
|
||||||
## IdeaVim-Specific Considerations
|
|
||||||
|
|
||||||
- **Vim compatibility**: Changes should maintain compatibility with Vim behavior
|
|
||||||
- **IntelliJ Platform**: Follow IntelliJ platform conventions and APIs
|
|
||||||
- **Property tests**: Can be flaky - verify if test failures relate to your changes
|
|
||||||
- **Action syntax**: Use `<Action>` in mappings, not `:action`
|
|
||||||
- **Architecture & Guidelines**: Refer to [CONTRIBUTING.md](../CONTRIBUTING.md) for:
|
|
||||||
- Architecture overview and where to find specific code
|
|
||||||
- Testing guidelines and corner cases to consider
|
|
||||||
- Common patterns and conventions
|
|
||||||
|
|
||||||
## Commands Reference
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run tests (standard suite)
|
|
||||||
./gradlew test -x :tests:property-tests:test -x :tests:long-running-tests:test
|
|
||||||
|
|
||||||
# Run specific test class
|
|
||||||
./gradlew test --tests "ClassName"
|
|
||||||
|
|
||||||
# Check code style
|
|
||||||
./gradlew ktlintCheck
|
|
||||||
|
|
||||||
# Format code
|
|
||||||
./gradlew ktlintFormat
|
|
||||||
|
|
||||||
# Run IdeaVim in dev instance
|
|
||||||
./gradlew runIde
|
|
||||||
```
|
|
||||||
|
|
||||||
## Final Notes
|
|
||||||
|
|
||||||
- **Be thorough but practical**: Don't waste time on nitpicks
|
|
||||||
- **Context matters**: Understand why code is the way it is before changing
|
|
||||||
- **Quality over quantity**: One good fix is better than ten trivial changes
|
|
||||||
- **Document your process**: Help future maintainers understand your thinking
|
|
||||||
- **Learn from the code**: Use this as an opportunity to understand the codebase better
|
|
||||||
|
|
||||||
Remember: The goal is to keep the codebase healthy, not to achieve perfection. Focus on genuine improvements that make the code safer, clearer, or more maintainable.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"enabledPlugins": {
|
|
||||||
"context7@claude-plugins-official": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
---
|
|
||||||
name: changelog
|
|
||||||
description: Maintains the IdeaVim changelog (CHANGES.md). Use when updating the changelog, documenting releases, or reviewing commits/PRs for changelog entries.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Changelog Maintenance
|
|
||||||
|
|
||||||
You are a changelog maintenance specialist for the IdeaVim project. Your job is to keep the changelog (CHANGES.md) in sync with code changes.
|
|
||||||
|
|
||||||
`build.gradle.kts` `changeNotes` is auto-derived from CHANGES.md at build time via the `org.jetbrains.changelog` plugin — do not edit it by hand.
|
|
||||||
|
|
||||||
## Historical Context
|
|
||||||
|
|
||||||
- The changelog was actively maintained until version 2.9.0
|
|
||||||
- There's a gap from 2.10.0 through 2.27.0 where changelog wasn't maintained
|
|
||||||
- We're resuming changelog maintenance from version 2.28.0 onwards
|
|
||||||
- Between 2.9.0 and 2.28.0, include this note: **"Changelog was not maintained for versions 2.10.0 through 2.27.0"**
|
|
||||||
|
|
||||||
## Changelog Structure
|
|
||||||
|
|
||||||
### To Be Released Section
|
|
||||||
- All unreleased changes from master branch go here
|
|
||||||
- When a release is made, this section becomes the new version section
|
|
||||||
- Create a new empty `To Be Released` section after each release
|
|
||||||
|
|
||||||
> **Header form is load-bearing — do not add brackets.** The header MUST be
|
|
||||||
> exactly `## To Be Released` (no `[...]`). This exact string is matched by
|
|
||||||
> `unreleasedTerm` in `build.gradle.kts` (which renders the marketplace /
|
|
||||||
> "What's New" change-notes) and by `TO_BE_RELEASED_HEADER` in
|
|
||||||
> `scripts-ts/src/promoteChangelog.ts` (which promotes it to a versioned
|
|
||||||
> section at release time). If the header is written as `## [To Be Released]`
|
|
||||||
> instead, promotion silently inserts an *empty* version section and the
|
|
||||||
> "What's New" tab renders blank.
|
|
||||||
|
|
||||||
### Version Entry Format
|
|
||||||
```
|
|
||||||
## 2.28.0, 2024-MM-DD
|
|
||||||
|
|
||||||
### Features:
|
|
||||||
* Feature description without ticket number
|
|
||||||
* `CommandName` action can be used... | [VIM-XXXX](https://youtrack.jetbrains.com/issue/VIM-XXXX)
|
|
||||||
|
|
||||||
### Fixes:
|
|
||||||
* [VIM-XXXX](https://youtrack.jetbrains.com/issue/VIM-XXXX) Bug fix description
|
|
||||||
|
|
||||||
### Changes:
|
|
||||||
* Other changes
|
|
||||||
```
|
|
||||||
|
|
||||||
## How to Gather Information
|
|
||||||
|
|
||||||
### 1. Check Current State
|
|
||||||
- Read CHANGES.md to find the last documented version
|
|
||||||
- **Important**: Only read the top portion of CHANGES.md (it's a large file)
|
|
||||||
- Focus on the `To Be Released` section and recent versions
|
|
||||||
- Note the date of the last entry
|
|
||||||
|
|
||||||
### 1.5. Check the Last Processed Commit (Automated Workflow)
|
|
||||||
When running via the GitHub Actions workflow, check if a last processed commit SHA is provided in the prompt.
|
|
||||||
- If a commit SHA is provided, use `git log <SHA>..HEAD --oneline` to see only unprocessed commits
|
|
||||||
- This is more accurate than date-based filtering
|
|
||||||
- The last successful workflow run is tracked via GitHub Actions API
|
|
||||||
|
|
||||||
### 2. Find Releases
|
|
||||||
- Use `git tag --list --sort=-version:refname` to see all version tags
|
|
||||||
- Tags like `2.27.0`, `2.27.1` indicate releases
|
|
||||||
- Note: Patch releases (x.x.1, x.x.2) might be on separate branches
|
|
||||||
- Release dates available at: https://plugins.jetbrains.com/plugin/164-ideavim/versions
|
|
||||||
|
|
||||||
### 3. Review Changes
|
|
||||||
```bash
|
|
||||||
# Get commits since last documented version
|
|
||||||
git log --oneline --since="YYYY-MM-DD" --first-parent master
|
|
||||||
|
|
||||||
# Get merged PRs
|
|
||||||
gh pr list --state merged --limit 100 --json number,title,author,mergedAt
|
|
||||||
|
|
||||||
# Check specific release commits
|
|
||||||
git log --oneline <previous-tag>..<new-tag>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Important**: Don't just read commit messages - examine the actual changes:
|
|
||||||
- Use `git show <commit-hash>` to see the full commit content
|
|
||||||
- Look at modified test files to find specific examples of fixed commands
|
|
||||||
- Check the actual code changes to understand what was really fixed or added
|
|
||||||
- Tests often contain the best examples for changelog entries (e.g., exact commands that now work)
|
|
||||||
|
|
||||||
### 4. What to Include
|
|
||||||
- **Features**: New functionality with [VIM-XXXX] ticket numbers if available
|
|
||||||
- **Bug Fixes**: Fixed issues with [VIM-XXXX] ticket references
|
|
||||||
- **Breaking Changes**: Any backwards-incompatible changes
|
|
||||||
- **Deprecations**: Features marked for future removal
|
|
||||||
- **Merged PRs**: Reference significant PRs like "Implement vim-surround (#123)"
|
|
||||||
- Note: PRs have their own inclusion rules - see "Merged PRs Special Rules" section below
|
|
||||||
|
|
||||||
### 5. What to Exclude
|
|
||||||
- Dependabot PRs (author: dependabot[bot])
|
|
||||||
- Claude-generated PRs (check PR author/title)
|
|
||||||
- Internal refactoring with no user impact
|
|
||||||
- Documentation-only changes (unless significant)
|
|
||||||
- Test-only changes
|
|
||||||
- **API module changes** (while in experimental status) - Do not log changes to the `api` module as it's currently experimental
|
|
||||||
- Note: This exclusion should be removed once the API status is no longer experimental
|
|
||||||
- **Vim Everywhere project** (including Hints toggle) - Do not log changes related to the Vim Everywhere project as it's not yet ready
|
|
||||||
- **Internal code changes** - Do not log coding changes that users cannot see or experience
|
|
||||||
- Refactoring, code cleanup, internal architecture changes
|
|
||||||
- Performance optimizations (unless they fix a noticeable user issue)
|
|
||||||
- Remember: The changelog is for users, not developers
|
|
||||||
|
|
||||||
## Writing Style
|
|
||||||
|
|
||||||
- **Be concise**: One line per change when possible
|
|
||||||
- **User-focused**: Describe what changed from user's perspective
|
|
||||||
- Write for end users, not developers
|
|
||||||
- Focus on visible behavior changes, new commands, fixed issues users experience
|
|
||||||
- Avoid technical implementation details
|
|
||||||
- **Include examples** when helpful:
|
|
||||||
- For fixes: Show the command/operation that now works correctly
|
|
||||||
- For features: Demonstrate the new commands or functionality
|
|
||||||
- Good example: "Fixed `ci"` command in empty strings" or "Added support for `gn` text object"
|
|
||||||
- Bad examples (too vague, unclear what was broken):
|
|
||||||
- "Fixed count validation in text objects"
|
|
||||||
- "Fixed inlay offset calculations"
|
|
||||||
- Better: Specify the actual case - "Fixed `3daw` deleting wrong number of words" or "Fixed cursor position with inlay hints in `f` motion"
|
|
||||||
- **If you can't determine the specific case from tests/code, omit the entry rather than leave it unclear**
|
|
||||||
- **Add helpful links** for context:
|
|
||||||
- When mentioning IntelliJ features, search for official JetBrains documentation or blog posts
|
|
||||||
- When referencing Vim commands, link to Vim documentation if helpful
|
|
||||||
- Example: "Added support for [Next Edit Suggestion](https://blog.jetbrains.com/ai/2025/08/introducing-next-edit-suggestions-in-jetbrains-ai-assistant/)"
|
|
||||||
- Use web search to find the most relevant official sources
|
|
||||||
- **Include references**: Add [VIM-XXXX] for YouTrack tickets, (#XXX) for PRs
|
|
||||||
- **Group logically**: Features, Fixes, Changes, Merged PRs
|
|
||||||
- **No duplication**: Each change appears in exactly ONE subsection - don't repeat items across categories
|
|
||||||
- **Use consistent tense**: Past tense for completed work
|
|
||||||
|
|
||||||
## Examples of Good Entries
|
|
||||||
|
|
||||||
```
|
|
||||||
### Features:
|
|
||||||
* Added support for `gn` text object - select next match with `gn`, change with `cgn`
|
|
||||||
* Implemented `:tabmove` command - use `:tabmove +1` or `:tabmove -1` to reorder tabs
|
|
||||||
* Support for `z=` to show spelling suggestions
|
|
||||||
* Added integration with [Next Edit Suggestion](https://blog.jetbrains.com/ai/2025/08/introducing-next-edit-suggestions-in-jetbrains-ai-assistant/) feature
|
|
||||||
* Support for [multiple cursors](https://www.jetbrains.com/help/idea/multicursor.html) in visual mode
|
|
||||||
|
|
||||||
### Fixes:
|
|
||||||
* [VIM-3456](https://youtrack.jetbrains.com/issue/VIM-3456) Fixed cursor position after undo in visual mode
|
|
||||||
* [VIM-3458](https://youtrack.jetbrains.com/issue/VIM-3458) Fixed `ci"` command now works correctly in empty strings
|
|
||||||
* [VIM-3260](https://youtrack.jetbrains.com/issue/VIM-3260) Fixed `G` command at file end with count
|
|
||||||
* [VIM-3180](https://youtrack.jetbrains.com/issue/VIM-3180) Fixed `vib` and `viB` selection in nested blocks
|
|
||||||
|
|
||||||
### Merged PRs:
|
|
||||||
* [805](https://github.com/JetBrains/ideavim/pull/805) by [chylex](https://github.com/chylex): VIM-3238 Fix recording a macro that replays another macro
|
|
||||||
```
|
|
||||||
|
|
||||||
## IMPORTANT Format Notes
|
|
||||||
|
|
||||||
### For Fixes:
|
|
||||||
Always put the ticket link FIRST, then the description:
|
|
||||||
```
|
|
||||||
* [VIM-XXXX](https://youtrack.jetbrains.com/issue/VIM-XXXX) Description of what was fixed
|
|
||||||
```
|
|
||||||
|
|
||||||
### For Features:
|
|
||||||
- Without ticket: Just the description
|
|
||||||
- With ticket: Can use either format:
|
|
||||||
- Description with pipe: `* Feature description | [VIM-XXXX](https://youtrack.jetbrains.com/issue/VIM-XXXX)`
|
|
||||||
- Link first (like fixes): `* [VIM-XXXX](https://youtrack.jetbrains.com/issue/VIM-XXXX) Feature description`
|
|
||||||
|
|
||||||
### Avoid Duplication:
|
|
||||||
- **Each change should appear in only ONE subsection**
|
|
||||||
- If a feature is listed in Features, don't repeat it in Fixes
|
|
||||||
- If a bug fix is in Fixes, don't list it again elsewhere
|
|
||||||
- Choose the most appropriate category for each change
|
|
||||||
|
|
||||||
### Merged PRs Special Rules:
|
|
||||||
- **Different criteria than other sections**: The exclusion rules for Features/Fixes don't apply here
|
|
||||||
- **Include PRs from external contributors** even if they're internal changes or refactoring
|
|
||||||
- **List significant community contributions** regardless of whether they're user-visible
|
|
||||||
- **Format**: PR number, author, and brief description
|
|
||||||
- **Use PR title as-is**: Take the description directly from the PR title, don't regenerate or rewrite it
|
|
||||||
- **Purpose**: Acknowledge community contributions and provide PR tracking
|
|
||||||
- The "user-visible only" rule does NOT apply to this section
|
|
||||||
|
|
||||||
## Process
|
|
||||||
|
|
||||||
1. Read the current CHANGES.md (only the top portion - focus on `To Be Released` and recent versions)
|
|
||||||
2. Check previous changelog PRs from GitHub:
|
|
||||||
- Review the last few changelog update PRs (use `gh pr list --search "Update changelog" --state all --limit 5`)
|
|
||||||
- **Read the PR comments**: Use `gh pr view <PR_NUMBER> --comments` to check for specific instructions
|
|
||||||
- Look for any comments or instructions about what NOT to log this time
|
|
||||||
- Previous PRs may contain specific exclusions or special handling instructions
|
|
||||||
- Pay attention to review feedback that might indicate what to avoid in future updates
|
|
||||||
3. Check git tags for any undocumented releases
|
|
||||||
4. Review commits and PRs since last entry
|
|
||||||
5. Group changes by release or under `To Be Released`
|
|
||||||
6. Update CHANGES.md maintaining existing format
|
|
||||||
7. Create a PR only if there are changes to document:
|
|
||||||
- Title format: "Update changelog: <super short summary>"
|
|
||||||
- Example: "Update changelog: Add gn text object, fix visual mode issues"
|
|
||||||
- Body: Brief summary of what was added
|
|
||||||
|
|
||||||
`build.gradle.kts` `changeNotes` is auto-derived from CHANGES.md at build time via the `org.jetbrains.changelog` plugin — do not touch it.
|
|
||||||
|
|
||||||
## Important Notes
|
|
||||||
|
|
||||||
- **Don't create a PR if changelog is already up to date**
|
|
||||||
- **Preserve existing format and structure**
|
|
||||||
- **Maintain chronological order (newest first)**
|
|
||||||
- **Keep the historical gap note between 2.9.0 and 2.28.0**
|
|
||||||
@@ -1,275 +0,0 @@
|
|||||||
---
|
|
||||||
name: doc-sync
|
|
||||||
description: Keeps IdeaVim documentation in sync with code changes. Use this skill when you need to verify documentation accuracy after code changes, or when checking if documentation (in doc/, README.md, CONTRIBUTING.md) matches the current codebase. The skill can work bidirectionally - from docs to code verification, or from code changes to documentation updates.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Doc Sync Skill
|
|
||||||
|
|
||||||
You are a documentation synchronization specialist for the IdeaVim project. Your job is to keep documentation in sync with code changes by identifying discrepancies and updating docs when necessary.
|
|
||||||
|
|
||||||
## Documentation Locations
|
|
||||||
|
|
||||||
The IdeaVim project has documentation in these locations:
|
|
||||||
- `doc/` folder - Detailed documentation files
|
|
||||||
- `README.md` - Main project README
|
|
||||||
- `CONTRIBUTING.md` - Contribution guidelines
|
|
||||||
|
|
||||||
## Core Mindset
|
|
||||||
|
|
||||||
**CRITICAL:** After code changes, documentation is **GUILTY until proven innocent**.
|
|
||||||
|
|
||||||
❌ **WRONG APPROACH:** "Be conservative, only update if clearly wrong"
|
|
||||||
✅ **RIGHT APPROACH:** "Be aggressive finding issues, conservative making fixes"
|
|
||||||
|
|
||||||
**Trust Hierarchy:**
|
|
||||||
1. Working Implementation in codebase (highest truth)
|
|
||||||
2. API Definition (interface/class)
|
|
||||||
3. Documentation (assume outdated until verified)
|
|
||||||
|
|
||||||
## Phase 0: Pre-Analysis Search (DO THIS FIRST)
|
|
||||||
|
|
||||||
Before reading full files, run these quick searches to find red flags:
|
|
||||||
|
|
||||||
### 1. Find Working Examples (Ground Truth)
|
|
||||||
```bash
|
|
||||||
# Find real implementations
|
|
||||||
grep -r '@VimPlugin\|@Plugin\|class.*Extension' --include="*.kt" | head -5
|
|
||||||
|
|
||||||
# Or search for known implementation patterns
|
|
||||||
find . -name "*NewApi.kt" -o -name "*Example*.kt"
|
|
||||||
```
|
|
||||||
**Read at least ONE working implementation as ground truth.** This shows you what "correct" looks like.
|
|
||||||
|
|
||||||
### 2. Check Recent Breaking Changes
|
|
||||||
```bash
|
|
||||||
# Check recent commits to the changed files
|
|
||||||
git log --oneline -10 -- '**/[ChangedFile]*'
|
|
||||||
|
|
||||||
# Look for removal commits
|
|
||||||
git log --grep="remove\|deprecate\|incorrect" --oneline -10
|
|
||||||
|
|
||||||
# Check what was actually deleted (more important than additions!)
|
|
||||||
git show [recent-commit] --stat
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Quick Pattern Search in Documentation
|
|
||||||
```bash
|
|
||||||
# Find all named parameters in code examples
|
|
||||||
grep -E '\w+\s*=' doc/*.md
|
|
||||||
|
|
||||||
# Extract all function signatures from docs
|
|
||||||
grep -E 'fun \w+\(|nmap\(|vmap\(|map\(' doc/*.md -B1 -A3
|
|
||||||
```
|
|
||||||
|
|
||||||
Compare each signature/parameter against the actual API.
|
|
||||||
|
|
||||||
## Two Modes of Operation
|
|
||||||
|
|
||||||
### Mode A: Documentation → Code Verification
|
|
||||||
Starting with documentation, verify that the code still matches what's documented.
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
0. **FIRST:** Find working implementation as ground truth (Phase 0)
|
|
||||||
1. Read the specified documentation file(s)
|
|
||||||
2. Extract ALL code examples and function signatures
|
|
||||||
3. For EACH code block:
|
|
||||||
- Extract every function call and parameter
|
|
||||||
- Verify signature exists in current API
|
|
||||||
- Compare pattern with working implementation
|
|
||||||
- If different from working code → documentation is WRONG
|
|
||||||
4. Update documentation if needed
|
|
||||||
|
|
||||||
### Mode B: Code Changes → Documentation Update
|
|
||||||
Starting with code changes (e.g., from git diff), find related documentation and update if needed.
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
0. **FIRST:** Understand what was REMOVED (Phase 0 - check git show/diff)
|
|
||||||
1. Read the changed files and git diff
|
|
||||||
2. Understand what changed (especially deletions and breaking changes)
|
|
||||||
3. Find working implementations that use the new API
|
|
||||||
4. Search for documentation that references these files/features/APIs
|
|
||||||
5. Extract all code examples from docs
|
|
||||||
6. Compare each example against working implementation
|
|
||||||
7. Update documentation to match the correct pattern
|
|
||||||
|
|
||||||
## Important Guidelines
|
|
||||||
|
|
||||||
### When to Update
|
|
||||||
✅ **DO update when:**
|
|
||||||
- API signatures have changed (parameters added/removed/renamed)
|
|
||||||
- Function/class/file names have been renamed
|
|
||||||
- Behavior has fundamentally changed
|
|
||||||
- Features have been removed or added
|
|
||||||
- File paths in documentation are now incorrect
|
|
||||||
- Code examples in docs no longer work
|
|
||||||
|
|
||||||
❌ **DON'T update when:**
|
|
||||||
- Only internal implementation changed (not public API)
|
|
||||||
- Wording could be slightly better but is still accurate
|
|
||||||
- Minor formatting inconsistencies
|
|
||||||
- Documentation uses slightly different terminology but conveys the same meaning
|
|
||||||
- Changes are in test files that don't affect public API
|
|
||||||
|
|
||||||
### Update Strategy
|
|
||||||
1. **Be aggressive in finding issues** - Assume docs are outdated after code changes
|
|
||||||
2. **Be conservative in making fixes** - Only update when there's a real problem
|
|
||||||
3. **Preserve style** - Match the existing documentation style
|
|
||||||
4. **Be specific** - Don't make sweeping changes; target the specific issue
|
|
||||||
5. **Verify accuracy** - Make sure your update is correct by checking working implementations
|
|
||||||
6. **Keep context** - Don't remove helpful context or examples unless they're wrong
|
|
||||||
|
|
||||||
### Verification Checklist
|
|
||||||
|
|
||||||
For EACH code block in documentation, verify:
|
|
||||||
|
|
||||||
- [ ] Extract the complete code example
|
|
||||||
- [ ] Identify every function call with its parameters
|
|
||||||
- [ ] For each function: Does this signature exist in current API?
|
|
||||||
- [ ] For each parameter: Does this parameter name/type exist in API?
|
|
||||||
- [ ] Does this pattern match the working implementation from codebase?
|
|
||||||
- [ ] If different from working code → **Documentation is WRONG**
|
|
||||||
- [ ] If parameters don't exist in API → **Documentation is WRONG**
|
|
||||||
|
|
||||||
## Workflow
|
|
||||||
|
|
||||||
When invoked, you should:
|
|
||||||
|
|
||||||
### Step 0: Establish Ground Truth (CRITICAL - DO FIRST)
|
|
||||||
- **Find working implementations:** Search for @VimPlugin, real examples in codebase
|
|
||||||
- **Check git history:** Run `git log -10` on changed files, look for "remove" commits
|
|
||||||
- **Understand deletions:** Run `git show [commit]` to see what was removed
|
|
||||||
- **Study working code:** Read at least 1-2 real implementations to understand correct patterns
|
|
||||||
|
|
||||||
### Step 1: Understand the Task
|
|
||||||
- If given doc files: Mode A (verify docs match code)
|
|
||||||
- If given code changes: Mode B (update docs to match code)
|
|
||||||
- If given both: Check if the code changes affect the mentioned docs
|
|
||||||
|
|
||||||
### Step 2: Quick Pattern Search
|
|
||||||
- Run grep searches from Phase 0 to find obvious red flags
|
|
||||||
- Extract all function signatures from docs
|
|
||||||
- Compare against API and working implementations
|
|
||||||
|
|
||||||
### Step 3: Detailed Verification
|
|
||||||
- Read relevant documentation thoroughly
|
|
||||||
- For EACH code example: Run through Verification Checklist
|
|
||||||
- Compare every signature and parameter against actual API
|
|
||||||
- Compare patterns against working implementations
|
|
||||||
|
|
||||||
### Step 4: Analyze Discrepancies
|
|
||||||
- List what's different between docs and code
|
|
||||||
- Assess severity (critical vs. minor)
|
|
||||||
- Determine if update is needed
|
|
||||||
- **Default to updating** when in doubt about code examples
|
|
||||||
|
|
||||||
### Step 5: Make Updates if Needed
|
|
||||||
- Edit documentation files with precise changes
|
|
||||||
- Explain what was changed and why
|
|
||||||
- Verify the update matches working implementation
|
|
||||||
|
|
||||||
### Step 6: Report Findings
|
|
||||||
- Summarize what was checked
|
|
||||||
- List any discrepancies found
|
|
||||||
- Describe what was updated (if anything)
|
|
||||||
- Note anything that might need human review
|
|
||||||
|
|
||||||
## Example Usage
|
|
||||||
|
|
||||||
### Example 1: Check specific documentation
|
|
||||||
```
|
|
||||||
User: "Check if doc/ideavim-mappings.md is in sync with the code"
|
|
||||||
|
|
||||||
You should:
|
|
||||||
0. FIRST: Find working implementation (grep for @VimPlugin or similar)
|
|
||||||
1. Read at least one working example to establish ground truth
|
|
||||||
2. Read doc/ideavim-mappings.md
|
|
||||||
3. Extract ALL code examples and function signatures
|
|
||||||
4. For EACH signature: verify it exists in API and matches working code
|
|
||||||
5. Compare patterns with working implementation
|
|
||||||
6. Update docs if any discrepancies found
|
|
||||||
```
|
|
||||||
|
|
||||||
### Example 2: Code changes → docs
|
|
||||||
```
|
|
||||||
User: "I changed MappingScope.kt, check if docs need updating"
|
|
||||||
|
|
||||||
You should:
|
|
||||||
0. FIRST: Check git log and recent commits for MappingScope
|
|
||||||
1. Run: git log --oneline -10 -- '**/MappingScope*'
|
|
||||||
2. Check for removal commits: git log --grep="remove" --oneline -5
|
|
||||||
3. If recent commits removed code: git show [commit] to see what was deleted
|
|
||||||
4. Find working implementation that uses MappingScope correctly
|
|
||||||
5. Read MappingScope.kt to understand current API
|
|
||||||
6. Search docs for references to MappingScope, mapping functions, etc.
|
|
||||||
7. Extract all code examples from docs
|
|
||||||
8. Compare each example against working implementation
|
|
||||||
9. Update docs to match the correct pattern
|
|
||||||
```
|
|
||||||
|
|
||||||
### Example 3: Comprehensive check
|
|
||||||
```
|
|
||||||
User: "Check if all documentation in doc/ folder is up to date"
|
|
||||||
|
|
||||||
You should:
|
|
||||||
0. FIRST: Find working implementations as ground truth
|
|
||||||
1. Check recent git history for breaking changes
|
|
||||||
2. List files in doc/ folder
|
|
||||||
3. For each doc file:
|
|
||||||
- Quick grep for function signatures and parameters
|
|
||||||
- Compare against API and working implementations
|
|
||||||
- Identify obvious issues
|
|
||||||
4. For files with issues: run full Mode A verification
|
|
||||||
5. Update any that need it
|
|
||||||
```
|
|
||||||
|
|
||||||
## Output Format
|
|
||||||
|
|
||||||
Always provide a clear report:
|
|
||||||
|
|
||||||
```
|
|
||||||
## Documentation Sync Report
|
|
||||||
|
|
||||||
### Files Checked
|
|
||||||
- [doc file 1]
|
|
||||||
- [doc file 2]
|
|
||||||
- [code file 1]
|
|
||||||
- [code file 2]
|
|
||||||
|
|
||||||
### Discrepancies Found
|
|
||||||
1. **[Doc file]: [Issue description]**
|
|
||||||
- Current docs say: [quote]
|
|
||||||
- Actual code: [description]
|
|
||||||
- Severity: [Critical/Minor]
|
|
||||||
- Action: [Updated/No action needed]
|
|
||||||
|
|
||||||
### Updates Made
|
|
||||||
- [File]: [Description of change]
|
|
||||||
|
|
||||||
### Notes
|
|
||||||
- [Any observations or recommendations]
|
|
||||||
```
|
|
||||||
|
|
||||||
## Tools Available
|
|
||||||
|
|
||||||
You have access to:
|
|
||||||
- **Read**: Read any file in the project
|
|
||||||
- **Edit**: Update documentation files
|
|
||||||
- **Glob**: Find files by pattern
|
|
||||||
- **Grep**: Search for text in files
|
|
||||||
- **Bash**: Run git commands to see recent changes
|
|
||||||
|
|
||||||
## Key Lessons Learned
|
|
||||||
|
|
||||||
**Most Important Insights:**
|
|
||||||
|
|
||||||
1. **Start with working code, not documentation.** The working implementation is your ground truth. Documentation is assumed outdated until proven otherwise.
|
|
||||||
|
|
||||||
2. **Deletions matter more than additions.** When code changes, what was REMOVED is more important than what was added. Removed functions/parameters will break documentation examples.
|
|
||||||
|
|
||||||
3. **Verify every parameter name.** Don't just check if the function exists - check if parameter names in examples actually exist in the function signature. Named parameters in docs that don't exist in code are a critical bug.
|
|
||||||
|
|
||||||
4. **Compare patterns, not just signatures.** A function might exist, but if the documentation shows a different usage pattern than the working implementation, the docs are wrong.
|
|
||||||
|
|
||||||
5. **Git history tells the story.** Recent commits with "remove", "deprecate", or "incorrect" in the message are red flags that documentation is likely outdated.
|
|
||||||
|
|
||||||
Remember: **Be aggressive in finding issues, conservative in making fixes.** Your goal is to ensure every code example in documentation actually works, not to improve writing style.
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
---
|
|
||||||
name: extensions-api-migration
|
|
||||||
description: Migrates IdeaVim extensions from the old VimExtensionFacade API to the new @VimPlugin annotation-based API. Use when converting existing extensions to use the new API patterns.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Extensions API Migration
|
|
||||||
|
|
||||||
You are an IdeaVim extensions migration specialist. Your job is to help migrate existing IdeaVim extensions from the old API (VimExtensionFacade) to the new API (@VimPlugin annotation).
|
|
||||||
|
|
||||||
## Key Locations
|
|
||||||
|
|
||||||
- **New API module**: `api/` folder - contains the new plugin API
|
|
||||||
- Old API: `VimExtensionFacade` in vim-engine
|
|
||||||
- Extensions location: `src/main/java/com/maddyhome/idea/vim/extension/`
|
|
||||||
|
|
||||||
## How to Use the New API
|
|
||||||
|
|
||||||
### Getting Access to the API
|
|
||||||
|
|
||||||
To get access to the new API, call the `api()` function from `com.maddyhome.idea.vim.extension.api`:
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
val api = api()
|
|
||||||
```
|
|
||||||
|
|
||||||
Obtain the API at the start of the `init()` method - this is the entry point for all further work.
|
|
||||||
|
|
||||||
### Registering Text Objects
|
|
||||||
|
|
||||||
Use `api.textObjects { }` to register text objects:
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
// From VimIndentObject.kt
|
|
||||||
override fun init() {
|
|
||||||
val api = api()
|
|
||||||
api.textObjects {
|
|
||||||
register("ai") { _ -> findIndentRange(includeAbove = true, includeBelow = false) }
|
|
||||||
register("aI") { _ -> findIndentRange(includeAbove = true, includeBelow = true) }
|
|
||||||
register("ii") { _ -> findIndentRange(includeAbove = false, includeBelow = false) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Registering Mappings
|
|
||||||
|
|
||||||
Use `api.mappings { }` to register mappings:
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
// From ParagraphMotion.kt
|
|
||||||
override fun init() {
|
|
||||||
val api = api()
|
|
||||||
|
|
||||||
api.mappings {
|
|
||||||
nmapPluginAction("}", "<Plug>(ParagraphNextMotion)", keepDefaultMapping = true) {
|
|
||||||
moveParagraph(1)
|
|
||||||
}
|
|
||||||
nmapPluginAction("{", "<Plug>(ParagraphPrevMotion)", keepDefaultMapping = true) {
|
|
||||||
moveParagraph(-1)
|
|
||||||
}
|
|
||||||
xmapPluginAction("}", "<Plug>(ParagraphNextMotion)", keepDefaultMapping = true) {
|
|
||||||
moveParagraph(1)
|
|
||||||
}
|
|
||||||
// ... operator-pending mode mappings with omapPluginAction
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Defining Helper Functions
|
|
||||||
|
|
||||||
The lambdas in text object and mapping registrations typically call helper functions. Define these functions with `VimApi` as a receiver - this makes the API available inside:
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
// From VimIndentObject.kt
|
|
||||||
private fun VimApi.findIndentRange(includeAbove: Boolean, includeBelow: Boolean): TextObjectRange? {
|
|
||||||
val charSequence = editor { read { text } }
|
|
||||||
val caretOffset = editor { read { withPrimaryCaret { offset } } }
|
|
||||||
// ... implementation using API
|
|
||||||
}
|
|
||||||
|
|
||||||
// From ParagraphMotion.kt
|
|
||||||
internal fun VimApi.moveParagraph(direction: Int) {
|
|
||||||
val count = getVariable<Int>("v:count1") ?: 1
|
|
||||||
editor {
|
|
||||||
change {
|
|
||||||
forEachCaret {
|
|
||||||
val newOffset = getNextParagraphBoundOffset(actualCount, includeWhitespaceLines = true)
|
|
||||||
if (newOffset != null) {
|
|
||||||
updateCaret(offset = newOffset)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### API Features
|
|
||||||
|
|
||||||
<!-- Fill in additional API features here -->
|
|
||||||
|
|
||||||
## How to Migrate Existing Extensions
|
|
||||||
|
|
||||||
### What Stays the Same
|
|
||||||
|
|
||||||
- The extension **still inherits VimExtensionFacade** - this does not change
|
|
||||||
- The extension **still registers in the XML file** - this does not change
|
|
||||||
|
|
||||||
### Migration Steps
|
|
||||||
|
|
||||||
#### Step 1: Ensure Test Coverage
|
|
||||||
|
|
||||||
Before starting migration, make sure tests exist for the extension:
|
|
||||||
- Tests should work and have good coverage
|
|
||||||
- If there aren't enough tests, create more tests first
|
|
||||||
- Verify tests pass on the existing version of the plugin
|
|
||||||
|
|
||||||
#### Step 2: Migrate in Small Steps
|
|
||||||
|
|
||||||
- Don't try to handle everything in one run
|
|
||||||
- Run tests on the plugin (just the single test class to speed up things) after making smaller changes
|
|
||||||
- This ensures consistency and makes it easier to identify issues
|
|
||||||
- **Do a separate commit for each small sensible change or migration** unless explicitly told not to
|
|
||||||
|
|
||||||
#### Step 3: Migrate Handlers One by One
|
|
||||||
|
|
||||||
If the extension has multiple handlers, migrate them one at a time rather than all at once.
|
|
||||||
|
|
||||||
#### Step 4: Handler Migration Process
|
|
||||||
|
|
||||||
For each handler, follow this approach:
|
|
||||||
|
|
||||||
1. **Inject the API**: Add `val api = api()` as the first line inside the `execute` function
|
|
||||||
|
|
||||||
2. **Extract to extension function**: Extract the content of the execute function into a separate function outside the `ExtensionHandler` class. The new function should:
|
|
||||||
- Have `VimApi` as a receiver
|
|
||||||
- Use the api that was obtained before
|
|
||||||
- Keep the extraction as-is (no changes to logic yet)
|
|
||||||
|
|
||||||
3. **Verify tests pass**: Run tests to ensure the extraction didn't break anything
|
|
||||||
|
|
||||||
4. **Migrate function content**: Now start migrating the content of the extracted function to use the new API
|
|
||||||
|
|
||||||
5. **Verify tests pass again**: Run tests after each significant change
|
|
||||||
|
|
||||||
6. **Update registration**: Finally, change the registration of shortcuts from the existing approach to `api.mappings { }` where you call the newly created function
|
|
||||||
|
|
||||||
#### Example Migration Flow
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
// BEFORE: Old style handler
|
|
||||||
class MyHandler : ExtensionHandler {
|
|
||||||
override fun execute(editor: VimEditor, context: ExecutionContext, operatorArguments: OperatorArguments) {
|
|
||||||
// ... implementation
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// STEP 1: Inject API
|
|
||||||
class MyHandler : ExtensionHandler {
|
|
||||||
override fun execute(editor: VimEditor, context: ExecutionContext, operatorArguments: OperatorArguments) {
|
|
||||||
val api = api()
|
|
||||||
// ... implementation
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// STEP 2: Extract to extension function (as-is)
|
|
||||||
class MyHandler : ExtensionHandler {
|
|
||||||
override fun execute(editor: VimEditor, context: ExecutionContext, operatorArguments: OperatorArguments) {
|
|
||||||
val api = api()
|
|
||||||
api.doMyAction(/* pass needed params */)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun VimApi.doMyAction(/* params */) {
|
|
||||||
// ... same implementation, moved here
|
|
||||||
}
|
|
||||||
|
|
||||||
// STEP 3-5: Migrate content to new API inside doMyAction()
|
|
||||||
|
|
||||||
// STEP 6: Update registration to use api.mappings { }
|
|
||||||
override fun init() {
|
|
||||||
val api = api()
|
|
||||||
api.mappings {
|
|
||||||
nmapPluginAction("key", "<Plug>(MyAction)") {
|
|
||||||
doMyAction()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Now MyHandler class can be removed
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Handling Complicated Plugins
|
|
||||||
|
|
||||||
For more complicated plugins, additional steps may be required.
|
|
||||||
|
|
||||||
For example, there might be a separate large class that performs calculations. However, this class may not be usable as-is because it takes a `Document` - a class that is no longer directly available through the new API.
|
|
||||||
|
|
||||||
In this case, perform a **pre-refactoring step**: update this class to remove the `Document` dependency before starting the main migration. For instance, change it to accept `CharSequence` instead, which is available via the new API.
|
|
||||||
|
|
||||||
#### Final Verification: Check for Old API Usage
|
|
||||||
|
|
||||||
After migration, verify that no old API is used by checking imports for `com.maddyhome`.
|
|
||||||
|
|
||||||
**Allowed imports** (these are still required):
|
|
||||||
- `com.maddyhome.idea.vim.extension.VimExtension`
|
|
||||||
- `com.maddyhome.idea.vim.extension.api`
|
|
||||||
|
|
||||||
Any other `com.maddyhome` imports indicate incomplete migration.
|
|
||||||
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
---
|
|
||||||
name: git-workflow
|
|
||||||
description: IdeaVim git workflow conventions covering commits, branches, PRs, and CI. Use when creating commits, managing branches, creating pull requests, reviewing git history, or any git-related activity in the IdeaVim project.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Git Workflow
|
|
||||||
|
|
||||||
## Branching
|
|
||||||
|
|
||||||
- **Master** is the trunk and MUST always be in a "ready to release" state
|
|
||||||
- Use **feature branches** for development work
|
|
||||||
- Naming: `VIM-XXXX/short-description` (e.g., `VIM-3948/editor`)
|
|
||||||
- Rebase to master frequently to avoid large conflicts
|
|
||||||
- Small, isolated changes (bug fixes, minor tweaks) MAY go directly to master
|
|
||||||
- Unfinished changes MAY be committed to master only if they do NOT break functionality
|
|
||||||
- Use **rebase** for integration, not merge commits (linear history)
|
|
||||||
|
|
||||||
## Commits
|
|
||||||
|
|
||||||
**Standard format:**
|
|
||||||
```
|
|
||||||
VIM-XXXX Description of the change
|
|
||||||
```
|
|
||||||
|
|
||||||
- Start with the YouTrack ticket ID when the change relates to a ticket
|
|
||||||
- Example: `VIM-3948 Traverse vertical panes in ConfigurableEditor`
|
|
||||||
|
|
||||||
**Auto-closing format** (moves YouTrack ticket to "Ready To Release"):
|
|
||||||
```
|
|
||||||
fix(VIM-XXXX): Description of the fix
|
|
||||||
```
|
|
||||||
|
|
||||||
**Content rules:**
|
|
||||||
- Each commit MUST contain a single, focused, meaningful change
|
|
||||||
- MUST NOT include unrelated changes (formatting, unrelated refactoring)
|
|
||||||
- Include appropriate tests with behavioral changes
|
|
||||||
|
|
||||||
## Pull Requests
|
|
||||||
|
|
||||||
- PRs target `master`
|
|
||||||
- CI runs standard tests automatically (`./gradlew test -x :tests:property-tests:test -x :tests:long-running-tests:test`)
|
|
||||||
- PRs from external contributors are listed in the changelog under "Merged PRs"
|
|
||||||
|
|
||||||
## Issue Tracking
|
|
||||||
|
|
||||||
- Use **YouTrack** (not GitHub Issues) - tickets are `VIM-XXXX`
|
|
||||||
- URL: https://youtrack.jetbrains.com/issues/VIM
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
---
|
|
||||||
name: issues-deduplication
|
|
||||||
description: Handles deduplication of YouTrack issues. Use when cleaning up duplicate issues, consolidating related bug reports, or organizing issue tracker.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Issues Deduplication
|
|
||||||
|
|
||||||
You are an issue tracker specialist for the IdeaVim project. Your job is to identify and properly handle duplicate issues in YouTrack.
|
|
||||||
|
|
||||||
## Core Principles
|
|
||||||
|
|
||||||
### 1. Choosing Which Issue to Keep Open
|
|
||||||
|
|
||||||
**Default rule**: The older issue is typically kept open, and newer issues are marked as duplicates.
|
|
||||||
|
|
||||||
**Exception - Activity trumps age**: If a newer issue has significantly more engagement (comments, votes, watchers), keep the newer one open and mark the older one as duplicate. Consider:
|
|
||||||
- Number of comments
|
|
||||||
- Number of votes/thumbs-up
|
|
||||||
- Number of watchers
|
|
||||||
- Quality of discussion and information
|
|
||||||
|
|
||||||
### 2. Never Duplicate Issues with Customer Tags
|
|
||||||
|
|
||||||
**IMPORTANT**: Do not mark an issue as duplicate if it has a customer-related tag:
|
|
||||||
- Tags like `Customer:XXX`
|
|
||||||
- Company name tags like `Uber`, `Google`, `Meta`, etc.
|
|
||||||
- Any tag indicating a specific customer reported or is affected by the issue
|
|
||||||
|
|
||||||
These issues need individual tracking for customer relationship purposes.
|
|
||||||
|
|
||||||
### 3. Closed Issue Warning
|
|
||||||
|
|
||||||
**CRITICAL**: Be very careful about duplicating into a closed issue!
|
|
||||||
|
|
||||||
Before marking issues as duplicates of a closed issue, verify:
|
|
||||||
- Is the closed issue actually fixed?
|
|
||||||
- Does the fix apply to all the duplicate reports?
|
|
||||||
- Are the newer reports potentially about a regression or different manifestation?
|
|
||||||
|
|
||||||
**If the problem is still occurring** (based on recent reports), do NOT duplicate into a closed issue. Instead:
|
|
||||||
- Reopen the closed issue, OR
|
|
||||||
- Keep one of the open issues as the primary and duplicate into that
|
|
||||||
|
|
||||||
Duplicating active issues into a wrongly-closed issue will mark all related issues as "resolved" and lose track of an unresolved problem.
|
|
||||||
|
|
||||||
### 4. Consolidate to a Single Issue
|
|
||||||
|
|
||||||
When multiple issues are duplicates of each other (e.g., issues 1, 2, 3, 4, 5):
|
|
||||||
- **DO**: Mark 2, 3, 4, 5 as duplicates of 1 (star topology)
|
|
||||||
- **DON'T**: Create chains like 2→1, 3→2, 4→3, 5→4
|
|
||||||
|
|
||||||
This makes it easier to track all related reports from a single issue.
|
|
||||||
|
|
||||||
### 5. Preserve Unique Information
|
|
||||||
|
|
||||||
Before marking an issue as duplicate:
|
|
||||||
1. Review the issue for unique information not present in the target issue
|
|
||||||
2. If valuable info exists (reproduction steps, logs, environment details, workarounds):
|
|
||||||
- Add a comment to the target issue summarizing the unique info
|
|
||||||
- Or update the target issue's description if the info is significant
|
|
||||||
3. Then mark as duplicate
|
|
||||||
|
|
||||||
## Process
|
|
||||||
|
|
||||||
### Step 1: Gather Issue Details
|
|
||||||
For each candidate issue, collect:
|
|
||||||
- Issue ID and summary
|
|
||||||
- Creation date
|
|
||||||
- Number of comments
|
|
||||||
- Number of votes
|
|
||||||
- Tags (especially customer tags)
|
|
||||||
- Current state (Open, Closed, etc.)
|
|
||||||
- Key details from description
|
|
||||||
|
|
||||||
### Step 2: Group Duplicates
|
|
||||||
Identify which issues are truly duplicates vs. related-but-different issues.
|
|
||||||
|
|
||||||
### Step 3: Select Primary Issue
|
|
||||||
Based on the rules above, select which issue should be the primary (kept open).
|
|
||||||
|
|
||||||
### Step 4: Check for Unique Information
|
|
||||||
Review each duplicate for information not in the primary issue.
|
|
||||||
|
|
||||||
### Step 5: Transfer Information
|
|
||||||
Add comments or update the primary issue with any unique valuable information.
|
|
||||||
|
|
||||||
### Step 6: Mark Duplicates
|
|
||||||
Use YouTrack to link issues as duplicates:
|
|
||||||
- Add "duplicates" link from duplicate → primary
|
|
||||||
- Update the issue state to "Duplicate"
|
|
||||||
|
|
||||||
### Step 7: Leave a Courteous Comment
|
|
||||||
After marking an issue as duplicate, leave a comment on the duplicated issue to:
|
|
||||||
- Inform the reporter about the merge
|
|
||||||
- Direct them to the primary issue for updates
|
|
||||||
- Thank them for their contribution
|
|
||||||
|
|
||||||
Example comment:
|
|
||||||
> This issue has been merged into VIM-XXXX for easier tracking. Please follow that issue for updates. Thank you for your contribution!
|
|
||||||
|
|
||||||
This maintains good relationships with reporters and ensures they stay informed.
|
|
||||||
|
|
||||||
## YouTrack Operations
|
|
||||||
|
|
||||||
### Link as Duplicate
|
|
||||||
Use `mcp__YouTrack__link_issues` with:
|
|
||||||
- `issueId`: The duplicate issue
|
|
||||||
- `targetIssueId`: The primary issue to duplicate into
|
|
||||||
- `linkName`: "duplicates"
|
|
||||||
|
|
||||||
### Add Comment
|
|
||||||
Use `mcp__YouTrack__add_issue_comment` to transfer unique information.
|
|
||||||
|
|
||||||
### Update Issue
|
|
||||||
Use `mcp__YouTrack__update_issue` to update description if needed.
|
|
||||||
|
|
||||||
## Example Decision Matrix
|
|
||||||
|
|
||||||
| Scenario | Action |
|
|
||||||
|----------------------------------------------------------------------------------|------------------------------------------------------------|
|
|
||||||
| Old issue (2022), new issue (2024) with same problem, similar activity | Duplicate new → old |
|
|
||||||
| Old issue (2022) with 2 comments, new issue (2024) with 15 comments and 10 votes | Duplicate old → new |
|
|
||||||
| Issue has `Customer:Acme` tag | Never mark as duplicate |
|
|
||||||
| Old issue closed as "Fixed", new reports say problem still exists | Keep new issue open, investigate if regression |
|
|
||||||
| 5 issues about same bug | Pick best one as primary, duplicate all 4 others → primary |
|
|
||||||
@@ -1,279 +0,0 @@
|
|||||||
---
|
|
||||||
name: tests-maintenance
|
|
||||||
description: Maintains IdeaVim test suite quality. Reviews disabled tests, ensures Neovim annotations are documented, and improves test readability. Use for periodic test maintenance.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Tests Maintenance Skill
|
|
||||||
|
|
||||||
You are a test maintenance specialist for the IdeaVim project. Your job is to keep the test suite healthy by reviewing test quality, checking disabled tests, and ensuring proper documentation of test exclusions.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
**DO:**
|
|
||||||
- Review test quality and readability
|
|
||||||
- Check if disabled tests can be re-enabled
|
|
||||||
- Ensure Neovim test exclusions are well-documented
|
|
||||||
- Improve test content (replace meaningless strings)
|
|
||||||
|
|
||||||
**DON'T:**
|
|
||||||
- Fix bugs in source code
|
|
||||||
- Implement new features
|
|
||||||
- Make changes to production code
|
|
||||||
|
|
||||||
## Change Granularity (Important for CI/GitHub Actions)
|
|
||||||
|
|
||||||
**One logical change per run.** This ensures granular, reviewable Pull Requests.
|
|
||||||
|
|
||||||
**Rules:**
|
|
||||||
1. **One test per run**: Focus on a single test file or test method
|
|
||||||
2. **One logical change per test**: Don't combine unrelated fixes in the same PR
|
|
||||||
3. **Group only if identical**: Multiple `@TestWithoutNeovim` annotations can be updated together ONLY if they:
|
|
||||||
- Have the same skip reason
|
|
||||||
- Require the same fix (e.g., all need the same description added)
|
|
||||||
- Are part of the same logical issue
|
|
||||||
|
|
||||||
**Examples:**
|
|
||||||
|
|
||||||
✅ **Good** (pick ONE of these per PR):
|
|
||||||
- Update one `DIFFERENT` → `IDEAVIM_API_USED` with description
|
|
||||||
- Add descriptions to 3 tests that all use `SCROLL` reason (same fix pattern)
|
|
||||||
- Re-enable one `@Disabled` test that now passes
|
|
||||||
|
|
||||||
❌ **Bad** (too many changes):
|
|
||||||
- Update `DIFFERENT` to `SCROLL` in one test AND `PLUGIN` in another (different reasons)
|
|
||||||
- Fix test content AND update annotations in the same PR
|
|
||||||
- Re-enable multiple unrelated disabled tests
|
|
||||||
|
|
||||||
**Why this matters:**
|
|
||||||
- Each PR can be reviewed independently
|
|
||||||
- Easy to revert if something breaks
|
|
||||||
- Clear git history of what changed and why
|
|
||||||
|
|
||||||
## How to Select Tests
|
|
||||||
|
|
||||||
Each run should focus on a small subset. Use one of these strategies:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Get a random test file
|
|
||||||
find . -path "*/test/*" -name "*Test*.kt" -not -path "*/build/*" | shuf -n 1
|
|
||||||
|
|
||||||
# Or focus on specific areas:
|
|
||||||
# - src/test/java/org/jetbrains/plugins/ideavim/action/
|
|
||||||
# - src/test/java/org/jetbrains/plugins/ideavim/ex/
|
|
||||||
# - src/test/java/org/jetbrains/plugins/ideavim/extension/
|
|
||||||
# - tests/java-tests/src/test/kotlin/
|
|
||||||
```
|
|
||||||
|
|
||||||
## What to Check
|
|
||||||
|
|
||||||
### 1. Disabled Tests (@Disabled)
|
|
||||||
|
|
||||||
Find disabled tests and check if they can be re-enabled:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Find all @Disabled tests
|
|
||||||
grep -rn "@Disabled" --include="*.kt" src/test tests/
|
|
||||||
```
|
|
||||||
|
|
||||||
For each disabled test:
|
|
||||||
1. **Try running it**: `./gradlew test --tests "ClassName.testMethod"`
|
|
||||||
2. **If it passes**: Investigate what changed, re-enable with explanation
|
|
||||||
3. **If it fails**: Ensure reason is documented in @Disabled annotation
|
|
||||||
4. **If obsolete**: Remove tests for features that no longer exist
|
|
||||||
|
|
||||||
### 2. Neovim Test Exclusions (@TestWithoutNeovim)
|
|
||||||
|
|
||||||
Tests excluded from Neovim verification must have clear documentation.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Find TestWithoutNeovim usages
|
|
||||||
grep -rn "@TestWithoutNeovim" --include="*.kt" src/test tests/
|
|
||||||
|
|
||||||
# Find those without description (needs fixing)
|
|
||||||
grep -rn "@TestWithoutNeovim(SkipNeovimReason\.[A-Z_]*)" --include="*.kt" src/test
|
|
||||||
```
|
|
||||||
|
|
||||||
#### SkipNeovimReason Categories
|
|
||||||
|
|
||||||
| Reason | When to Use |
|
|
||||||
|--------|-------------|
|
|
||||||
| `SEE_DESCRIPTION` | Case-specific difference that doesn't fit other categories (description required) |
|
|
||||||
| `PLUGIN` | IdeaVim extension-specific behavior (surround, commentary, etc.) |
|
|
||||||
| `INLAYS` | Test involves IntelliJ inlays (not present in Vim) |
|
|
||||||
| `OPTION` | IdeaVim-specific option behavior |
|
|
||||||
| `UNCLEAR` | **DEPRECATED** - Investigate and use a more specific reason |
|
|
||||||
| `NON_ASCII` | Non-ASCII character handling differs |
|
|
||||||
| `MAPPING` | Mapping-specific test |
|
|
||||||
| `SELECT_MODE` | Vim's select mode |
|
|
||||||
| `VISUAL_BLOCK_MODE` | Visual block mode edge cases |
|
|
||||||
| `DIFFERENT` | **DEPRECATED** - Use a more specific reason instead |
|
|
||||||
| `NOT_VIM_TESTING` | Test doesn't verify Vim behavior (IDE integration, etc.) |
|
|
||||||
| `SHOW_CMD` | :showcmd related differences |
|
|
||||||
| `SCROLL` | Scrolling behavior (viewport differs) |
|
|
||||||
| `TEMPLATES` | IntelliJ live templates |
|
|
||||||
| `EDITOR_MODIFICATION` | Editor-specific modifications |
|
|
||||||
| `CMD` | Command-line mode differences |
|
|
||||||
| `ACTION_COMMAND` | `:action` command (IDE-specific) |
|
|
||||||
| `FOLDING` | Code folding (IDE feature) |
|
|
||||||
| `TABS` | Tab/window management differences |
|
|
||||||
| `PLUGIN_ERROR` | Plugin execution error handling |
|
|
||||||
| `VIM_SCRIPT` | VimScript implementation differences |
|
|
||||||
| `GUARDED_BLOCKS` | IDE guarded/read-only blocks |
|
|
||||||
| `CTRL_CODES` | Control code handling |
|
|
||||||
| `BUG_IN_NEOVIM` | Known Neovim bug (not IdeaVim issue) |
|
|
||||||
| `PSI` | IntelliJ PSI/code intelligence features |
|
|
||||||
| `IDEAVIM_API_USED` | Test uses IdeaVim API that prevents Neovim state sync |
|
|
||||||
| `IDEAVIM_WORKS_INTENTIONALLY_DIFFERENT` | IdeaVim intentionally deviates from Neovim for better UX or IntelliJ integration |
|
|
||||||
| `INTELLIJ_PLATFORM_INHERITED_DIFFERENCE` | Behavior difference inherited from IntelliJ Platform constraints |
|
|
||||||
|
|
||||||
**Requirements:**
|
|
||||||
- Add `description` parameter for non-obvious cases
|
|
||||||
- Check if the reason is still valid
|
|
||||||
- Consider if test could be split: part that works with Neovim, part that doesn't
|
|
||||||
|
|
||||||
**Special requirement for `IDEAVIM_WORKS_INTENTIONALLY_DIFFERENT`:**
|
|
||||||
- **ONLY use when you find clear evidence** of intentional deviation:
|
|
||||||
- Explicit commit messages explaining the intentional difference
|
|
||||||
- Code comments documenting why IdeaVim deviates from Vim/Neovim
|
|
||||||
- Absolutely obvious cases (e.g., IntelliJ-specific features not in Neovim)
|
|
||||||
- **DO NOT use based on guesswork or assumptions**
|
|
||||||
- If uncertain, use `DIFFERENT` or `UNCLEAR` instead and investigate git history/comments
|
|
||||||
- The `description` parameter is **mandatory** and must explain what exactly differs and why
|
|
||||||
|
|
||||||
**Special requirement for `INTELLIJ_PLATFORM_INHERITED_DIFFERENCE`:**
|
|
||||||
- Use when behavior difference is due to IntelliJ Platform's underlying implementation
|
|
||||||
- Common cases include:
|
|
||||||
- Empty buffer handling (Platform editors can be empty, Neovim buffers always have a newline)
|
|
||||||
- Position/offset calculations for newline characters
|
|
||||||
- Line/column indexing differences
|
|
||||||
- The `description` parameter is **mandatory** and must explain:
|
|
||||||
- What Platform behavior causes the difference
|
|
||||||
- How it manifests in the test
|
|
||||||
- Evidence can be found in Platform API documentation, IdeaVim code comments, or obvious Platform limitations
|
|
||||||
|
|
||||||
**Special requirement for `SEE_DESCRIPTION`:**
|
|
||||||
- Use as a last resort when the difference doesn't fit any standard category
|
|
||||||
- The `description` parameter is **mandatory** and must provide a clear, specific explanation
|
|
||||||
- Use sparingly - if multiple tests share similar reasons, consider creating a new dedicated reason
|
|
||||||
- Always check existing reasons first before using this catch-all
|
|
||||||
|
|
||||||
**Handling `DIFFERENT` and `UNCLEAR` (DEPRECATED):**
|
|
||||||
|
|
||||||
Both `DIFFERENT` and `UNCLEAR` reasons are deprecated because they're too vague. When you encounter a test with either of these reasons, follow this process:
|
|
||||||
|
|
||||||
1. **First, try removing the annotation and running with Neovim:**
|
|
||||||
```bash
|
|
||||||
# Comment out or remove @TestWithoutNeovim, then run:
|
|
||||||
./gradlew test -Dnvim --tests "ClassName.testMethodName"
|
|
||||||
```
|
|
||||||
|
|
||||||
**IMPORTANT:** Verify the output contains `NEOVIM TESTING ENABLED` to confirm Neovim testing is active.
|
|
||||||
If this message is not present, the test ran without Neovim verification.
|
|
||||||
|
|
||||||
2. **If the test passes with Neovim:**
|
|
||||||
- The annotation is outdated and should be removed
|
|
||||||
- IdeaVim and Neovim now behave identically for this case
|
|
||||||
|
|
||||||
3. **If the test fails with Neovim:**
|
|
||||||
- Analyze the failure to understand WHY the behavior differs
|
|
||||||
- Replace `DIFFERENT` with a more specific reason:
|
|
||||||
- `IDEAVIM_API_USED` - if test uses VimPlugin.* or injector.* APIs directly
|
|
||||||
- `IDEAVIM_WORKS_INTENTIONALLY_DIFFERENT` - if IdeaVim intentionally deviates (need evidence)
|
|
||||||
- `INTELLIJ_PLATFORM_INHERITED_DIFFERENCE` - if difference comes from Platform constraints
|
|
||||||
- `SEE_DESCRIPTION` - for unique cases that don't fit other categories (description required)
|
|
||||||
- Or another appropriate reason from the table above
|
|
||||||
- Always add a `description` parameter explaining the specific difference
|
|
||||||
|
|
||||||
### 3. Test Quality & Readability
|
|
||||||
|
|
||||||
**Meaningful test content**: Avoid senseless text. Look for:
|
|
||||||
```bash
|
|
||||||
grep -rn "asdf\|qwerty\|xxxxx\|aaaaa\|dhjkw" --include="*.kt" src/test tests/
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
|
||||||
- Actual code snippets relevant to the test
|
|
||||||
- Lorem Ipsum template from CONTRIBUTING.md
|
|
||||||
- Realistic text demonstrating the feature
|
|
||||||
|
|
||||||
**Test naming**: Names should explain what's being tested.
|
|
||||||
|
|
||||||
### 4. @VimBehaviorDiffers Annotation
|
|
||||||
|
|
||||||
Tests marked with this document intentional differences from Vim:
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
@VimBehaviorDiffers(
|
|
||||||
originalVimAfter = "expected vim result",
|
|
||||||
description = "why IdeaVim differs",
|
|
||||||
shouldBeFixed = true/false
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
Check:
|
|
||||||
- Is the difference still valid?
|
|
||||||
- If `shouldBeFixed = true`, is there a YouTrack issue?
|
|
||||||
- Can behavior now be aligned with Vim?
|
|
||||||
|
|
||||||
## Making Changes
|
|
||||||
|
|
||||||
### When to Change
|
|
||||||
|
|
||||||
**DO fix:**
|
|
||||||
- Unclear or missing test descriptions
|
|
||||||
- Senseless test content
|
|
||||||
- Disabled tests that now pass
|
|
||||||
- Incorrect `@TestWithoutNeovim` reasons
|
|
||||||
- Missing `description` on annotations
|
|
||||||
|
|
||||||
**DON'T:**
|
|
||||||
- Fix source code bugs
|
|
||||||
- Implement missing features
|
|
||||||
- Major refactoring without clear benefit
|
|
||||||
|
|
||||||
### Commit Messages
|
|
||||||
|
|
||||||
```
|
|
||||||
tests: Re-enable DeleteMotionTest after fix in #1234
|
|
||||||
|
|
||||||
The test was disabled due to a caret positioning bug that was
|
|
||||||
fixed in commit abc123. Verified the test passes consistently.
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
|
||||||
tests: Improve test content readability in ChangeActionTest
|
|
||||||
|
|
||||||
Replace meaningless "asdfgh" strings with realistic code snippets
|
|
||||||
that better demonstrate the change operation behavior.
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
|
||||||
tests: Document @TestWithoutNeovim reasons in ScrollTest
|
|
||||||
|
|
||||||
Added description parameter to clarify why scroll tests
|
|
||||||
are excluded from Neovim verification (viewport behavior differs).
|
|
||||||
```
|
|
||||||
|
|
||||||
## Commands Reference
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run specific test
|
|
||||||
./gradlew test --tests "ClassName.testMethod"
|
|
||||||
|
|
||||||
# Run all tests in a class
|
|
||||||
./gradlew test --tests "ClassName"
|
|
||||||
|
|
||||||
# Run tests with Neovim verification (look for "NEOVIM TESTING ENABLED" in output)
|
|
||||||
./gradlew test -Dnvim --tests "ClassName"
|
|
||||||
|
|
||||||
# Standard test suite (excludes property and long-running)
|
|
||||||
./gradlew test -x :tests:property-tests:test -x :tests:long-running-tests:test
|
|
||||||
```
|
|
||||||
|
|
||||||
## Output
|
|
||||||
|
|
||||||
When run via workflow, if changes are made, create a PR with:
|
|
||||||
- **Title**: "Tests maintenance: <brief description>"
|
|
||||||
- **Body**: What was checked, issues found, changes made
|
|
||||||
|
|
||||||
If no changes needed, report what was checked and that everything is fine.
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
@@ -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
|
||||||
|
|||||||
Generated
+1
-1
@@ -18,5 +18,5 @@
|
|||||||
</list>
|
</list>
|
||||||
</option>
|
</option>
|
||||||
</component>
|
</component>
|
||||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="corretto-21" project-jdk-type="JavaSDK" />
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK" />
|
||||||
</project>
|
</project>
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
# CLAUDE.md
|
|
||||||
|
|
||||||
Guidance for Claude Code when working with IdeaVim.
|
|
||||||
|
|
||||||
## Quick Reference
|
|
||||||
|
|
||||||
Essential commands:
|
|
||||||
- `./gradlew runIde` - Start dev IntelliJ with IdeaVim
|
|
||||||
- `./gradlew test -x :tests:property-tests:test -x :tests:long-running-tests:test` - Run standard tests
|
|
||||||
|
|
||||||
Avoid running all tests, this takes too long. It's preferred to run specific test.
|
|
||||||
|
|
||||||
When running gradle tasks, use `--console=plain` for cleaner output without progress bars.
|
|
||||||
|
|
||||||
See CONTRIBUTING.md for architecture details and a complete command list.
|
|
||||||
|
|
||||||
## IdeaVim-Specific Notes
|
|
||||||
|
|
||||||
- Property tests can be flaky - verify if failures relate to your changes
|
|
||||||
- Use `<Action>` in mappings, not `:action`
|
|
||||||
- Config file: `~/.ideavimrc` (XDG supported)
|
|
||||||
- Goal: Match Vim functionality and architecture
|
|
||||||
|
|
||||||
## Issue Tracking
|
|
||||||
|
|
||||||
This project uses **YouTrack** for issue tracking, NOT GitHub Issues.
|
|
||||||
- Tickets are prefixed with `VIM-` (e.g., VIM-1234)
|
|
||||||
- YouTrack URL: https://youtrack.jetbrains.com/issues/VIM
|
|
||||||
- `gh issue` commands will NOT work
|
|
||||||
|
|
||||||
## Commit Messages
|
|
||||||
|
|
||||||
For commits that fix a tracked issue, use the format `Fix(VIM-XXX): <description>` (e.g., `Fix(VIM-4115): NPE in CommandKeyConsumer after plugin disable/enable`).
|
|
||||||
|
|
||||||
## Additional Documentation
|
|
||||||
|
|
||||||
- Changelog maintenance: Handled by the `changelog` skill (auto-detected when updating changelog)
|
|
||||||
|
|
||||||
## Active Technologies
|
|
||||||
- Kotlin (JVM 21) + IntelliJ Platform SDK, IdeaVim vim-engine (001-api-layer)
|
|
||||||
|
|
||||||
## Recent Changes
|
|
||||||
- 001-api-layer: Added Kotlin (JVM 21) + IntelliJ Platform SDK, IdeaVim vim-engine
|
|
||||||
@@ -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")
|
||||||
|
|||||||
+6
-14
@@ -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")
|
||||||
@@ -48,7 +48,7 @@ plugins {
|
|||||||
// NOTE: Unignore "test block comment falls back to line comment when not available" test
|
// NOTE: Unignore "test block comment falls back to line comment when not available" test
|
||||||
// After changing this version. It supposed to work on the next version of the gradle plugin
|
// After changing this version. It supposed to work on the next version of the gradle plugin
|
||||||
// Or go report to the devs that this test still fails.
|
// Or go report to the devs that this test still fails.
|
||||||
id("org.jetbrains.intellij.platform") version "2.18.0"
|
id("org.jetbrains.intellij.platform") version "2.18.1"
|
||||||
|
|
||||||
id("org.jetbrains.changelog") version "2.5.0"
|
id("org.jetbrains.changelog") version "2.5.0"
|
||||||
id("com.dorongold.task-tree") version "4.0.2"
|
id("com.dorongold.task-tree") version "4.0.2"
|
||||||
@@ -138,6 +138,8 @@ dependencies {
|
|||||||
bundledModule("intellij.spellchecker")
|
bundledModule("intellij.spellchecker")
|
||||||
if (bookmarksIsSeparatePlugin) bundledPlugin("intellij.bookmarks.plugin")
|
if (bookmarksIsSeparatePlugin) bundledPlugin("intellij.bookmarks.plugin")
|
||||||
bundledModule("intellij.platform.kernel.impl")
|
bundledModule("intellij.platform.kernel.impl")
|
||||||
|
|
||||||
|
compatiblePlugin("com.intellij.classic.ui")
|
||||||
}
|
}
|
||||||
|
|
||||||
moduleSources(project(":vim-engine", "sourcesJarArtifacts"))
|
moduleSources(project(":vim-engine", "sourcesJarArtifacts"))
|
||||||
@@ -264,6 +266,7 @@ tasks {
|
|||||||
|
|
||||||
runIde {
|
runIde {
|
||||||
systemProperty("ideavim.use.debug.ideavimrc", "true")
|
systemProperty("ideavim.use.debug.ideavimrc", "true")
|
||||||
|
systemProperty("idea.trust.all.projects", "true")
|
||||||
}
|
}
|
||||||
|
|
||||||
val runPycharm by intellijPlatformTesting.runIde.registering {
|
val runPycharm by intellijPlatformTesting.runIde.registering {
|
||||||
@@ -461,11 +464,6 @@ tasks {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
buildPlugin {
|
|
||||||
dependsOn(sourcesJar)
|
|
||||||
from(sourcesJar) { into("lib/src") }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
java {
|
java {
|
||||||
@@ -536,13 +534,7 @@ intellijPlatform {
|
|||||||
})
|
})
|
||||||
|
|
||||||
ideaVersion {
|
ideaVersion {
|
||||||
// Let the Gradle plugin set the since-build version. It defaults to the version of the IDE we're building against
|
sinceBuild.set("253")
|
||||||
// specified as two components, `{branch}.{build}` (e.g., "241.15989"). There is no third component specified.
|
|
||||||
// The until-build version defaults to `{branch}.*`, but we want to support _all_ future versions, so we set it
|
|
||||||
// with a null provider (the provider is important).
|
|
||||||
// By letting the Gradle plugin handle this, the Plugin DevKit IntelliJ plugin cannot help us with the "Usage of
|
|
||||||
// IntelliJ API not available in older IDEs" inspection. However, since our since-build is the version we compile
|
|
||||||
// against, we can never get an API that's newer - it would be an unresolved symbol.
|
|
||||||
untilBuild.set(provider { null })
|
untilBuild.set(provider { null })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
-2
@@ -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=SNAPSHOT
|
version=9999.61-chylex
|
||||||
javaVersion=21
|
javaVersion=21
|
||||||
remoteRobotVersion=0.11.23
|
remoteRobotVersion=0.11.23
|
||||||
antlrVersion=4.10.1
|
antlrVersion=4.10.1
|
||||||
@@ -35,7 +35,6 @@ slackUrl=
|
|||||||
youtrackToken=
|
youtrackToken=
|
||||||
# Gradle settings
|
# Gradle settings
|
||||||
org.gradle.jvmargs='-Dfile.encoding=UTF-8'
|
org.gradle.jvmargs='-Dfile.encoding=UTF-8'
|
||||||
org.gradle.configuration-cache=true
|
|
||||||
org.gradle.caching=true
|
org.gradle.caching=true
|
||||||
# Disable warning from gradle-intellij-plugin. Kotlin stdlib is included as compileOnly, so the warning is unnecessary
|
# Disable warning from gradle-intellij-plugin. Kotlin stdlib is included as compileOnly, so the warning is unnecessary
|
||||||
kotlin.stdlib.default.dependency=false
|
kotlin.stdlib.default.dependency=false
|
||||||
|
|||||||
@@ -317,6 +317,7 @@
|
|||||||
</group>
|
</group>
|
||||||
|
|
||||||
<action id="VimFindActionIdAction" class="com.maddyhome.idea.vim.listener.FindActionIdAction"/>
|
<action id="VimFindActionIdAction" class="com.maddyhome.idea.vim.listener.FindActionIdAction"/>
|
||||||
|
<action id="VimJumpToSource" class="com.intellij.diff.actions.impl.OpenInEditorAction" />
|
||||||
</actions>
|
</actions>
|
||||||
|
|
||||||
<!-- Frontend vim extensions (editor/text manipulation, no PSI/file-system dependency) -->
|
<!-- Frontend vim extensions (editor/text manipulation, no PSI/file-system dependency) -->
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
package com.maddyhome.idea.vim.action.macro
|
||||||
|
|
||||||
|
import com.intellij.openapi.command.CommandProcessor
|
||||||
|
import com.intellij.openapi.command.UndoConfirmationPolicy
|
||||||
|
import com.intellij.openapi.command.impl.FinishMarkAction
|
||||||
|
import com.intellij.openapi.command.impl.StartMarkAction
|
||||||
|
import com.intellij.openapi.fileEditor.TextEditor
|
||||||
|
import com.intellij.openapi.fileEditor.ex.FileEditorManagerEx
|
||||||
|
import com.intellij.vim.annotations.CommandOrMotion
|
||||||
|
import com.intellij.vim.annotations.Mode
|
||||||
|
import com.maddyhome.idea.vim.KeyHandler
|
||||||
|
import com.maddyhome.idea.vim.api.ExecutionContext
|
||||||
|
import com.maddyhome.idea.vim.api.VimEditor
|
||||||
|
import com.maddyhome.idea.vim.api.injector
|
||||||
|
import com.maddyhome.idea.vim.command.Argument
|
||||||
|
import com.maddyhome.idea.vim.command.Command
|
||||||
|
import com.maddyhome.idea.vim.command.OperatorArguments
|
||||||
|
import com.maddyhome.idea.vim.handler.VimActionHandler
|
||||||
|
import com.maddyhome.idea.vim.newapi.ij
|
||||||
|
import com.maddyhome.idea.vim.newapi.vim
|
||||||
|
|
||||||
|
@CommandOrMotion(keys = ["z@"], modes = [Mode.NORMAL])
|
||||||
|
class PlaybackRegisterInOpenFilesAction : VimActionHandler.SingleExecution() {
|
||||||
|
override val type: Command.Type = Command.Type.OTHER_SELF_SYNCHRONIZED
|
||||||
|
|
||||||
|
override val argumentType: Argument.Type = Argument.Type.CHARACTER
|
||||||
|
|
||||||
|
private val playbackRegisterAction = PlaybackRegisterAction()
|
||||||
|
|
||||||
|
override fun execute(
|
||||||
|
editor: VimEditor,
|
||||||
|
context: ExecutionContext,
|
||||||
|
cmd: Command,
|
||||||
|
operatorArguments: OperatorArguments,
|
||||||
|
): Boolean {
|
||||||
|
val argument = cmd.argument as? Argument.Character ?: return false
|
||||||
|
|
||||||
|
val project = editor.ij.project ?: return false
|
||||||
|
val fileEditorManager = FileEditorManagerEx.getInstanceExIfCreated(project) ?: return false
|
||||||
|
|
||||||
|
val register = argument.character.let { if (it == '@') injector.macro.lastRegister else it }
|
||||||
|
val commandName = "Execute Macro '$register' in All Open Files"
|
||||||
|
|
||||||
|
val action = Runnable {
|
||||||
|
CommandProcessor.getInstance().markCurrentCommandAsGlobal(project)
|
||||||
|
|
||||||
|
for (textEditor in fileEditorManager.allEditors.filterIsInstance<TextEditor>()) {
|
||||||
|
fileEditorManager.openFile(textEditor.file, true)
|
||||||
|
|
||||||
|
val editor = textEditor.editor
|
||||||
|
val vimEditor = editor.vim
|
||||||
|
|
||||||
|
vimEditor.mode = com.maddyhome.idea.vim.state.mode.Mode.NORMAL()
|
||||||
|
KeyHandler.Companion.getInstance().reset(vimEditor)
|
||||||
|
|
||||||
|
val startMarkAction = StartMarkAction.start(editor, project, commandName)
|
||||||
|
playbackRegisterAction.execute(vimEditor, context, cmd, operatorArguments)
|
||||||
|
FinishMarkAction.finish(project, editor, startMarkAction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CommandProcessor.getInstance()
|
||||||
|
.executeCommand(project, action, commandName, null, UndoConfirmationPolicy.REQUEST_CONFIRMATION)
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
package com.maddyhome.idea.vim.config
|
package com.maddyhome.idea.vim.config
|
||||||
|
|
||||||
|
import com.maddyhome.idea.vim.VimPlugin
|
||||||
import org.jdom.Element
|
import org.jdom.Element
|
||||||
import kotlin.properties.ReadWriteProperty
|
import kotlin.properties.ReadWriteProperty
|
||||||
import kotlin.reflect.KProperty
|
import kotlin.reflect.KProperty
|
||||||
@@ -16,11 +17,11 @@ import kotlin.reflect.KProperty
|
|||||||
* @author Alex Plate
|
* @author Alex Plate
|
||||||
*/
|
*/
|
||||||
class VimState {
|
class VimState {
|
||||||
var isIdeaJoinNotified by StateProperty("idea-join")
|
var isIdeaJoinNotified get() = true; set(_) = Unit
|
||||||
var isIdeaPutNotified by StateProperty("idea-put")
|
var isIdeaPutNotified get() = true; set(_) = Unit
|
||||||
var wasSubscribedToEAPAutomatically by StateProperty("was-automatically-subscribed-to-eap")
|
var wasSubscribedToEAPAutomatically get() = false; set(_) = Unit
|
||||||
var firstIdeaVimVersion: String? by StringProperty("first-ideavim-version", null)
|
var firstIdeaVimVersion: String? get() = "1"; set(_) = Unit
|
||||||
var lastIdeaVimVersion: String? by StringProperty("last-ideavim-version", null)
|
var lastIdeaVimVersion: String? get() = VimPlugin.getVersion(); set(_) = Unit
|
||||||
|
|
||||||
fun readData(element: Element) {
|
fun readData(element: Element) {
|
||||||
val notifications = element.getChild("notifications")
|
val notifications = element.getChild("notifications")
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ private object FileTypePatterns {
|
|||||||
} else if (fileTypeName == "CMakeLists.txt" || fileName == "CMakeLists") {
|
} else if (fileTypeName == "CMakeLists.txt" || fileName == "CMakeLists") {
|
||||||
this.cMakePatterns
|
this.cMakePatterns
|
||||||
} else {
|
} else {
|
||||||
return null
|
this.htmlPatterns
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.maddyhome.idea.vim.extension.surround
|
||||||
|
|
||||||
|
import com.intellij.util.text.CharSequenceSubSequence
|
||||||
|
|
||||||
|
internal data class RepeatedCharSequence(val text: CharSequence, val count: Int) : CharSequence {
|
||||||
|
override val length = text.length * count
|
||||||
|
|
||||||
|
override fun get(index: Int): Char {
|
||||||
|
if (index < 0 || index >= length) throw IndexOutOfBoundsException()
|
||||||
|
return text[index % text.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun subSequence(startIndex: Int, endIndex: Int): CharSequence {
|
||||||
|
return CharSequenceSubSequence(this, startIndex, endIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString(): String {
|
||||||
|
return text.repeat(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun of(text: CharSequence, count: Int): CharSequence {
|
||||||
|
return when (count) {
|
||||||
|
0 -> ""
|
||||||
|
1 -> text
|
||||||
|
else -> RepeatedCharSequence(text, count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import com.maddyhome.idea.vim.KeyHandler
|
|||||||
import com.maddyhome.idea.vim.VimPlugin
|
import com.maddyhome.idea.vim.VimPlugin
|
||||||
import com.maddyhome.idea.vim.api.ExecutionContext
|
import com.maddyhome.idea.vim.api.ExecutionContext
|
||||||
import com.maddyhome.idea.vim.api.VimCaret
|
import com.maddyhome.idea.vim.api.VimCaret
|
||||||
|
import com.maddyhome.idea.vim.api.VimChangeGroup
|
||||||
import com.maddyhome.idea.vim.api.VimEditor
|
import com.maddyhome.idea.vim.api.VimEditor
|
||||||
import com.maddyhome.idea.vim.api.endsWithNewLine
|
import com.maddyhome.idea.vim.api.endsWithNewLine
|
||||||
import com.maddyhome.idea.vim.api.getLeadingCharacterOffset
|
import com.maddyhome.idea.vim.api.getLeadingCharacterOffset
|
||||||
@@ -36,7 +37,10 @@ import com.maddyhome.idea.vim.extension.VimExtensionFacade.setRegisterForCaret
|
|||||||
import com.maddyhome.idea.vim.extension.exportOperatorFunction
|
import com.maddyhome.idea.vim.extension.exportOperatorFunction
|
||||||
import com.maddyhome.idea.vim.group.findBlockRange
|
import com.maddyhome.idea.vim.group.findBlockRange
|
||||||
import com.maddyhome.idea.vim.helper.exitVisualMode
|
import com.maddyhome.idea.vim.helper.exitVisualMode
|
||||||
|
import com.maddyhome.idea.vim.helper.runWithEveryCaretAndRestore
|
||||||
import com.maddyhome.idea.vim.key.OperatorFunction
|
import com.maddyhome.idea.vim.key.OperatorFunction
|
||||||
|
import com.maddyhome.idea.vim.newapi.IjVimCaret
|
||||||
|
import com.maddyhome.idea.vim.newapi.IjVimEditor
|
||||||
import com.maddyhome.idea.vim.newapi.ij
|
import com.maddyhome.idea.vim.newapi.ij
|
||||||
import com.maddyhome.idea.vim.newapi.vim
|
import com.maddyhome.idea.vim.newapi.vim
|
||||||
import com.maddyhome.idea.vim.options.helpers.ClipboardOptionHelper
|
import com.maddyhome.idea.vim.options.helpers.ClipboardOptionHelper
|
||||||
@@ -139,7 +143,7 @@ internal class VimSurroundExtension : VimExtension {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
VimExtensionFacade.exportOperatorFunction(OPERATOR_FUNC, Operator())
|
VimExtensionFacade.exportOperatorFunction(OPERATOR_FUNC, Operator(supportsMultipleCursors = false, count = 1)) // TODO
|
||||||
}
|
}
|
||||||
|
|
||||||
private class YSurroundHandler : ExtensionHandler {
|
private class YSurroundHandler : ExtensionHandler {
|
||||||
@@ -166,7 +170,7 @@ internal class VimSurroundExtension : VimExtension {
|
|||||||
val lastNonWhiteSpaceOffset = getLastNonWhitespaceCharacterOffset(editor.text(), lineStartOffset, lineEndOffset)
|
val lastNonWhiteSpaceOffset = getLastNonWhitespaceCharacterOffset(editor.text(), lineStartOffset, lineEndOffset)
|
||||||
if (lastNonWhiteSpaceOffset != null) {
|
if (lastNonWhiteSpaceOffset != null) {
|
||||||
val range = TextRange(lineStartOffset, lastNonWhiteSpaceOffset + 1)
|
val range = TextRange(lineStartOffset, lastNonWhiteSpaceOffset + 1)
|
||||||
performSurround(pair, range, it)
|
performSurround(pair, range, it, count = operatorArguments.count1)
|
||||||
}
|
}
|
||||||
// it.moveToOffset(lineStartOffset)
|
// it.moveToOffset(lineStartOffset)
|
||||||
}
|
}
|
||||||
@@ -189,15 +193,13 @@ internal class VimSurroundExtension : VimExtension {
|
|||||||
|
|
||||||
private class VSurroundHandler : ExtensionHandler {
|
private class VSurroundHandler : ExtensionHandler {
|
||||||
override fun execute(editor: VimEditor, context: ExecutionContext, operatorArguments: OperatorArguments) {
|
override fun execute(editor: VimEditor, context: ExecutionContext, operatorArguments: OperatorArguments) {
|
||||||
val selectionStart = editor.ij.caretModel.primaryCaret.selectionStart
|
|
||||||
// NB: Operator ignores SelectionType anyway
|
// NB: Operator ignores SelectionType anyway
|
||||||
if (!Operator().apply(editor, context, editor.mode.selectionType)) {
|
if (!Operator(supportsMultipleCursors = true, count = operatorArguments.count1).apply(editor, context, editor.mode.selectionType)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
runWriteAction {
|
runWriteAction {
|
||||||
// Leave visual mode
|
// Leave visual mode
|
||||||
editor.exitVisualMode()
|
editor.exitVisualMode()
|
||||||
editor.ij.caretModel.moveToOffset(selectionStart)
|
|
||||||
|
|
||||||
// Reset the key handler so that the command trie is updated for the new mode (Normal)
|
// Reset the key handler so that the command trie is updated for the new mode (Normal)
|
||||||
// TODO: This should probably be handled by ToHandlerMapping.execute
|
// TODO: This should probably be handled by ToHandlerMapping.execute
|
||||||
@@ -220,6 +222,10 @@ internal class VimSurroundExtension : VimExtension {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun change(editor: VimEditor, context: ExecutionContext, charFrom: Char, newSurround: SurroundPair?) {
|
fun change(editor: VimEditor, context: ExecutionContext, charFrom: Char, newSurround: SurroundPair?) {
|
||||||
|
editor.ij.runWithEveryCaretAndRestore { changeAtCaret(editor, context, charFrom, newSurround) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun changeAtCaret(editor: VimEditor, context: ExecutionContext, charFrom: Char, newSurround: SurroundPair?) {
|
||||||
// Save old register values for carets
|
// Save old register values for carets
|
||||||
val surroundings = editor.sortedCarets()
|
val surroundings = editor.sortedCarets()
|
||||||
.map {
|
.map {
|
||||||
@@ -342,20 +348,41 @@ internal class VimSurroundExtension : VimExtension {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class Operator : OperatorFunction {
|
private class Operator(private val supportsMultipleCursors: Boolean, private val count: Int) : OperatorFunction {
|
||||||
override fun apply(editor: VimEditor, context: ExecutionContext, selectionType: SelectionType?): Boolean {
|
override fun apply(editor: VimEditor, context: ExecutionContext, selectionType: SelectionType?): Boolean {
|
||||||
val ijEditor = editor.ij
|
val ijEditor = editor.ij
|
||||||
val c = injector.keyGroup.getChar(editor) ?: return true
|
val c = injector.keyGroup.getChar(editor) ?: return true
|
||||||
|
|
||||||
val pair = getOrInputPair(c, ijEditor, context.ij) ?: return false
|
val pair = getOrInputPair(c, ijEditor, context.ij) ?: return false
|
||||||
// XXX: Will it work with line-wise or block-wise selections?
|
|
||||||
val range = getSurroundRange(editor.currentCaret()) ?: return false
|
runWriteAction {
|
||||||
performSurround(pair, range, editor.currentCaret(), selectionType == SelectionType.LINE_WISE)
|
val change = VimPlugin.getChange()
|
||||||
// Jump back to start
|
if (supportsMultipleCursors) {
|
||||||
executeNormalWithoutMapping(injector.parser.parseKeys("`["), ijEditor)
|
ijEditor.runWithEveryCaretAndRestore {
|
||||||
|
applyOnce(ijEditor, change, pair, count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
applyOnce(ijEditor, change, pair, count)
|
||||||
|
// Jump back to start
|
||||||
|
executeNormalWithoutMapping(injector.parser.parseKeys("`["), ijEditor)
|
||||||
|
}
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun applyOnce(editor: Editor, change: VimChangeGroup, pair: SurroundPair, count: Int) {
|
||||||
|
// XXX: Will it work with line-wise or block-wise selections?
|
||||||
|
val primaryCaret = editor.caretModel.primaryCaret
|
||||||
|
val range = getSurroundRange(primaryCaret.vim)
|
||||||
|
if (range != null) {
|
||||||
|
val start = RepeatedCharSequence.of(pair.first, count)
|
||||||
|
val end = RepeatedCharSequence.of(pair.second, count)
|
||||||
|
change.insertText(IjVimEditor(editor), IjVimCaret(primaryCaret), range.startOffset, start)
|
||||||
|
change.insertText(IjVimEditor(editor), IjVimCaret(primaryCaret), range.endOffset + start.length, end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun getSurroundRange(caret: VimCaret): TextRange? {
|
private fun getSurroundRange(caret: VimCaret): TextRange? {
|
||||||
val editor = caret.editor
|
val editor = caret.editor
|
||||||
if (editor.mode is Mode.CMD_LINE) {
|
if (editor.mode is Mode.CMD_LINE) {
|
||||||
@@ -444,14 +471,14 @@ private fun getOrInputPair(c: Char, editor: Editor, context: DataContext): Surro
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private fun performSurround(pair: SurroundPair, range: TextRange, caret: VimCaret, tagsOnNewLines: Boolean = false) {
|
private fun performSurround(pair: SurroundPair, range: TextRange, caret: VimCaret, count: Int, tagsOnNewLines: Boolean = false) {
|
||||||
val editor = caret.editor
|
val editor = caret.editor
|
||||||
val change = VimPlugin.getChange()
|
val change = VimPlugin.getChange()
|
||||||
val leftSurround = pair.first + if (tagsOnNewLines) "\n" else ""
|
val leftSurround = RepeatedCharSequence.of(pair.first + if (tagsOnNewLines) "\n" else "", count)
|
||||||
|
|
||||||
val isEOF = range.endOffset == editor.text().length
|
val isEOF = range.endOffset == editor.text().length
|
||||||
val hasNewLine = editor.endsWithNewLine()
|
val hasNewLine = editor.endsWithNewLine()
|
||||||
val rightSurround = if (tagsOnNewLines) {
|
val rightSurround = (if (tagsOnNewLines) {
|
||||||
if (isEOF && !hasNewLine) {
|
if (isEOF && !hasNewLine) {
|
||||||
"\n" + pair.second
|
"\n" + pair.second
|
||||||
} else {
|
} else {
|
||||||
@@ -459,7 +486,7 @@ private fun performSurround(pair: SurroundPair, range: TextRange, caret: VimCare
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
pair.second
|
pair.second
|
||||||
}
|
}).let { RepeatedCharSequence.of(it, count) }
|
||||||
|
|
||||||
change.insertText(editor, caret, range.startOffset, leftSurround)
|
change.insertText(editor, caret, range.startOffset, leftSurround)
|
||||||
change.insertText(editor, caret, range.endOffset + leftSurround.length, rightSurround)
|
change.insertText(editor, caret, range.endOffset + leftSurround.length, rightSurround)
|
||||||
|
|||||||
+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",
|
||||||
@@ -147,7 +158,7 @@ object IjOptions {
|
|||||||
"closenotebooks"
|
"closenotebooks"
|
||||||
)
|
)
|
||||||
val ideaoldundo: ToggleOption =
|
val ideaoldundo: ToggleOption =
|
||||||
addOption(ToggleOption("ideaoldundo", GLOBAL, "ideaoldundo", false, isHidden = true), "oldundo")
|
addOption(ToggleOption("ideaoldundo", GLOBAL, "ideaoldundo", true, isHidden = true), "oldundo")
|
||||||
val ideaunifyjumps: ToggleOption =
|
val ideaunifyjumps: ToggleOption =
|
||||||
addOption(ToggleOption("ideaunifyjumps", GLOBAL, "ideaunifyjumps", true, isHidden = true), "unifyjumps")
|
addOption(ToggleOption("ideaunifyjumps", GLOBAL, "ideaunifyjumps", true, isHidden = true), "unifyjumps")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package com.maddyhome.idea.vim.group
|
||||||
|
|
||||||
|
import com.intellij.codeInsight.daemon.ReferenceImporter
|
||||||
|
import com.intellij.openapi.actionSystem.CommonDataKeys
|
||||||
|
import com.intellij.openapi.actionSystem.DataContext
|
||||||
|
import com.intellij.openapi.application.ApplicationManager
|
||||||
|
import com.intellij.openapi.application.ReadAction
|
||||||
|
import com.intellij.openapi.command.WriteCommandAction
|
||||||
|
import com.intellij.openapi.editor.Editor
|
||||||
|
import com.intellij.openapi.fileEditor.FileDocumentManager
|
||||||
|
import com.intellij.openapi.progress.ProgressIndicator
|
||||||
|
import com.intellij.openapi.progress.ProgressManager
|
||||||
|
import com.intellij.openapi.progress.Task
|
||||||
|
import com.intellij.psi.PsiDocumentManager
|
||||||
|
import com.intellij.psi.PsiElement
|
||||||
|
import com.intellij.psi.PsiRecursiveElementWalkingVisitor
|
||||||
|
import java.util.function.BooleanSupplier
|
||||||
|
|
||||||
|
internal object MacroAutoImport {
|
||||||
|
fun run(editor: Editor, dataContext: DataContext) {
|
||||||
|
val project = CommonDataKeys.PROJECT.getData(dataContext) ?: return
|
||||||
|
val file = PsiDocumentManager.getInstance(project).getPsiFile(editor.document) ?: return
|
||||||
|
|
||||||
|
if (!FileDocumentManager.getInstance().requestWriting(editor.document, project)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val importers = ReferenceImporter.EP_NAME.extensionList
|
||||||
|
if (importers.isEmpty()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ProgressManager.getInstance().run(object : Task.Backgroundable(project, "Auto import", true) {
|
||||||
|
override fun run(indicator: ProgressIndicator) {
|
||||||
|
val fixes = ReadAction.nonBlocking<List<BooleanSupplier>> {
|
||||||
|
val fixes = mutableListOf<BooleanSupplier>()
|
||||||
|
|
||||||
|
file.accept(object : PsiRecursiveElementWalkingVisitor() {
|
||||||
|
override fun visitElement(element: PsiElement) {
|
||||||
|
for (reference in element.references) {
|
||||||
|
if (reference.resolve() != null) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for (importer in importers) {
|
||||||
|
importer.computeAutoImportAtOffset(editor, file, element.textRange.startOffset, true)
|
||||||
|
?.let(fixes::add)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
super.visitElement(element)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return@nonBlocking fixes
|
||||||
|
}.executeSynchronously()
|
||||||
|
|
||||||
|
ApplicationManager.getApplication().invokeAndWait {
|
||||||
|
WriteCommandAction.writeCommandAction(project)
|
||||||
|
.withName("Auto Import")
|
||||||
|
.withGroupId("IdeaVimAutoImportAfterMacro")
|
||||||
|
.shouldRecordActionForActiveDocument(true)
|
||||||
|
.run<RuntimeException> {
|
||||||
|
fixes.forEach { it.asBoolean }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -100,6 +100,9 @@ class MacroGroup : VimMacroBase() {
|
|||||||
} finally {
|
} finally {
|
||||||
keyStack.removeFirst()
|
keyStack.removeFirst()
|
||||||
}
|
}
|
||||||
|
if (!isInternalMacro) {
|
||||||
|
MacroAutoImport.run(editor.ij, context.ij)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isInternalMacro) {
|
if (isInternalMacro) {
|
||||||
|
|||||||
@@ -24,12 +24,11 @@ import com.maddyhome.idea.vim.api.ImmutableVimCaret
|
|||||||
import com.maddyhome.idea.vim.api.VimChangeGroupBase
|
import com.maddyhome.idea.vim.api.VimChangeGroupBase
|
||||||
import com.maddyhome.idea.vim.api.VimEditor
|
import com.maddyhome.idea.vim.api.VimEditor
|
||||||
import com.maddyhome.idea.vim.api.VimMotionGroupBase
|
import com.maddyhome.idea.vim.api.VimMotionGroupBase
|
||||||
|
import com.maddyhome.idea.vim.api.VirtualBufferKind
|
||||||
import com.maddyhome.idea.vim.api.anyNonWhitespace
|
import com.maddyhome.idea.vim.api.anyNonWhitespace
|
||||||
import com.maddyhome.idea.vim.api.getLeadingCharacterOffset
|
import com.maddyhome.idea.vim.api.getLeadingCharacterOffset
|
||||||
import com.maddyhome.idea.vim.api.getVisualLineCount
|
import com.maddyhome.idea.vim.api.getVisualLineCount
|
||||||
import com.maddyhome.idea.vim.api.VirtualBufferKind
|
|
||||||
import com.maddyhome.idea.vim.api.injector
|
import com.maddyhome.idea.vim.api.injector
|
||||||
import com.maddyhome.idea.vim.helper.CmdwinKeys
|
|
||||||
import com.maddyhome.idea.vim.api.lineLength
|
import com.maddyhome.idea.vim.api.lineLength
|
||||||
import com.maddyhome.idea.vim.api.normalizeOffset
|
import com.maddyhome.idea.vim.api.normalizeOffset
|
||||||
import com.maddyhome.idea.vim.api.normalizeVisualLine
|
import com.maddyhome.idea.vim.api.normalizeVisualLine
|
||||||
@@ -44,6 +43,7 @@ import com.maddyhome.idea.vim.handler.Motion
|
|||||||
import com.maddyhome.idea.vim.handler.Motion.AbsoluteOffset
|
import com.maddyhome.idea.vim.handler.Motion.AbsoluteOffset
|
||||||
import com.maddyhome.idea.vim.handler.MotionActionHandler
|
import com.maddyhome.idea.vim.handler.MotionActionHandler
|
||||||
import com.maddyhome.idea.vim.handler.TextObjectActionHandler
|
import com.maddyhome.idea.vim.handler.TextObjectActionHandler
|
||||||
|
import com.maddyhome.idea.vim.helper.CmdwinKeys
|
||||||
import com.maddyhome.idea.vim.helper.EditorHelper
|
import com.maddyhome.idea.vim.helper.EditorHelper
|
||||||
import com.maddyhome.idea.vim.helper.exitVisualMode
|
import com.maddyhome.idea.vim.helper.exitVisualMode
|
||||||
import com.maddyhome.idea.vim.helper.getNormalizedScrollOffset
|
import com.maddyhome.idea.vim.helper.getNormalizedScrollOffset
|
||||||
@@ -126,6 +126,9 @@ class MotionGroup : VimMotionGroupBase() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun moveCaretToCurrentDisplayLineStart(editor: VimEditor, caret: ImmutableVimCaret): Motion {
|
override fun moveCaretToCurrentDisplayLineStart(editor: VimEditor, caret: ImmutableVimCaret): Motion {
|
||||||
|
if (editor.ij.softWrapModel.isSoftWrappingEnabled) {
|
||||||
|
return AbsoluteOffset(caret.ij.visualLineStart)
|
||||||
|
}
|
||||||
val col = EditorHelper.getVisualColumnAtLeftOfDisplay(editor.ij, caret.getVisualPosition().line)
|
val col = EditorHelper.getVisualColumnAtLeftOfDisplay(editor.ij, caret.getVisualPosition().line)
|
||||||
return moveCaretToColumn(editor, caret, col, false)
|
return moveCaretToColumn(editor, caret, col, false)
|
||||||
}
|
}
|
||||||
@@ -134,6 +137,15 @@ class MotionGroup : VimMotionGroupBase() {
|
|||||||
editor: VimEditor,
|
editor: VimEditor,
|
||||||
caret: ImmutableVimCaret,
|
caret: ImmutableVimCaret,
|
||||||
): @Range(from = 0, to = Int.MAX_VALUE.toLong()) Int {
|
): @Range(from = 0, to = Int.MAX_VALUE.toLong()) Int {
|
||||||
|
if (editor.ij.softWrapModel.isSoftWrappingEnabled) {
|
||||||
|
val offset = caret.ij.visualLineStart
|
||||||
|
val line = editor.offsetToBufferPosition(offset).line
|
||||||
|
return if (offset == editor.getLineStartOffset(line)) {
|
||||||
|
editor.getLeadingCharacterOffset(line, 0)
|
||||||
|
} else {
|
||||||
|
offset
|
||||||
|
}
|
||||||
|
}
|
||||||
val col = EditorHelper.getVisualColumnAtLeftOfDisplay(editor.ij, caret.getVisualPosition().line)
|
val col = EditorHelper.getVisualColumnAtLeftOfDisplay(editor.ij, caret.getVisualPosition().line)
|
||||||
val bufferLine = caret.getLine()
|
val bufferLine = caret.getLine()
|
||||||
return editor.getLeadingCharacterOffset(bufferLine, col)
|
return editor.getLeadingCharacterOffset(bufferLine, col)
|
||||||
@@ -144,6 +156,9 @@ class MotionGroup : VimMotionGroupBase() {
|
|||||||
caret: ImmutableVimCaret,
|
caret: ImmutableVimCaret,
|
||||||
allowEnd: Boolean,
|
allowEnd: Boolean,
|
||||||
): Motion {
|
): Motion {
|
||||||
|
if (editor.ij.softWrapModel.isSoftWrappingEnabled) {
|
||||||
|
return AbsoluteOffset(caret.ij.visualLineEnd - 1)
|
||||||
|
}
|
||||||
val col = EditorHelper.getVisualColumnAtRightOfDisplay(editor.ij, caret.getVisualPosition().line)
|
val col = EditorHelper.getVisualColumnAtRightOfDisplay(editor.ij, caret.getVisualPosition().line)
|
||||||
return moveCaretToColumn(editor, caret, col, allowEnd)
|
return moveCaretToColumn(editor, caret, col, allowEnd)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,10 @@
|
|||||||
package com.maddyhome.idea.vim.helper
|
package com.maddyhome.idea.vim.helper
|
||||||
|
|
||||||
import com.intellij.openapi.actionSystem.AnActionEvent
|
import com.intellij.openapi.actionSystem.AnActionEvent
|
||||||
|
import com.intellij.openapi.util.SystemInfoRt
|
||||||
|
import com.intellij.openapi.util.registry.Registry
|
||||||
import com.intellij.ui.KeyStrokeAdapter
|
import com.intellij.ui.KeyStrokeAdapter
|
||||||
|
import java.awt.event.InputEvent
|
||||||
import java.awt.event.KeyEvent
|
import java.awt.event.KeyEvent
|
||||||
import javax.swing.KeyStroke
|
import javax.swing.KeyStroke
|
||||||
|
|
||||||
@@ -24,12 +27,30 @@ internal class ActionEventKeyStrokeExtractor {
|
|||||||
val defaultKeyStroke = KeyStrokeAdapter.getDefaultKeyStroke(inputEvent)
|
val defaultKeyStroke = KeyStrokeAdapter.getDefaultKeyStroke(inputEvent)
|
||||||
val strokeCache = keyStrokeCache
|
val strokeCache = keyStrokeCache
|
||||||
if (defaultKeyStroke != null) {
|
if (defaultKeyStroke != null) {
|
||||||
keyStrokeCache = inputEvent.`when` to defaultKeyStroke
|
val fixedKeyStroke = fixKeyStroke(defaultKeyStroke)
|
||||||
return defaultKeyStroke
|
keyStrokeCache = inputEvent.`when` to fixedKeyStroke
|
||||||
|
return fixedKeyStroke
|
||||||
} else if (strokeCache.first == inputEvent.`when`) {
|
} else if (strokeCache.first == inputEvent.`when`) {
|
||||||
keyStrokeCache = null to null
|
keyStrokeCache = null to null
|
||||||
return strokeCache.second
|
return strokeCache.second
|
||||||
}
|
}
|
||||||
return KeyStroke.getKeyStrokeForEvent(inputEvent)
|
return KeyStroke.getKeyStrokeForEvent(inputEvent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun fixKeyStroke(key: KeyStroke): KeyStroke {
|
||||||
|
return if (
|
||||||
|
key.modifiers and CTRL_ALT_MASK != 0 &&
|
||||||
|
key.isOnKeyRelease &&
|
||||||
|
SystemInfoRt.isWindows &&
|
||||||
|
Registry.`is`("actionSystem.fix.alt.gr", true)
|
||||||
|
) {
|
||||||
|
KeyStroke.getKeyStroke(key.keyCode, key.modifiers)
|
||||||
|
} else {
|
||||||
|
key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val CTRL_ALT_MASK = InputEvent.CTRL_DOWN_MASK or InputEvent.ALT_DOWN_MASK
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -385,7 +387,7 @@ public class EditorHelper {
|
|||||||
final int offset =
|
final int offset =
|
||||||
y - getStickyLinesPanelHeight(editor) - ((screenHeight - lineHeight) / lineHeight / 2 * lineHeight);
|
y - getStickyLinesPanelHeight(editor) - ((screenHeight - lineHeight) / lineHeight / 2 * lineHeight);
|
||||||
final @NotNull VimEditor editor1 = new IjVimEditor(editor);
|
final @NotNull VimEditor editor1 = new IjVimEditor(editor);
|
||||||
final int lastVisualLine = EngineEditorHelperKt.getVisualLineCount(editor1) - 1;
|
final int lastVisualLine = EngineEditorHelperKt.getVisualLineCount(editor1) + editor.getSettings().getAdditionalLinesCount();
|
||||||
final int offsetForLastLineAtBottom = getOffsetToScrollVisualLineToBottomOfScreen(editor, lastVisualLine);
|
final int offsetForLastLineAtBottom = getOffsetToScrollVisualLineToBottomOfScreen(editor, lastVisualLine);
|
||||||
|
|
||||||
// For `zz`, we want to use virtual space and move any line, including the last one, to the middle of the screen.
|
// For `zz`, we want to use virtual space and move any line, including the last one, to the middle of the screen.
|
||||||
@@ -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?`).
|
||||||
|
|||||||
@@ -12,9 +12,12 @@ package com.maddyhome.idea.vim.helper
|
|||||||
|
|
||||||
import com.intellij.codeWithMe.ClientId
|
import com.intellij.codeWithMe.ClientId
|
||||||
import com.intellij.openapi.editor.Caret
|
import com.intellij.openapi.editor.Caret
|
||||||
|
import com.intellij.openapi.editor.CaretState
|
||||||
import com.intellij.openapi.editor.Editor
|
import com.intellij.openapi.editor.Editor
|
||||||
|
import com.intellij.openapi.editor.EditorKind
|
||||||
import com.intellij.openapi.editor.ex.util.EditorUtil
|
import com.intellij.openapi.editor.ex.util.EditorUtil
|
||||||
import com.intellij.openapi.fileEditor.ex.FileEditorManagerEx
|
import com.intellij.openapi.fileEditor.ex.FileEditorManagerEx
|
||||||
|
import com.intellij.openapi.ui.popup.util.PopupUtil
|
||||||
import com.intellij.util.ui.table.JBTableRowEditor
|
import com.intellij.util.ui.table.JBTableRowEditor
|
||||||
import com.maddyhome.idea.vim.VimPlugin
|
import com.maddyhome.idea.vim.VimPlugin
|
||||||
import com.maddyhome.idea.vim.api.StringListOptionValue
|
import com.maddyhome.idea.vim.api.StringListOptionValue
|
||||||
@@ -22,6 +25,8 @@ import com.maddyhome.idea.vim.api.injector
|
|||||||
import com.maddyhome.idea.vim.group.IjOptionConstants
|
import com.maddyhome.idea.vim.group.IjOptionConstants
|
||||||
import com.maddyhome.idea.vim.key.IdeaVimDisablerExtensionPoint
|
import com.maddyhome.idea.vim.key.IdeaVimDisablerExtensionPoint
|
||||||
import com.maddyhome.idea.vim.newapi.globalIjOptions
|
import com.maddyhome.idea.vim.newapi.globalIjOptions
|
||||||
|
import com.maddyhome.idea.vim.newapi.vim
|
||||||
|
import com.maddyhome.idea.vim.state.mode.inBlockSelection
|
||||||
import java.awt.Component
|
import java.awt.Component
|
||||||
import javax.swing.JComponent
|
import javax.swing.JComponent
|
||||||
import javax.swing.JTable
|
import javax.swing.JTable
|
||||||
@@ -62,17 +67,33 @@ 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) {
|
||||||
|
return PopupUtil.getPopupContainerFor(component) == null
|
||||||
|
}
|
||||||
|
|
||||||
return EditorHelper.isCommitWindowEditor(this)
|
return EditorHelper.isCommitWindowEditor(this)
|
||||||
|| EditorHelper.isKotlinClassDecompiledToJavaFile(this)
|
|| EditorHelper.isKotlinClassDecompiledToJavaFile(this)
|
||||||
|| 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 {
|
||||||
@@ -112,10 +133,7 @@ internal fun Editor.isPrimaryEditor(): Boolean {
|
|||||||
internal fun Editor.isTerminalEditor(): Boolean {
|
internal fun Editor.isTerminalEditor(): Boolean {
|
||||||
return !isViewer
|
return !isViewer
|
||||||
&& document.isWritable
|
&& document.isWritable
|
||||||
&& !EditorHelper.isFileEditor(this)
|
&& this.editorKind == EditorKind.CONSOLE
|
||||||
&& !EditorHelper.isDiffEditor(this)
|
|
||||||
&& !EditorHelper.isCommandHistoryWindow(this)
|
|
||||||
&& !EditorHelper.isPythonConsole(this)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optimized clone of com.intellij.ide.ui.laf.darcula.DarculaUIUtil.isTableCellEditor
|
// Optimized clone of com.intellij.ide.ui.laf.darcula.DarculaUIUtil.isTableCellEditor
|
||||||
@@ -144,3 +162,41 @@ private fun vimEnabled(editor: Editor?): Boolean {
|
|||||||
if (editor != null && editor.isIdeaVimDisabledHere) return false
|
if (editor != null && editor.isIdeaVimDisabledHere) return false
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal inline fun Editor.runWithEveryCaretAndRestore(action: () -> Unit) {
|
||||||
|
val caretModel = this.caretModel
|
||||||
|
val carets = if (this.vim.inBlockSelection) null else caretModel.allCarets
|
||||||
|
if (carets == null || carets.size == 1) {
|
||||||
|
action()
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
var initialDocumentSize = this.document.textLength
|
||||||
|
var documentSizeDifference = 0
|
||||||
|
|
||||||
|
val caretOffsets = carets.map { it.selectionStart to it.selectionEnd }
|
||||||
|
val restoredCarets = mutableListOf<CaretState>()
|
||||||
|
|
||||||
|
caretModel.removeSecondaryCarets()
|
||||||
|
|
||||||
|
for ((selectionStart, selectionEnd) in caretOffsets) {
|
||||||
|
if (selectionStart == selectionEnd) {
|
||||||
|
caretModel.primaryCaret.moveToOffset(selectionStart + documentSizeDifference)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
caretModel.primaryCaret.setSelection(
|
||||||
|
selectionStart + documentSizeDifference,
|
||||||
|
selectionEnd + documentSizeDifference
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
action()
|
||||||
|
restoredCarets.add(caretModel.caretsAndSelections.single())
|
||||||
|
|
||||||
|
val documentLength = this.document.textLength
|
||||||
|
documentSizeDifference += documentLength - initialDocumentSize
|
||||||
|
initialDocumentSize = documentLength
|
||||||
|
}
|
||||||
|
|
||||||
|
caretModel.caretsAndSelections = restoredCarets
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import com.intellij.openapi.diagnostic.thisLogger
|
|||||||
import com.intellij.openapi.editor.actionSystem.DocCommandGroupId
|
import com.intellij.openapi.editor.actionSystem.DocCommandGroupId
|
||||||
import com.intellij.openapi.progress.util.ProgressIndicatorUtils
|
import com.intellij.openapi.progress.util.ProgressIndicatorUtils
|
||||||
import com.intellij.openapi.util.NlsContexts
|
import com.intellij.openapi.util.NlsContexts
|
||||||
|
import com.intellij.refactoring.actions.BaseRefactoringAction
|
||||||
import com.maddyhome.idea.vim.RegisterActions
|
import com.maddyhome.idea.vim.RegisterActions
|
||||||
import com.maddyhome.idea.vim.api.ExecutionContext
|
import com.maddyhome.idea.vim.api.ExecutionContext
|
||||||
import com.maddyhome.idea.vim.api.NativeAction
|
import com.maddyhome.idea.vim.api.NativeAction
|
||||||
@@ -35,6 +36,8 @@ import com.maddyhome.idea.vim.handler.EditorActionHandlerBase
|
|||||||
import com.maddyhome.idea.vim.newapi.IjNativeAction
|
import com.maddyhome.idea.vim.newapi.IjNativeAction
|
||||||
import com.maddyhome.idea.vim.newapi.IjVimDocument
|
import com.maddyhome.idea.vim.newapi.IjVimDocument
|
||||||
import com.maddyhome.idea.vim.newapi.ij
|
import com.maddyhome.idea.vim.newapi.ij
|
||||||
|
import com.maddyhome.idea.vim.state.mode.Mode
|
||||||
|
import com.maddyhome.idea.vim.state.mode.inVisualMode
|
||||||
import org.jetbrains.annotations.NonNls
|
import org.jetbrains.annotations.NonNls
|
||||||
import java.awt.Component
|
import java.awt.Component
|
||||||
import javax.swing.JComponent
|
import javax.swing.JComponent
|
||||||
@@ -70,15 +73,35 @@ class IjActionExecutor : VimActionExecutor {
|
|||||||
thisLogger().error("Actions cannot be updated when write-action is running or pending")
|
thisLogger().error("Actions cannot be updated when write-action is running or pending")
|
||||||
}
|
}
|
||||||
|
|
||||||
return executeAction((action as IjNativeAction).action, editor?.ij?.contentComponent)
|
return executeAction(editor, (action as IjNativeAction).action, editor?.ij?.contentComponent)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun executeAction(ijAction: AnAction, contextComponent: Component?): Boolean {
|
private fun executeAction(editor: VimEditor?, ijAction: AnAction, contextComponent: Component?): Boolean {
|
||||||
|
val startVisualModeType = (editor?.mode as? Mode.VISUAL)?.selectionType
|
||||||
|
val startVisualCaretSelection = if (editor != null && startVisualModeType != null && ijAction !is BaseRefactoringAction)
|
||||||
|
editor.primaryCaret().let { Triple(it.offset, it.selectionStart, it.selectionEnd) }
|
||||||
|
else
|
||||||
|
null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
isRunningActionFromVim = true
|
isRunningActionFromVim = true
|
||||||
val place = ijAction.choosePlace()
|
val place = ijAction.choosePlace()
|
||||||
val res = ActionManager.getInstance().tryToExecute(ijAction, null, contextComponent, place, true)
|
val res = ActionManager.getInstance().tryToExecute(ijAction, null, contextComponent, place, true)
|
||||||
res.waitFor(5_000)
|
res.waitFor(5_000)
|
||||||
|
|
||||||
|
if (startVisualModeType != null && startVisualCaretSelection != null) {
|
||||||
|
val primaryCaret = editor.primaryCaret()
|
||||||
|
val endVisualCaretOffset = primaryCaret.offset
|
||||||
|
if (startVisualCaretSelection.first != endVisualCaretOffset) {
|
||||||
|
if (!editor.inVisualMode || (editor.mode as Mode.VISUAL).selectionType != startVisualModeType) {
|
||||||
|
injector.visualMotionGroup.toggleVisual(editor, 1, 0, startVisualModeType)
|
||||||
|
}
|
||||||
|
primaryCaret.moveToOffset(startVisualCaretSelection.first)
|
||||||
|
primaryCaret.setSelection(startVisualCaretSelection.second, startVisualCaretSelection.third)
|
||||||
|
primaryCaret.moveToOffset(endVisualCaretOffset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return res.isDone
|
return res.isDone
|
||||||
} finally {
|
} finally {
|
||||||
isRunningActionFromVim = false
|
isRunningActionFromVim = false
|
||||||
@@ -88,7 +111,7 @@ class IjActionExecutor : VimActionExecutor {
|
|||||||
override fun executeAction(editor: VimEditor?, action: NativeAction, context: ExecutionContext): Boolean {
|
override fun executeAction(editor: VimEditor?, action: NativeAction, context: ExecutionContext): Boolean {
|
||||||
if (editor != null && editor === injector.fallbackWindow) {
|
if (editor != null && editor === injector.fallbackWindow) {
|
||||||
val contextComponent = context.ij.getData(PlatformDataKeys.CONTEXT_COMPONENT)
|
val contextComponent = context.ij.getData(PlatformDataKeys.CONTEXT_COMPONENT)
|
||||||
return executeAction((action as IjNativeAction).action, contextComponent)
|
return executeAction(editor, (action as IjNativeAction).action, contextComponent)
|
||||||
}
|
}
|
||||||
return executeAction(editor, action)
|
return executeAction(editor, action)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ internal object ScrollViewHelper {
|
|||||||
// that this needs to be replaced as a more or less dumb line for line rewrite.
|
// that this needs to be replaced as a more or less dumb line for line rewrite.
|
||||||
val topLine = getVisualLineAtTopOfScreen(editor)
|
val topLine = getVisualLineAtTopOfScreen(editor)
|
||||||
val bottomLine = getVisualLineAtBottomOfScreen(editor)
|
val bottomLine = getVisualLineAtBottomOfScreen(editor)
|
||||||
val lastLine = vimEditor.getVisualLineCount() - 1
|
val lastLine = vimEditor.getVisualLineCount() + editor.settings.additionalLinesCount
|
||||||
|
|
||||||
// We need the non-normalised value here, so we can handle cases such as so=999 to keep the current line centred
|
// We need the non-normalised value here, so we can handle cases such as so=999 to keep the current line centred
|
||||||
val scrollOffset = injector.options(vimEditor).scrolloff
|
val scrollOffset = injector.options(vimEditor).scrolloff
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -198,6 +198,11 @@ private fun updateSearchHighlights(
|
|||||||
currentMatchOffset,
|
currentMatchOffset,
|
||||||
editor
|
editor
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (!isIncsearch) {
|
||||||
|
removeHighlightsEditors.add(editor)
|
||||||
|
removeHighlightsTimer.restart()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return currentEditorCurrentMatchOffset
|
return currentEditorCurrentMatchOffset
|
||||||
@@ -214,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(
|
||||||
@@ -343,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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -443,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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -512,3 +382,9 @@ private fun shouldAddCurrentMatchSearchHighlight(pattern: String?, hlSearch: Boo
|
|||||||
*/
|
*/
|
||||||
@Contract(pure = true)
|
@Contract(pure = true)
|
||||||
private fun isIncrementalSearchHighlights(initialOffset: Int) = initialOffset != -1
|
private fun isIncrementalSearchHighlights(initialOffset: Int) = initialOffset != -1
|
||||||
|
|
||||||
|
val removeHighlightsEditors = mutableListOf<Editor>()
|
||||||
|
val removeHighlightsTimer = javax.swing.Timer(450) {
|
||||||
|
removeHighlightsEditors.forEach(::removeSearchHighlights)
|
||||||
|
removeHighlightsEditors.clear()
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import com.intellij.openapi.fileEditor.TextEditorWithPreview
|
|||||||
import com.intellij.openapi.fileEditor.impl.text.TextEditorProvider
|
import com.intellij.openapi.fileEditor.impl.text.TextEditorProvider
|
||||||
import com.intellij.openapi.util.registry.Registry
|
import com.intellij.openapi.util.registry.Registry
|
||||||
import com.intellij.util.PlatformUtils
|
import com.intellij.util.PlatformUtils
|
||||||
|
import com.maddyhome.idea.vim.VimPlugin
|
||||||
import com.maddyhome.idea.vim.api.ExecutionContext
|
import com.maddyhome.idea.vim.api.ExecutionContext
|
||||||
import com.maddyhome.idea.vim.api.VimCaret
|
import com.maddyhome.idea.vim.api.VimCaret
|
||||||
import com.maddyhome.idea.vim.api.VimEditor
|
import com.maddyhome.idea.vim.api.VimEditor
|
||||||
@@ -29,6 +30,8 @@ import com.maddyhome.idea.vim.newapi.IjVimCaret
|
|||||||
import com.maddyhome.idea.vim.newapi.globalIjOptions
|
import com.maddyhome.idea.vim.newapi.globalIjOptions
|
||||||
import com.maddyhome.idea.vim.newapi.ij
|
import com.maddyhome.idea.vim.newapi.ij
|
||||||
import com.maddyhome.idea.vim.state.mode.Mode
|
import com.maddyhome.idea.vim.state.mode.Mode
|
||||||
|
import com.maddyhome.idea.vim.state.mode.SelectionType
|
||||||
|
import com.maddyhome.idea.vim.state.mode.inVisualMode
|
||||||
import com.maddyhome.idea.vim.undo.VimTimestampBasedUndoService
|
import com.maddyhome.idea.vim.undo.VimTimestampBasedUndoService
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -85,15 +88,7 @@ class UndoRedoHelper : VimTimestampBasedUndoService {
|
|||||||
// TODO refactor me after VIM-308 when restoring selection and caret movement will be ignored by undo
|
// TODO refactor me after VIM-308 when restoring selection and caret movement will be ignored by undo
|
||||||
editor.runWithChangeTracking {
|
editor.runWithChangeTracking {
|
||||||
undoManager.undo(fileEditor)
|
undoManager.undo(fileEditor)
|
||||||
|
restoreVisualMode(editor)
|
||||||
// We execute undo one more time if the previous one just restored selection
|
|
||||||
if (!hasChanges && hasSelection(editor) && undoManager.isUndoAvailable(fileEditor)) {
|
|
||||||
undoManager.undo(fileEditor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
CommandProcessor.getInstance().runUndoTransparentAction {
|
|
||||||
removeSelections(editor)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
runWithBooleanRegistryOption("ide.undo.transparent.caret.movement", true) {
|
runWithBooleanRegistryOption("ide.undo.transparent.caret.movement", true) {
|
||||||
@@ -261,4 +256,21 @@ class UndoRedoHelper : VimTimestampBasedUndoService {
|
|||||||
val hasChanges: Boolean
|
val hasChanges: Boolean
|
||||||
get() = changeListener.hasChanged || initialPath != editor.getPath()
|
get() = changeListener.hasChanged || initialPath != editor.getPath()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun restoreVisualMode(editor: VimEditor) {
|
||||||
|
if (!editor.inVisualMode && editor.ij.selectionModel.hasSelection()) {
|
||||||
|
val detectedMode = VimPlugin.getVisualMotion().detectSelectionType(editor)
|
||||||
|
|
||||||
|
// Visual block selection is restored into multiple carets, so multi-carets that form a block are always
|
||||||
|
// identified as visual block mode, leading to false positives.
|
||||||
|
// Since I use visual block mode much less often than multi-carets, this is a judgment call to never restore
|
||||||
|
// visual block mode.
|
||||||
|
val wantedMode = if (detectedMode == SelectionType.BLOCK_WISE)
|
||||||
|
SelectionType.CHARACTER_WISE
|
||||||
|
else
|
||||||
|
detectedMode
|
||||||
|
|
||||||
|
VimPlugin.getVisualMotion().enterVisualMode(editor, wantedMode)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,14 +15,15 @@ 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.maddyhome.idea.vim.api.CaretRegisterStorageBase
|
import com.maddyhome.idea.vim.api.CaretRegisterStorageBase
|
||||||
import com.maddyhome.idea.vim.api.LocalMarkStorage
|
import com.maddyhome.idea.vim.api.LocalMarkStorage
|
||||||
import com.maddyhome.idea.vim.api.SelectionInfo
|
import com.maddyhome.idea.vim.api.SelectionInfo
|
||||||
import com.maddyhome.idea.vim.api.VimOutputPanel
|
import com.maddyhome.idea.vim.api.VimOutputPanel
|
||||||
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
|
||||||
@@ -97,7 +98,7 @@ var Caret.vimInsertStart: RangeMarker by userDataOr {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Data could be lost during visual block motion
|
// TODO: Data could be lost during visual block motion
|
||||||
var Caret.registerStorage: CaretRegisterStorageBase? by userDataCaretToEditor()
|
var Caret.registerStorage: CaretRegisterStorageBase? by GlobalCaretData.userData()
|
||||||
var Caret.markStorage: LocalMarkStorage? by userDataCaretToEditor()
|
var Caret.markStorage: LocalMarkStorage? by userDataCaretToEditor()
|
||||||
var Caret.lastSelectionInfo: SelectionInfo? by userDataCaretToEditor()
|
var Caret.lastSelectionInfo: SelectionInfo? by userDataCaretToEditor()
|
||||||
|
|
||||||
@@ -107,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()
|
||||||
@@ -144,6 +152,39 @@ fun <T> userData(): ReadWriteProperty<UserDataHolder, T?> =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
object GlobalCaretData {
|
||||||
|
private val holders = mutableListOf<UserDataHolderBase>()
|
||||||
|
|
||||||
|
private fun getHolder(caretIndex: Int): UserDataHolderBase {
|
||||||
|
while (holders.lastIndex < caretIndex) {
|
||||||
|
holders.add(UserDataHolderBase())
|
||||||
|
}
|
||||||
|
return holders[caretIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T> userData(): ReadWriteProperty<Caret, T?> = object : UserDataReadWriteProperty<Caret, T?>() {
|
||||||
|
override fun getValue(thisRef: Caret, property: KProperty<*>): T? {
|
||||||
|
return getCaretData(thisRef).getUserData(getKey(property))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setValue(thisRef: Caret, property: KProperty<*>, value: T?) {
|
||||||
|
getCaretData(thisRef).putUserData(getKey(property), value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getCaretData(caret: Caret): UserDataHolder {
|
||||||
|
if (caret == caret.caretModel.primaryCaret) {
|
||||||
|
return caret.editor
|
||||||
|
}
|
||||||
|
|
||||||
|
val caretIndex = caret.caretModel.allCarets.indexOf(caret)
|
||||||
|
return if (caretIndex == -1)
|
||||||
|
caret.editor
|
||||||
|
else
|
||||||
|
getHolder(caretIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Function for delegated properties.
|
* Function for delegated properties.
|
||||||
* The property will be saved to caret if this caret is not primary
|
* The property will be saved to caret if this caret is not primary
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -65,8 +66,10 @@ class IJEditorFocusListener : EditorListener {
|
|||||||
VimPlugin.getChange().insertBeforeCaret(editor, context)
|
VimPlugin.getChange().insertBeforeCaret(editor, context)
|
||||||
KeyHandler.getInstance().lastUsedEditorInfo = LastUsedEditorInfo(currentEditorHashCode, true)
|
KeyHandler.getInstance().lastUsedEditorInfo = LastUsedEditorInfo(currentEditorHashCode, true)
|
||||||
}
|
}
|
||||||
if (isCurrentEditorTerminal && !ijEditor.inInsertMode) {
|
if (isCurrentEditorTerminal) {
|
||||||
switchToInsertMode.run()
|
if (!ijEditor.inInsertMode) {
|
||||||
|
switchToInsertMode.run()
|
||||||
|
}
|
||||||
} else if (ijEditor.isInsertMode && (oldEditorInfo.isInsertModeForced || !ijEditor.document.isWritable)) {
|
} else if (ijEditor.isInsertMode && (oldEditorInfo.isInsertModeForced || !ijEditor.document.isWritable)) {
|
||||||
val context: ExecutionContext = injector.executionContextManager.getEditorExecutionContext(editor)
|
val context: ExecutionContext = injector.executionContextManager.getEditorExecutionContext(editor)
|
||||||
val mode = injector.vimState.mode
|
val mode = injector.vimState.mode
|
||||||
@@ -75,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import com.intellij.codeInsight.template.TemplateManagerListener
|
|||||||
import com.intellij.codeInsight.template.impl.TemplateImpl
|
import com.intellij.codeInsight.template.impl.TemplateImpl
|
||||||
import com.intellij.codeInsight.template.impl.TemplateManagerImpl
|
import com.intellij.codeInsight.template.impl.TemplateManagerImpl
|
||||||
import com.intellij.codeInsight.template.impl.TemplateState
|
import com.intellij.codeInsight.template.impl.TemplateState
|
||||||
|
import com.intellij.codeInsight.template.impl.actions.NextVariableAction
|
||||||
import com.intellij.find.FindModelListener
|
import com.intellij.find.FindModelListener
|
||||||
import com.intellij.ide.actions.ApplyIntentionAction
|
import com.intellij.ide.actions.ApplyIntentionAction
|
||||||
import com.intellij.openapi.actionSystem.ActionManager
|
import com.intellij.openapi.actionSystem.ActionManager
|
||||||
@@ -35,6 +36,7 @@ import com.intellij.openapi.application.ApplicationManager
|
|||||||
import com.intellij.openapi.editor.Editor
|
import com.intellij.openapi.editor.Editor
|
||||||
import com.intellij.openapi.editor.RangeMarker
|
import com.intellij.openapi.editor.RangeMarker
|
||||||
import com.intellij.openapi.editor.actions.EnterAction
|
import com.intellij.openapi.editor.actions.EnterAction
|
||||||
|
import com.intellij.openapi.editor.impl.ScrollingModelImpl
|
||||||
import com.intellij.openapi.fileEditor.FileEditorManager
|
import com.intellij.openapi.fileEditor.FileEditorManager
|
||||||
import com.intellij.openapi.keymap.KeymapManager
|
import com.intellij.openapi.keymap.KeymapManager
|
||||||
import com.intellij.openapi.project.DumbAwareToggleAction
|
import com.intellij.openapi.project.DumbAwareToggleAction
|
||||||
@@ -51,7 +53,6 @@ import com.maddyhome.idea.vim.group.NotificationService
|
|||||||
import com.maddyhome.idea.vim.group.visual.IdeaSelectionControl
|
import com.maddyhome.idea.vim.group.visual.IdeaSelectionControl
|
||||||
import com.maddyhome.idea.vim.helper.exitSelectMode
|
import com.maddyhome.idea.vim.helper.exitSelectMode
|
||||||
import com.maddyhome.idea.vim.helper.exitVisualMode
|
import com.maddyhome.idea.vim.helper.exitVisualMode
|
||||||
import com.maddyhome.idea.vim.helper.hasVisualSelection
|
|
||||||
import com.maddyhome.idea.vim.helper.isEndAllowed
|
import com.maddyhome.idea.vim.helper.isEndAllowed
|
||||||
import com.maddyhome.idea.vim.helper.isIdeaVimDisabledHere
|
import com.maddyhome.idea.vim.helper.isIdeaVimDisabledHere
|
||||||
import com.maddyhome.idea.vim.helper.vimInitialised
|
import com.maddyhome.idea.vim.helper.vimInitialised
|
||||||
@@ -89,6 +90,7 @@ internal object IdeaSpecifics {
|
|||||||
private val surrounderAction =
|
private val surrounderAction =
|
||||||
"com.intellij.codeInsight.generation.surroundWith.SurroundWithHandler\$InvokeSurrounderAction"
|
"com.intellij.codeInsight.generation.surroundWith.SurroundWithHandler\$InvokeSurrounderAction"
|
||||||
private var editor: Editor? = null
|
private var editor: Editor? = null
|
||||||
|
private var caretOffset = -1
|
||||||
private var completionData: CompletionData? = null
|
private var completionData: CompletionData? = null
|
||||||
|
|
||||||
override fun beforeActionPerformed(action: AnAction, event: AnActionEvent) {
|
override fun beforeActionPerformed(action: AnAction, event: AnActionEvent) {
|
||||||
@@ -97,6 +99,7 @@ internal object IdeaSpecifics {
|
|||||||
val hostEditor = event.dataContext.getData(CommonDataKeys.HOST_EDITOR)
|
val hostEditor = event.dataContext.getData(CommonDataKeys.HOST_EDITOR)
|
||||||
if (hostEditor != null) {
|
if (hostEditor != null) {
|
||||||
editor = hostEditor
|
editor = hostEditor
|
||||||
|
caretOffset = hostEditor.caretModel.offset
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remember the mode before the action runs, while it is still correct. For 'idearefactormode' = "keep" the mode
|
// Remember the mode before the action runs, while it is still correct. For 'idearefactormode' = "keep" the mode
|
||||||
@@ -189,24 +192,43 @@ internal object IdeaSpecifics {
|
|||||||
if (VimPlugin.isNotEnabled()) return
|
if (VimPlugin.isNotEnabled()) return
|
||||||
|
|
||||||
val editor = editor
|
val editor = editor
|
||||||
if (editor != null && action is ChooseItemAction && injector.registerGroup.isRecording) {
|
if (editor != null) {
|
||||||
completionData?.recordCompletion(editor, VimPlugin.getRegister())
|
if (action is ChooseItemAction && injector.registerGroup.isRecording) {
|
||||||
}
|
completionData?.recordCompletion(editor, VimPlugin.getRegister()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
//region Enter insert mode after surround with if
|
//region Enter insert mode after surround with if
|
||||||
if (surrounderAction == action.javaClass.name && surrounderItems.any {
|
if (surrounderAction == action.javaClass.name && surrounderItems.any {
|
||||||
action.templatePresentation.text.endsWith(
|
action.templatePresentation.text.endsWith(
|
||||||
it,
|
it,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
editor?.let {
|
||||||
|
it.vim.mode = Mode.NORMAL()
|
||||||
|
VimPlugin.getChange().insertBeforeCaret(it.vim, event.dataContext.vim)
|
||||||
|
KeyHandler.getInstance().reset(it.vim)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
) {
|
else if (action is NextVariableAction && TemplateManagerImpl.getTemplateState(editor) == null) {
|
||||||
editor?.let {
|
editor.vim.exitInsertMode(event.dataContext.vim)
|
||||||
it.vim.mode = Mode.NORMAL()
|
KeyHandler.getInstance().reset(editor.vim)
|
||||||
VimPlugin.getChange().insertBeforeCaret(it.vim, event.dataContext.vim)
|
}
|
||||||
KeyHandler.getInstance().reset(it.vim)
|
//endregion
|
||||||
|
|
||||||
|
if (caretOffset != -1 && caretOffset != editor.caretModel.offset) {
|
||||||
|
val scrollModel = editor.scrollingModel as ScrollingModelImpl
|
||||||
|
if (scrollModel.isScrollingNow) {
|
||||||
|
val v = scrollModel.verticalScrollOffset
|
||||||
|
val h = scrollModel.horizontalScrollOffset
|
||||||
|
scrollModel.finishAnimation()
|
||||||
|
scrollModel.scroll(h, v)
|
||||||
|
scrollModel.finishAnimation()
|
||||||
|
}
|
||||||
|
injector.scroll.scrollCaretIntoView(editor.vim)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
//endregion
|
|
||||||
|
|
||||||
// Do not normalize carets after native undo/redo. The platform undo (UndoRedo.execute) treats caret movement as a
|
// Do not normalize carets after native undo/redo. The platform undo (UndoRedo.execute) treats caret movement as a
|
||||||
// separate undo step by default ('ide.undo.transparent.caret.movement' = false): before reverting the document it
|
// separate undo step by default ('ide.undo.transparent.caret.movement' = false): before reverting the document it
|
||||||
@@ -219,6 +241,7 @@ internal object IdeaSpecifics {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.editor = null
|
this.editor = null
|
||||||
|
this.caretOffset = -1
|
||||||
|
|
||||||
this.completionData?.dispose()
|
this.completionData?.dispose()
|
||||||
this.completionData = null
|
this.completionData = null
|
||||||
@@ -394,23 +417,6 @@ internal object IdeaSpecifics {
|
|||||||
vimEditor.exitMode()
|
vimEditor.exitMode()
|
||||||
vimEditor.mode = Mode.NORMAL()
|
vimEditor.mode = Mode.NORMAL()
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// IdeaSelectionControl will not be called if we're moving to a new variable with no change in selection.
|
|
||||||
// And if we're moving to the end of the template, the change in selection will reset us to Normal because
|
|
||||||
// IdeaSelectionControl will be called when the template is no longer active.
|
|
||||||
if ((!editor.selectionModel.hasSelection() && !vimEditor.mode.hasVisualSelection) || newIndex == -1) {
|
|
||||||
if (vimEditor.isIdeaRefactorModeSelect) {
|
|
||||||
if (vimEditor.mode !is Mode.INSERT) {
|
|
||||||
vimEditor.exitMode()
|
|
||||||
injector.application.runReadAction {
|
|
||||||
val context = injector.executionContextManager.getEditorExecutionContext(editor.vim)
|
|
||||||
VimPlugin.getChange().insertBeforeCaret(editor.vim, context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
vimEditor.mode = Mode.NORMAL()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,9 +121,9 @@ 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.listener.VimListenerManager.VimEditorFactoryListener.editorCreated
|
|
||||||
import com.maddyhome.idea.vim.newapi.IjVimEditor
|
import com.maddyhome.idea.vim.newapi.IjVimEditor
|
||||||
import com.maddyhome.idea.vim.newapi.IjVimSearchGroup
|
import com.maddyhome.idea.vim.newapi.IjVimSearchGroup
|
||||||
import com.maddyhome.idea.vim.newapi.InsertTimeRecorder
|
import com.maddyhome.idea.vim.newapi.InsertTimeRecorder
|
||||||
@@ -248,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
|
||||||
@@ -298,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()
|
||||||
}
|
}
|
||||||
@@ -387,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)
|
||||||
@@ -406,16 +417,18 @@ object VimListenerManager {
|
|||||||
editorGroup?.editorDeinit(editor.vim)
|
editorGroup?.editorDeinit(editor.vim)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ApplicationManager.getApplication().invokeLater {
|
||||||
|
if (vimDisabled(editor)) {
|
||||||
|
remove(editor)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun remove(editor: Editor) {
|
fun remove(editor: Editor) {
|
||||||
val editorDisposable = editor.removeUserData(editorListenersDisposableKey)
|
val editorDisposable = editor.removeUserData(editorListenersDisposableKey)
|
||||||
if (editorDisposable != null) {
|
if (editorDisposable != null) {
|
||||||
Disposer.dispose(editorDisposable)
|
Disposer.dispose(editorDisposable)
|
||||||
} else if (!EditorHelper.isCommandHistoryWindow(editor)) {
|
|
||||||
// Cmdwin editors (q:, q/, q?) may bypass `add` in some test paths; that's expected.
|
|
||||||
// We definitely do not expect this to happen for any other editor.
|
|
||||||
StrictMode.fail("Editor doesn't have disposable attached. $editor")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -503,6 +516,17 @@ object VimListenerManager {
|
|||||||
// We can't rely on being passed a non-null editor, so check for Code With Me scenarios explicitly
|
// We can't rely on being passed a non-null editor, so check for Code With Me scenarios explicitly
|
||||||
if (VimPlugin.isNotEnabled() || !ClientId.isCurrentlyUnderLocalId) return
|
if (VimPlugin.isNotEnabled() || !ClientId.isCurrentlyUnderLocalId) return
|
||||||
|
|
||||||
|
val newEditor = event.newEditor
|
||||||
|
if (newEditor is TextEditor) {
|
||||||
|
val editor = newEditor.editor
|
||||||
|
if (editor.isInsertMode) {
|
||||||
|
editor.vim.mode = Mode.NORMAL()
|
||||||
|
KeyHandler.getInstance().reset(editor.vim)
|
||||||
|
}
|
||||||
|
// Breaks relativenumber for some reason
|
||||||
|
// injector.scroll.scrollCaretIntoView(editor.vim)
|
||||||
|
}
|
||||||
|
|
||||||
if (!isVirtualBuffer(event.oldFile) && !isVirtualBuffer(event.newFile)) {
|
if (!isVirtualBuffer(event.oldFile) && !isVirtualBuffer(event.newFile)) {
|
||||||
// Vim order: BufLeave → WinLeave → WinEnter → BufEnter
|
// Vim order: BufLeave → WinLeave → WinEnter → BufEnter
|
||||||
// Buf events only fire when the buffer (file) actually changes
|
// Buf events only fire when the buffer (file) actually changes
|
||||||
@@ -519,7 +543,7 @@ object VimListenerManager {
|
|||||||
|
|
||||||
MotionGroup.fileEditorManagerSelectionChangedCallback(event)
|
MotionGroup.fileEditorManagerSelectionChangedCallback(event)
|
||||||
FileGroupHelper.fileEditorManagerSelectionChangedCallback(event)
|
FileGroupHelper.fileEditorManagerSelectionChangedCallback(event)
|
||||||
VimPlugin.getSearch().fileEditorManagerSelectionChangedCallback(event)
|
// VimPlugin.getSearch().fileEditorManagerSelectionChangedCallback(event)
|
||||||
IjVimRedrawService.fileEditorManagerSelectionChangedCallback(event)
|
IjVimRedrawService.fileEditorManagerSelectionChangedCallback(event)
|
||||||
VimLastSelectedEditorTracker.setLastSelectedEditor(event.newEditor)
|
VimLastSelectedEditorTracker.setLastSelectedEditor(event.newEditor)
|
||||||
}
|
}
|
||||||
@@ -611,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)
|
||||||
@@ -636,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
|
||||||
@@ -1075,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.
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import com.intellij.openapi.editor.ex.ScrollingModelEx
|
|||||||
import com.intellij.openapi.editor.ex.util.EditorUtil
|
import com.intellij.openapi.editor.ex.util.EditorUtil
|
||||||
import com.intellij.openapi.editor.impl.CaretModelImpl
|
import com.intellij.openapi.editor.impl.CaretModelImpl
|
||||||
import com.intellij.openapi.editor.impl.EditorImpl
|
import com.intellij.openapi.editor.impl.EditorImpl
|
||||||
|
import com.intellij.openapi.util.text.StringUtil
|
||||||
import com.intellij.openapi.vfs.VirtualFileManager
|
import com.intellij.openapi.vfs.VirtualFileManager
|
||||||
import com.intellij.platform.project.projectId
|
import com.intellij.platform.project.projectId
|
||||||
import com.maddyhome.idea.vim.api.BufferPosition
|
import com.maddyhome.idea.vim.api.BufferPosition
|
||||||
@@ -159,7 +160,7 @@ class IjVimEditor(editor: Editor) : MutableLinearEditor, VimEditorBase() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!isWritable()) return
|
if (!isWritable()) return
|
||||||
editor.document.insertString(atPosition, text)
|
editor.document.insertString(atPosition, StringUtil.convertLineSeparators(text, "\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun replaceString(start: Int, end: Int, newString: String) {
|
override fun replaceString(start: Int, end: Int, newString: String) {
|
||||||
@@ -656,6 +657,10 @@ class IjVimEditor(editor: Editor) : MutableLinearEditor, VimEditorBase() {
|
|||||||
get() = ijFoldRegion.endOffset
|
get() = ijFoldRegion.endOffset
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun getSoftWrapStartAtOffset(offset: Int): Int? {
|
||||||
|
return editor.softWrapModel.getSoftWrap(offset)?.start
|
||||||
|
}
|
||||||
|
|
||||||
override fun <T : ImmutableVimCaret> findLastVersionOfCaret(caret: T): T {
|
override fun <T : ImmutableVimCaret> findLastVersionOfCaret(caret: T): T {
|
||||||
return caret
|
return caret
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import com.intellij.openapi.editor.colors.EditorColorsScheme
|
|||||||
import com.intellij.openapi.editor.ex.EditorEx
|
import com.intellij.openapi.editor.ex.EditorEx
|
||||||
import com.intellij.openapi.util.registry.Registry
|
import com.intellij.openapi.util.registry.Registry
|
||||||
import com.intellij.openapi.wm.impl.IdeBackgroundUtil
|
import com.intellij.openapi.wm.impl.IdeBackgroundUtil
|
||||||
|
import com.intellij.openapi.wm.impl.ToolWindowManagerImpl
|
||||||
import com.intellij.ui.ClientProperty
|
import com.intellij.ui.ClientProperty
|
||||||
import com.intellij.ui.JBColor
|
import com.intellij.ui.JBColor
|
||||||
import com.intellij.ui.components.JBPanel
|
import com.intellij.ui.components.JBPanel
|
||||||
@@ -184,6 +185,8 @@ internal class OutputPanel private constructor(private val editor: Editor) : JBP
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
putClientProperty(ToolWindowManagerImpl.PARENT_COMPONENT, editor.component)
|
||||||
|
|
||||||
updateUI()
|
updateUI()
|
||||||
|
|
||||||
// This is usually called in EDT, except for TabmoveTest
|
// This is usually called in EDT, except for TabmoveTest
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import com.intellij.openapi.editor.colors.EditorColors
|
|||||||
import com.intellij.openapi.editor.colors.EditorColorsListener
|
import com.intellij.openapi.editor.colors.EditorColorsListener
|
||||||
import com.intellij.openapi.editor.colors.EditorColorsScheme
|
import com.intellij.openapi.editor.colors.EditorColorsScheme
|
||||||
import com.intellij.openapi.wm.IdeFocusManager
|
import com.intellij.openapi.wm.IdeFocusManager
|
||||||
|
import com.intellij.openapi.wm.impl.ToolWindowManagerImpl
|
||||||
import com.intellij.ui.DocumentAdapter
|
import com.intellij.ui.DocumentAdapter
|
||||||
import com.intellij.util.IJSwingUtilities
|
import com.intellij.util.IJSwingUtilities
|
||||||
import com.maddyhome.idea.vim.EventFacade
|
import com.maddyhome.idea.vim.EventFacade
|
||||||
@@ -201,6 +202,7 @@ class ExEntryPanel private constructor() : JPanel(), VimCommandLine {
|
|||||||
glassPaneManager.activate(editor, this)
|
glassPaneManager.activate(editor, this)
|
||||||
positionPanel()
|
positionPanel()
|
||||||
glassPaneManager.show()
|
glassPaneManager.show()
|
||||||
|
putClientProperty(ToolWindowManagerImpl.PARENT_COMPONENT, parent)
|
||||||
SwingUtilities.invokeLater { entry.requestFocusInWindow() }
|
SwingUtilities.invokeLater { entry.requestFocusInWindow() }
|
||||||
}
|
}
|
||||||
isActive = true
|
isActive = true
|
||||||
@@ -268,6 +270,7 @@ class ExEntryPanel private constructor() : JPanel(), VimCommandLine {
|
|||||||
requestFocus(parent!!)
|
requestFocus(parent!!)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
putClientProperty(ToolWindowManagerImpl.PARENT_COMPONENT, null)
|
||||||
glassPaneManager.deactivate()
|
glassPaneManager.deactivate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
package com.maddyhome.idea.vim.vimscript.model.functions.handlers
|
||||||
|
|
||||||
|
import com.intellij.openapi.actionSystem.ActionManager
|
||||||
|
import com.intellij.openapi.actionSystem.impl.PresentationFactory
|
||||||
|
import com.intellij.openapi.actionSystem.impl.Utils
|
||||||
|
import com.intellij.openapi.keymap.impl.ActionProcessor
|
||||||
|
import com.intellij.vim.annotations.VimscriptFunction
|
||||||
|
import com.maddyhome.idea.vim.api.ExecutionContext
|
||||||
|
import com.maddyhome.idea.vim.api.VimEditor
|
||||||
|
import com.maddyhome.idea.vim.newapi.ij
|
||||||
|
import com.maddyhome.idea.vim.vimscript.model.VimLContext
|
||||||
|
import com.maddyhome.idea.vim.vimscript.model.datatypes.VimInt
|
||||||
|
import com.maddyhome.idea.vim.vimscript.model.datatypes.asVimInt
|
||||||
|
import com.maddyhome.idea.vim.vimscript.model.functions.BuiltinFunctionHandler
|
||||||
|
import java.awt.event.KeyEvent
|
||||||
|
|
||||||
|
@VimscriptFunction(name = "isactionenabled")
|
||||||
|
internal class IsActionEnabled : BuiltinFunctionHandler<VimInt>() {
|
||||||
|
override fun doFunction(
|
||||||
|
arguments: Arguments,
|
||||||
|
editor: VimEditor,
|
||||||
|
context: ExecutionContext,
|
||||||
|
vimContext: VimLContext,
|
||||||
|
): VimInt {
|
||||||
|
val action = ActionManager.getInstance().getAction(arguments.getString(0).value)
|
||||||
|
if (action == null) {
|
||||||
|
return false.asVimInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
val presentationFactory = PresentationFactory()
|
||||||
|
val wrappedContext = Utils.createAsyncDataContext(context.ij)
|
||||||
|
val actionProcessor = object : ActionProcessor() {}
|
||||||
|
val inputEventAdjusted = KeyEvent(editor.ij.contentComponent, KeyEvent.KEY_PRESSED, 0L, 0, KeyEvent.VK_UNDEFINED, '\u0000')
|
||||||
|
|
||||||
|
val updateEvent = Utils.runUpdateSessionForInputEvent(listOf(action), inputEventAdjusted, wrappedContext, "IdeaVim", actionProcessor, presentationFactory) { _, updater, events ->
|
||||||
|
val presentation = updater(action)
|
||||||
|
events[presentation]
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = updateEvent != null && updateEvent.presentation.isEnabled
|
||||||
|
return result.asVimInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
+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,12 +1,4 @@
|
|||||||
<!--
|
<idea-plugin>
|
||||||
~ Copyright 2003-2026 The IdeaVim authors
|
|
||||||
~
|
|
||||||
~ Use of this source code is governed by an MIT-style
|
|
||||||
~ license that can be found in the LICENSE.txt file or at
|
|
||||||
~ https://opensource.org/licenses/MIT.
|
|
||||||
-->
|
|
||||||
|
|
||||||
<idea-plugin url="https://plugins.jetbrains.com/plugin/164">
|
|
||||||
<name>IdeaVim</name>
|
<name>IdeaVim</name>
|
||||||
<id>IdeaVIM</id>
|
<id>IdeaVIM</id>
|
||||||
<description><![CDATA[
|
<description><![CDATA[
|
||||||
@@ -40,7 +32,7 @@
|
|||||||
<li><a href="https://youtrack.jetbrains.com/issues/VIM">Issue tracker</a> — feature requests and bug reports</li>
|
<li><a href="https://youtrack.jetbrains.com/issues/VIM">Issue tracker</a> — feature requests and bug reports</li>
|
||||||
</ul>
|
</ul>
|
||||||
]]></description>
|
]]></description>
|
||||||
<version>SNAPSHOT</version>
|
<version>chylex</version>
|
||||||
<vendor>JetBrains</vendor>
|
<vendor>JetBrains</vendor>
|
||||||
|
|
||||||
<!-- Mark the plugin as compatible with RubyMine and other products based on the IntelliJ platform (including CWM) -->
|
<!-- Mark the plugin as compatible with RubyMine and other products based on the IntelliJ platform (including CWM) -->
|
||||||
|
|||||||
@@ -118,5 +118,10 @@
|
|||||||
"keys": "gJ",
|
"keys": "gJ",
|
||||||
"class": "com.maddyhome.idea.vim.action.change.delete.DeleteJoinVisualLinesAction",
|
"class": "com.maddyhome.idea.vim.action.change.delete.DeleteJoinVisualLinesAction",
|
||||||
"modes": "X"
|
"modes": "X"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"keys": "z@",
|
||||||
|
"class": "com.maddyhome.idea.vim.action.macro.PlaybackRegisterInOpenFilesAction",
|
||||||
|
"modes": "N"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -1,4 +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",
|
||||||
|
"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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import com.maddyhome.idea.vim.command.Command
|
|||||||
import com.maddyhome.idea.vim.command.OperatorArguments
|
import com.maddyhome.idea.vim.command.OperatorArguments
|
||||||
import com.maddyhome.idea.vim.handler.VimActionHandler
|
import com.maddyhome.idea.vim.handler.VimActionHandler
|
||||||
|
|
||||||
@CommandOrMotion(keys = ["<C-R>"], modes = [Mode.NORMAL])
|
@CommandOrMotion(keys = ["U", "<C-R>"], modes = [Mode.NORMAL, Mode.VISUAL])
|
||||||
class RedoAction : VimActionHandler.SingleExecution() {
|
class RedoAction : VimActionHandler.SingleExecution() {
|
||||||
override val type: Command.Type = Command.Type.OTHER_SELF_SYNCHRONIZED
|
override val type: Command.Type = Command.Type.OTHER_SELF_SYNCHRONIZED
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import com.maddyhome.idea.vim.command.Command
|
|||||||
import com.maddyhome.idea.vim.command.OperatorArguments
|
import com.maddyhome.idea.vim.command.OperatorArguments
|
||||||
import com.maddyhome.idea.vim.handler.VimActionHandler
|
import com.maddyhome.idea.vim.handler.VimActionHandler
|
||||||
|
|
||||||
@CommandOrMotion(keys = ["u", "<Undo>"], modes = [Mode.NORMAL])
|
@CommandOrMotion(keys = ["u", "<Undo>"], modes = [Mode.NORMAL, Mode.VISUAL])
|
||||||
class UndoAction : VimActionHandler.SingleExecution() {
|
class UndoAction : VimActionHandler.SingleExecution() {
|
||||||
override val type: Command.Type = Command.Type.OTHER_SELF_SYNCHRONIZED
|
override val type: Command.Type = Command.Type.OTHER_SELF_SYNCHRONIZED
|
||||||
|
|
||||||
|
|||||||
+1
-2
@@ -8,7 +8,6 @@
|
|||||||
package com.maddyhome.idea.vim.action.change.change
|
package com.maddyhome.idea.vim.action.change.change
|
||||||
|
|
||||||
import com.intellij.vim.annotations.CommandOrMotion
|
import com.intellij.vim.annotations.CommandOrMotion
|
||||||
import com.intellij.vim.annotations.Mode
|
|
||||||
import com.maddyhome.idea.vim.api.ExecutionContext
|
import com.maddyhome.idea.vim.api.ExecutionContext
|
||||||
import com.maddyhome.idea.vim.api.VimCaret
|
import com.maddyhome.idea.vim.api.VimCaret
|
||||||
import com.maddyhome.idea.vim.api.VimChangeGroup
|
import com.maddyhome.idea.vim.api.VimChangeGroup
|
||||||
@@ -26,7 +25,7 @@ import com.maddyhome.idea.vim.handler.VisualOperatorActionHandler
|
|||||||
* While 'v_gu' is not explicitly documented in Vim help, we treat these commands as identical
|
* While 'v_gu' is not explicitly documented in Vim help, we treat these commands as identical
|
||||||
* based on observed behavior, without examining Vim's source code.
|
* based on observed behavior, without examining Vim's source code.
|
||||||
*/
|
*/
|
||||||
@CommandOrMotion(keys = ["u", "gu"], modes = [Mode.VISUAL])
|
@CommandOrMotion(keys = [], modes = [])
|
||||||
class ChangeCaseLowerVisualAction : VisualOperatorActionHandler.ForEachCaret() {
|
class ChangeCaseLowerVisualAction : VisualOperatorActionHandler.ForEachCaret() {
|
||||||
override val type: Command.Type = Command.Type.CHANGE
|
override val type: Command.Type = Command.Type.CHANGE
|
||||||
|
|
||||||
|
|||||||
+1
-2
@@ -8,7 +8,6 @@
|
|||||||
package com.maddyhome.idea.vim.action.change.change
|
package com.maddyhome.idea.vim.action.change.change
|
||||||
|
|
||||||
import com.intellij.vim.annotations.CommandOrMotion
|
import com.intellij.vim.annotations.CommandOrMotion
|
||||||
import com.intellij.vim.annotations.Mode
|
|
||||||
import com.maddyhome.idea.vim.api.ExecutionContext
|
import com.maddyhome.idea.vim.api.ExecutionContext
|
||||||
import com.maddyhome.idea.vim.api.VimCaret
|
import com.maddyhome.idea.vim.api.VimCaret
|
||||||
import com.maddyhome.idea.vim.api.VimChangeGroup
|
import com.maddyhome.idea.vim.api.VimChangeGroup
|
||||||
@@ -26,7 +25,7 @@ import com.maddyhome.idea.vim.handler.VisualOperatorActionHandler
|
|||||||
* While 'v_gU' is not explicitly documented in Vim help, we treat these commands as identical
|
* While 'v_gU' is not explicitly documented in Vim help, we treat these commands as identical
|
||||||
* based on observed behavior, without examining Vim's source code.
|
* based on observed behavior, without examining Vim's source code.
|
||||||
*/
|
*/
|
||||||
@CommandOrMotion(keys = ["U", "gU"], modes = [Mode.VISUAL])
|
@CommandOrMotion(keys = [], modes = [])
|
||||||
class ChangeCaseUpperVisualAction : VisualOperatorActionHandler.ForEachCaret() {
|
class ChangeCaseUpperVisualAction : VisualOperatorActionHandler.ForEachCaret() {
|
||||||
override val type: Command.Type = Command.Type.CHANGE
|
override val type: Command.Type = Command.Type.CHANGE
|
||||||
|
|
||||||
|
|||||||
+1
-2
@@ -115,8 +115,7 @@ class ProcessSearchEntryAction(private val parentAction: ProcessExEntryAction) :
|
|||||||
val offsetAndMotion = injector.searchGroup.processSearchCommand(
|
val offsetAndMotion = injector.searchGroup.processSearchCommand(
|
||||||
editor, argument.string, caret.offset, count, direction
|
editor, argument.string, caret.offset, count, direction
|
||||||
)
|
)
|
||||||
// Vim doesn't treat not finding something as an error, although it might report either an error or warning message
|
if (offsetAndMotion == null) return Motion.Error
|
||||||
if (offsetAndMotion == null) return Motion.NoMotion
|
|
||||||
parentAction.motionType = offsetAndMotion.second
|
parentAction.motionType = offsetAndMotion.second
|
||||||
return offsetAndMotion.first.toMotionOrError()
|
return offsetAndMotion.first.toMotionOrError()
|
||||||
}
|
}
|
||||||
|
|||||||
+7
@@ -76,6 +76,13 @@ sealed class TillCharacterMotion(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
injector.motion.setLastFTCmd(tillCharacterMotionType, argument.character)
|
injector.motion.setLastFTCmd(tillCharacterMotionType, argument.character)
|
||||||
|
|
||||||
|
val offset = if (!finishBeforeCharacter) ""
|
||||||
|
else if (direction == Direction.FORWARDS) "s-1"
|
||||||
|
else "s+1"
|
||||||
|
|
||||||
|
injector.searchGroup.setLastSearchState(argument.character.let { if (it in "`^$.*[~/\\") "\\$it" else it.toString() }, offset, direction)
|
||||||
|
|
||||||
return res.toMotionOrError()
|
return res.toMotionOrError()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
@@ -31,6 +31,12 @@ class MotionCamelLeftAction : MotionActionHandler.ForEachCaret() {
|
|||||||
argument: Argument?,
|
argument: Argument?,
|
||||||
operatorArguments: OperatorArguments,
|
operatorArguments: OperatorArguments,
|
||||||
): Motion {
|
): Motion {
|
||||||
|
if (caret.hasSelection() && caret.offset > caret.vimSelectionStart) {
|
||||||
|
val target = injector.searchHelper.findPreviousCamelEnd(editor.text(), caret.offset, operatorArguments.count1)
|
||||||
|
if (target != null && target > caret.vimSelectionStart) {
|
||||||
|
return target.toMotionOrError()
|
||||||
|
}
|
||||||
|
}
|
||||||
return injector.searchHelper.findPreviousCamelStart(editor.text(), caret.offset, operatorArguments.count1)
|
return injector.searchHelper.findPreviousCamelStart(editor.text(), caret.offset, operatorArguments.count1)
|
||||||
?.toMotionOrError() ?: Motion.Error
|
?.toMotionOrError() ?: Motion.Error
|
||||||
}
|
}
|
||||||
@@ -47,6 +53,10 @@ class MotionCamelRightAction : MotionActionHandler.ForEachCaret() {
|
|||||||
argument: Argument?,
|
argument: Argument?,
|
||||||
operatorArguments: OperatorArguments,
|
operatorArguments: OperatorArguments,
|
||||||
): Motion {
|
): Motion {
|
||||||
|
if (caret.hasSelection() && caret.offset >= caret.vimSelectionStart) {
|
||||||
|
return injector.searchHelper.findNextCamelEnd(editor.text(), caret.offset + 1, operatorArguments.count1)
|
||||||
|
?.toMotionOrError() ?: Motion.Error
|
||||||
|
}
|
||||||
return injector.searchHelper.findNextCamelStart(editor.text(), caret.offset + 1, operatorArguments.count1)
|
return injector.searchHelper.findNextCamelStart(editor.text(), caret.offset + 1, operatorArguments.count1)
|
||||||
?.toMotionOrError() ?: Motion.Error
|
?.toMotionOrError() ?: Motion.Error
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -70,6 +70,6 @@ class MotionDownNotLineWiseAction : MotionActionHandler.ForEachCaret() {
|
|||||||
argument: Argument?,
|
argument: Argument?,
|
||||||
operatorArguments: OperatorArguments,
|
operatorArguments: OperatorArguments,
|
||||||
): Motion {
|
): Motion {
|
||||||
return injector.motion.getVerticalMotionOffset(editor, caret, operatorArguments.count1)
|
return injector.motion.getVerticalMotionOffset(editor, caret, operatorArguments.count1, bufferLines = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -70,6 +70,6 @@ class MotionUpNotLineWiseAction : MotionActionHandler.ForEachCaret() {
|
|||||||
argument: Argument?,
|
argument: Argument?,
|
||||||
operatorArguments: OperatorArguments,
|
operatorArguments: OperatorArguments,
|
||||||
): Motion {
|
): Motion {
|
||||||
return injector.motion.getVerticalMotionOffset(editor, caret, -operatorArguments.count1)
|
return injector.motion.getVerticalMotionOffset(editor, caret, -operatorArguments.count1, bufferLines = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import com.maddyhome.idea.vim.register.Register
|
|||||||
import com.maddyhome.idea.vim.register.RegisterConstants
|
import com.maddyhome.idea.vim.register.RegisterConstants
|
||||||
import com.maddyhome.idea.vim.register.VimRegisterGroupBase
|
import com.maddyhome.idea.vim.register.VimRegisterGroupBase
|
||||||
import com.maddyhome.idea.vim.state.mode.SelectionType
|
import com.maddyhome.idea.vim.state.mode.SelectionType
|
||||||
|
import com.maddyhome.idea.vim.yank.MultiCaretJoinedText
|
||||||
import javax.swing.KeyStroke
|
import javax.swing.KeyStroke
|
||||||
|
|
||||||
abstract class VimCaretBase : VimCaret
|
abstract class VimCaretBase : VimCaret
|
||||||
@@ -67,6 +68,11 @@ open class CaretRegisterStorageBase(override var caret: ImmutableVimCaret) : Car
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun getRegister(editor: VimEditor, context: ExecutionContext, r: Char): Register? {
|
override fun getRegister(editor: VimEditor, context: ExecutionContext, r: Char): Register? {
|
||||||
|
val multiCaretJoinRegister = injector.registerGroup.getRegister(editor, context, RegisterConstants.MULTICARET_JOIN_REGISTER)
|
||||||
|
val multiCaretJoinedText = multiCaretJoinRegister?.copiedText as? MultiCaretJoinedText
|
||||||
|
if (multiCaretJoinedText != null && multiCaretJoinedText.carets.let { it > 0 && it != editor.carets().size }) {
|
||||||
|
return multiCaretJoinRegister
|
||||||
|
}
|
||||||
if (caret.isPrimary || !RegisterConstants.RECORDABLE_REGISTERS.contains(r)) {
|
if (caret.isPrimary || !RegisterConstants.RECORDABLE_REGISTERS.contains(r)) {
|
||||||
return injector.registerGroup.getRegister(editor, context, r)
|
return injector.registerGroup.getRegister(editor, context, r)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -251,7 +251,7 @@ interface VimChangeGroup {
|
|||||||
operatorArguments: OperatorArguments,
|
operatorArguments: OperatorArguments,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun insertText(editor: VimEditor, caret: VimCaret, offset: Int, str: String): VimCaret
|
fun insertText(editor: VimEditor, caret: VimCaret, offset: Int, str: CharSequence): VimCaret
|
||||||
|
|
||||||
fun insertText(editor: VimEditor, caret: VimCaret, str: String): VimCaret
|
fun insertText(editor: VimEditor, caret: VimCaret, str: String): VimCaret
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -251,7 +251,7 @@ abstract class VimChangeGroupBase : VimChangeGroup {
|
|||||||
* @param caret The caret to start insertion in
|
* @param caret The caret to start insertion in
|
||||||
* @param str The text to insert
|
* @param str The text to insert
|
||||||
*/
|
*/
|
||||||
override fun insertText(editor: VimEditor, caret: VimCaret, offset: Int, str: String): VimCaret {
|
override fun insertText(editor: VimEditor, caret: VimCaret, offset: Int, str: CharSequence): VimCaret {
|
||||||
injector.application.runWriteAction {
|
injector.application.runWriteAction {
|
||||||
(editor as MutableVimEditor).insertText(caret, offset, str)
|
(editor as MutableVimEditor).insertText(caret, offset, str)
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -314,6 +314,7 @@ interface VimEditor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun createIndentBySize(size: Int): String
|
fun createIndentBySize(size: Int): String
|
||||||
|
fun getSoftWrapStartAtOffset(offset: Int): Int?
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the collapsed fold region at the specified offset, if any.
|
* Returns the collapsed fold region at the specified offset, if any.
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ interface VimMotionGroup {
|
|||||||
allowWrap: Boolean = false,
|
allowWrap: Boolean = false,
|
||||||
): Motion
|
): Motion
|
||||||
|
|
||||||
fun getVerticalMotionOffset(editor: VimEditor, caret: ImmutableVimCaret, count: Int): Motion
|
fun getVerticalMotionOffset(editor: VimEditor, caret: ImmutableVimCaret, count: Int, bufferLines: Boolean = false): Motion
|
||||||
|
|
||||||
// TODO: Consider naming. These don't move the caret, but calculate offsets. Also consider returning Motion
|
// TODO: Consider naming. These don't move the caret, but calculate offsets. Also consider returning Motion
|
||||||
|
|
||||||
|
|||||||
@@ -38,14 +38,18 @@ abstract class VimMotionGroupBase : VimMotionGroup {
|
|||||||
override var lastFTCmd: TillCharacterMotionType = TillCharacterMotionType.LAST_SMALL_T
|
override var lastFTCmd: TillCharacterMotionType = TillCharacterMotionType.LAST_SMALL_T
|
||||||
override var lastFTChar: Char = ' '
|
override var lastFTChar: Char = ' '
|
||||||
|
|
||||||
override fun getVerticalMotionOffset(editor: VimEditor, caret: ImmutableVimCaret, count: Int): Motion {
|
override fun getVerticalMotionOffset(editor: VimEditor, caret: ImmutableVimCaret, count: Int, bufferLines: Boolean): Motion {
|
||||||
val pos = caret.getVisualPosition()
|
val pos = caret.getVisualPosition()
|
||||||
if ((pos.line == 0 && count < 0) || (pos.line >= editor.getVisualLineCount() - 1 && count > 0)) {
|
if ((pos.line == 0 && count < 0) || (pos.line >= editor.getVisualLineCount() - 1 && count > 0)) {
|
||||||
return Motion.Error
|
return Motion.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
val intendedColumn = caret.vimLastColumn
|
val intendedColumn = caret.vimLastColumn
|
||||||
val line = editor.normalizeVisualLine(pos.line + count)
|
val line = if (bufferLines)
|
||||||
|
// TODO Does not work with folds, but I don't use those.
|
||||||
|
editor.normalizeVisualLine(editor.bufferLineToVisualLine(editor.visualLineToBufferLine(pos.line) + count))
|
||||||
|
else
|
||||||
|
editor.normalizeVisualLine(pos.line + count)
|
||||||
|
|
||||||
if (intendedColumn == LAST_COLUMN) {
|
if (intendedColumn == LAST_COLUMN) {
|
||||||
val normalisedColumn = injector.engineEditorHelper.normalizeVisualColumn(
|
val normalisedColumn = injector.engineEditorHelper.normalizeVisualColumn(
|
||||||
|
|||||||
@@ -235,4 +235,17 @@ interface VimSearchGroup {
|
|||||||
fun isSomeTextHighlighted(): Boolean
|
fun isSomeTextHighlighted(): Boolean
|
||||||
|
|
||||||
fun getCurrentIncsearchResultRange(editor: VimEditor): TextRange?
|
fun getCurrentIncsearchResultRange(editor: VimEditor): TextRange?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the last search state purely for tests
|
||||||
|
*
|
||||||
|
* @param pattern The pattern to save. This is the last search pattern, not the last substitute pattern
|
||||||
|
* @param patternOffset The pattern offset, e.g. `/{pattern}/{offset}`
|
||||||
|
* @param direction The direction to search
|
||||||
|
*/
|
||||||
|
fun setLastSearchState(
|
||||||
|
pattern: String,
|
||||||
|
patternOffset: String,
|
||||||
|
direction: Direction,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
*
|
*
|
||||||
@@ -1599,8 +1586,7 @@ abstract class VimSearchGroupBase : VimSearchGroup {
|
|||||||
* @param patternOffset The pattern offset, e.g. `/{pattern}/{offset}`
|
* @param patternOffset The pattern offset, e.g. `/{pattern}/{offset}`
|
||||||
* @param direction The direction to search
|
* @param direction The direction to search
|
||||||
*/
|
*/
|
||||||
@TestOnly
|
override fun setLastSearchState(
|
||||||
fun setLastSearchState(
|
|
||||||
pattern: String,
|
pattern: String,
|
||||||
patternOffset: String,
|
patternOffset: String,
|
||||||
direction: Direction,
|
direction: Direction,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import com.maddyhome.idea.vim.api.ImmutableVimCaret
|
|||||||
import com.maddyhome.idea.vim.api.VimCaret
|
import com.maddyhome.idea.vim.api.VimCaret
|
||||||
import com.maddyhome.idea.vim.api.VimCaretListener
|
import com.maddyhome.idea.vim.api.VimCaretListener
|
||||||
import com.maddyhome.idea.vim.api.VimEditor
|
import com.maddyhome.idea.vim.api.VimEditor
|
||||||
|
import com.maddyhome.idea.vim.api.VimMotionGroupBase
|
||||||
import com.maddyhome.idea.vim.api.injector
|
import com.maddyhome.idea.vim.api.injector
|
||||||
import com.maddyhome.idea.vim.api.normalizeOffset
|
import com.maddyhome.idea.vim.api.normalizeOffset
|
||||||
import com.maddyhome.idea.vim.command.Argument
|
import com.maddyhome.idea.vim.command.Argument
|
||||||
@@ -247,7 +248,15 @@ sealed class MotionActionHandler : EditorActionHandlerBase(false) {
|
|||||||
StrictMode.assert(caret.isPrimary, "Block selection mode must only operate on primary caret")
|
StrictMode.assert(caret.isPrimary, "Block selection mode must only operate on primary caret")
|
||||||
}
|
}
|
||||||
|
|
||||||
val normalisedOffset = prepareMoveToAbsoluteOffset(editor, cmd, offset)
|
val normalisedOffset = prepareMoveToAbsoluteOffset(editor, cmd, offset).let {
|
||||||
|
if (offset.intendedColumn == VimMotionGroupBase.LAST_COLUMN) {
|
||||||
|
val softWrapStart = editor.getSoftWrapStartAtOffset(it)
|
||||||
|
if (softWrapStart != null) softWrapStart - 1 else it
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
it
|
||||||
|
}
|
||||||
|
}
|
||||||
StrictMode.assert(normalisedOffset == offset.offset, "Adjusted offset should be normalised by action")
|
StrictMode.assert(normalisedOffset == offset.offset, "Adjusted offset should be normalised by action")
|
||||||
|
|
||||||
// Set before moving, so it can be applied during move, especially important for LAST_COLUMN and visual block mode
|
// Set before moving, so it can be applied during move, especially important for LAST_COLUMN and visual block mode
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -792,6 +792,10 @@ class VimRegex(pattern: String) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun getSoftWrapStartAtOffset(offset: Int): Int? {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
override fun getAllFoldRegions(): List<VimFoldRegion> {
|
override fun getAllFoldRegions(): List<VimFoldRegion> {
|
||||||
return emptyList()
|
return emptyList()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ object RegisterConstants {
|
|||||||
const val CLIPBOARD_REGISTERS: String = CLIPBOARD_REGISTER.toString() + PRIMARY_REGISTER
|
const val CLIPBOARD_REGISTERS: String = CLIPBOARD_REGISTER.toString() + PRIMARY_REGISTER
|
||||||
|
|
||||||
const val NUMBERED_REGISTERS: String = "0123456789"
|
const val NUMBERED_REGISTERS: String = "0123456789"
|
||||||
const val NAMED_REGISTERS: String = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
const val MULTICARET_JOIN_REGISTER: Char = '\u0000'
|
||||||
|
const val NAMED_REGISTERS: String = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + MULTICARET_JOIN_REGISTER
|
||||||
|
|
||||||
const val WRITABLE_REGISTERS: String = (
|
const val WRITABLE_REGISTERS: String = (
|
||||||
NUMBERED_REGISTERS + NAMED_REGISTERS + CLIPBOARD_REGISTERS +
|
NUMBERED_REGISTERS + NAMED_REGISTERS + CLIPBOARD_REGISTERS +
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.maddyhome.idea.vim.yank
|
||||||
|
|
||||||
|
import com.maddyhome.idea.vim.common.VimCopiedText
|
||||||
|
|
||||||
|
data class MultiCaretJoinedText(val carets: Int, override val text: String): VimCopiedText {
|
||||||
|
companion object {
|
||||||
|
val EMPTY = MultiCaretJoinedText(0, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateText(newText: String): VimCopiedText {
|
||||||
|
return MultiCaretJoinedText(carets, newText)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,12 +16,15 @@ import com.maddyhome.idea.vim.api.VimEditor
|
|||||||
import com.maddyhome.idea.vim.api.anyNonWhitespace
|
import com.maddyhome.idea.vim.api.anyNonWhitespace
|
||||||
import com.maddyhome.idea.vim.api.getLineEndForOffset
|
import com.maddyhome.idea.vim.api.getLineEndForOffset
|
||||||
import com.maddyhome.idea.vim.api.getLineStartForOffset
|
import com.maddyhome.idea.vim.api.getLineStartForOffset
|
||||||
|
import com.maddyhome.idea.vim.api.getText
|
||||||
import com.maddyhome.idea.vim.api.injector
|
import com.maddyhome.idea.vim.api.injector
|
||||||
import com.maddyhome.idea.vim.command.Argument
|
import com.maddyhome.idea.vim.command.Argument
|
||||||
import com.maddyhome.idea.vim.command.MotionType
|
import com.maddyhome.idea.vim.command.MotionType
|
||||||
import com.maddyhome.idea.vim.command.OperatorArguments
|
import com.maddyhome.idea.vim.command.OperatorArguments
|
||||||
import com.maddyhome.idea.vim.common.TextRange
|
import com.maddyhome.idea.vim.common.TextRange
|
||||||
import com.maddyhome.idea.vim.handler.MotionActionHandler
|
import com.maddyhome.idea.vim.handler.MotionActionHandler
|
||||||
|
import com.maddyhome.idea.vim.register.Register
|
||||||
|
import com.maddyhome.idea.vim.register.RegisterConstants
|
||||||
import com.maddyhome.idea.vim.state.mode.SelectionType
|
import com.maddyhome.idea.vim.state.mode.SelectionType
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
|
|
||||||
@@ -42,6 +45,20 @@ open class YankGroupBase : VimYankGroup {
|
|||||||
for ((caret, myRange) in caretToRange) {
|
for ((caret, myRange) in caretToRange) {
|
||||||
result = caret.registerStorage.storeText(editor, context, myRange.first, myRange.second, false) && result
|
result = caret.registerStorage.storeText(editor, context, myRange.first, myRange.second, false) && result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val selectionType = caretToRange.values.first().second
|
||||||
|
val multiCaretJoinedText: MultiCaretJoinedText
|
||||||
|
if (result && caretToRange.size > 1) {
|
||||||
|
val joinedText = caretToRange.entries.sortedBy { it.key.offset }.joinToString("\n") { editor.getText(it.value.first).trimEnd('\n') }
|
||||||
|
multiCaretJoinedText = MultiCaretJoinedText(caretToRange.size, joinedText)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
multiCaretJoinedText = MultiCaretJoinedText.EMPTY
|
||||||
|
}
|
||||||
|
injector.registerGroup.saveRegister(editor, context, RegisterConstants.MULTICARET_JOIN_REGISTER, Register(
|
||||||
|
RegisterConstants.MULTICARET_JOIN_REGISTER, multiCaretJoinedText, selectionType)
|
||||||
|
)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -442,7 +442,7 @@
|
|||||||
{
|
{
|
||||||
"keys": "<C-R>",
|
"keys": "<C-R>",
|
||||||
"class": "com.maddyhome.idea.vim.action.change.RedoAction",
|
"class": "com.maddyhome.idea.vim.action.change.RedoAction",
|
||||||
"modes": "N"
|
"modes": "NX"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"keys": "<C-R>",
|
"keys": "<C-R>",
|
||||||
@@ -1092,7 +1092,7 @@
|
|||||||
{
|
{
|
||||||
"keys": "<Undo>",
|
"keys": "<Undo>",
|
||||||
"class": "com.maddyhome.idea.vim.action.change.UndoAction",
|
"class": "com.maddyhome.idea.vim.action.change.UndoAction",
|
||||||
"modes": "N"
|
"modes": "NX"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"keys": "<Up>",
|
"keys": "<Up>",
|
||||||
@@ -1316,8 +1316,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"keys": "U",
|
"keys": "U",
|
||||||
"class": "com.maddyhome.idea.vim.action.change.change.ChangeCaseUpperVisualAction",
|
"class": "com.maddyhome.idea.vim.action.change.RedoAction",
|
||||||
"modes": "X"
|
"modes": "NX"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"keys": "V",
|
"keys": "V",
|
||||||
@@ -1814,11 +1814,6 @@
|
|||||||
"class": "com.maddyhome.idea.vim.action.change.change.ChangeCaseUpperMotionAction",
|
"class": "com.maddyhome.idea.vim.action.change.change.ChangeCaseUpperMotionAction",
|
||||||
"modes": "N"
|
"modes": "N"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"keys": "gU",
|
|
||||||
"class": "com.maddyhome.idea.vim.action.change.change.ChangeCaseUpperVisualAction",
|
|
||||||
"modes": "X"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"keys": "g^",
|
"keys": "g^",
|
||||||
"class": "com.maddyhome.idea.vim.action.motion.leftright.MotionFirstScreenNonSpaceAction",
|
"class": "com.maddyhome.idea.vim.action.motion.leftright.MotionFirstScreenNonSpaceAction",
|
||||||
@@ -1929,11 +1924,6 @@
|
|||||||
"class": "com.maddyhome.idea.vim.action.change.change.ChangeCaseLowerMotionAction",
|
"class": "com.maddyhome.idea.vim.action.change.change.ChangeCaseLowerMotionAction",
|
||||||
"modes": "N"
|
"modes": "N"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"keys": "gu",
|
|
||||||
"class": "com.maddyhome.idea.vim.action.change.change.ChangeCaseLowerVisualAction",
|
|
||||||
"modes": "X"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"keys": "gv",
|
"keys": "gv",
|
||||||
"class": "com.maddyhome.idea.vim.action.motion.visual.VisualSelectPreviousAction",
|
"class": "com.maddyhome.idea.vim.action.motion.visual.VisualSelectPreviousAction",
|
||||||
@@ -2167,12 +2157,7 @@
|
|||||||
{
|
{
|
||||||
"keys": "u",
|
"keys": "u",
|
||||||
"class": "com.maddyhome.idea.vim.action.change.UndoAction",
|
"class": "com.maddyhome.idea.vim.action.change.UndoAction",
|
||||||
"modes": "N"
|
"modes": "NX"
|
||||||
},
|
|
||||||
{
|
|
||||||
"keys": "u",
|
|
||||||
"class": "com.maddyhome.idea.vim.action.change.change.ChangeCaseLowerVisualAction",
|
|
||||||
"modes": "X"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"keys": "v",
|
"keys": "v",
|
||||||
|
|||||||
Reference in New Issue
Block a user