├── .idea
├── .name
├── .gitignore
├── compiler.xml
├── kotlinc.xml
├── yatool.xml
├── misc.xml
└── codeStyles
├── settings.gradle.kts
├── gradle.properties
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── changelog.txt
├── src
├── main
│ ├── kotlin
│ │ └── io
│ │ │ └── github
│ │ │ └── mishkun
│ │ │ └── ataman
│ │ │ ├── ConfigService.kt
│ │ │ ├── PluginStartup.kt
│ │ │ ├── ReloadAtamanConfigAction.kt
│ │ │ ├── RepeatLatestAtamanCommandAction.kt
│ │ │ ├── OpenAtamanConfigAction.kt
│ │ │ ├── LeaderBinding.kt
│ │ │ ├── AtamanAction.kt
│ │ │ ├── LeaderPopupUI.kt
│ │ │ ├── LeaderKeyDispatcher.kt
│ │ │ └── AtamanConfig.kt
│ └── resources
│ │ └── META-INF
│ │ ├── plugin.xml
│ │ └── pluginIcon.svg
├── test
│ └── kotlin
│ │ └── io
│ │ └── github
│ │ └── mishkun
│ │ └── ataman
│ │ ├── core
│ │ ├── BaseTestWithConfig.kt
│ │ └── HomeDirStub.kt
│ │ ├── AtamanStartupTest.kt
│ │ ├── OpenAtamanConfigActionTest.kt
│ │ ├── AtamanConfigFileFinderTest.kt
│ │ ├── ReloadAtamanConfigActionTest.kt
│ │ ├── RepeatLatestAtamanCommandActionTest.kt
│ │ ├── ConfigServiceTest.kt
│ │ ├── AtamanActionUnitTest.kt
│ │ ├── MergeBindingsTest.kt
│ │ ├── LeaderKeyDispatcherTest.kt
│ │ └── AtamanConfigParsingTest.kt
└── uiTest
│ └── kotlin
│ └── io
│ └── github
│ └── mishkun
│ └── ataman
│ ├── MyTestCase.kt
│ └── AtamanActionTest.kt
├── .github
└── workflows
│ ├── build.yml
│ ├── release-snapshot.yml
│ └── release-plugin.yml
├── LICENSE.txt
├── example-config
└── .atamanrc.config
├── gradlew.bat
├── .gitignore
├── README.md
└── gradlew
/.idea/.name:
--------------------------------------------------------------------------------
1 | ataman-plugin
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | rootProject.name = "ataman-plugin"
2 |
3 |
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | org.gradle.jvmargs=-Xmx1024m
2 | kotlin.stdlib.default.dependency=false
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mishkun/ataman-intellij/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/changelog.txt:
--------------------------------------------------------------------------------
1 | Fix compatability issues with Intellij IDEA 2025.1, Bump minimum required version to 2024.2, Fix proper consuming of keys for bindings
2 |
--------------------------------------------------------------------------------
/.idea/compiler.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/kotlinc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | distributionBase=GRADLE_USER_HOME
2 | distributionPath=wrapper/dists
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
4 | networkTimeout=10000
5 | zipStoreBase=GRADLE_USER_HOME
6 | zipStorePath=wrapper/dists
7 |
--------------------------------------------------------------------------------
/.idea/yatool.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/src/main/kotlin/io/github/mishkun/ataman/ConfigService.kt:
--------------------------------------------------------------------------------
1 | package io.github.mishkun.ataman
2 |
3 | import com.intellij.openapi.components.Service
4 | import java.io.File
5 |
6 | @Service
7 | class ConfigService {
8 | var parsedBindings: List = emptyList()
9 | var latestCommand: String? = null
10 | }
11 |
12 | class Config {
13 | val configDir: File = File(System.getProperty("ataman.configFolder") ?: System.getProperty("user.home"))
14 | }
15 |
--------------------------------------------------------------------------------
/src/main/kotlin/io/github/mishkun/ataman/PluginStartup.kt:
--------------------------------------------------------------------------------
1 | package io.github.mishkun.ataman
2 |
3 | import com.intellij.openapi.application.ApplicationInfo
4 | import com.intellij.openapi.project.Project
5 | import com.intellij.openapi.startup.ProjectActivity
6 |
7 | class PluginStartup : ProjectActivity {
8 | override suspend fun execute(project: Project) {
9 | val configDir = Config().configDir
10 | val build = ApplicationInfo.getInstance().build.productCode
11 | updateConfig(project, configDir, build)
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/main/kotlin/io/github/mishkun/ataman/ReloadAtamanConfigAction.kt:
--------------------------------------------------------------------------------
1 | package io.github.mishkun.ataman
2 |
3 | import com.intellij.openapi.actionSystem.AnActionEvent
4 | import com.intellij.openapi.application.ApplicationInfo
5 | import com.intellij.openapi.project.DumbAwareAction
6 |
7 | class ReloadAtamanConfigAction : DumbAwareAction() {
8 | override fun actionPerformed(e: AnActionEvent) {
9 | val project = checkNotNull(e.project)
10 | val build = ApplicationInfo.getInstance().build.productCode
11 | updateConfig(project, Config().configDir, build)
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/main/kotlin/io/github/mishkun/ataman/RepeatLatestAtamanCommandAction.kt:
--------------------------------------------------------------------------------
1 | package io.github.mishkun.ataman
2 |
3 | import com.intellij.openapi.actionSystem.AnActionEvent
4 | import com.intellij.openapi.components.service
5 | import com.intellij.openapi.project.DumbAwareAction
6 |
7 | class RepeatLatestAtamanCommandAction : DumbAwareAction() {
8 | override fun actionPerformed(e: AnActionEvent) {
9 | val configService = service()
10 | configService.latestCommand?.let { command ->
11 | executeAction(command, e.dataContext)
12 | }
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/main/kotlin/io/github/mishkun/ataman/OpenAtamanConfigAction.kt:
--------------------------------------------------------------------------------
1 | package io.github.mishkun.ataman
2 |
3 | import com.intellij.ide.actions.OpenFileAction
4 | import com.intellij.openapi.actionSystem.AnActionEvent
5 | import com.intellij.openapi.project.DumbAwareAction
6 |
7 | class OpenAtamanConfigAction : DumbAwareAction() {
8 |
9 | override fun actionPerformed(e: AnActionEvent) {
10 | val eventProject = e.project
11 | if (eventProject != null) {
12 | val atamanRc = findOrCreateRcFile(Config().configDir)
13 | OpenFileAction.openFile(atamanRc.path, eventProject)
14 | }
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/src/test/kotlin/io/github/mishkun/ataman/core/BaseTestWithConfig.kt:
--------------------------------------------------------------------------------
1 | package io.github.mishkun.ataman.core
2 |
3 | import com.intellij.testFramework.LightPlatform4TestCase
4 |
5 | abstract class BaseTestWithConfig : LightPlatform4TestCase() {
6 | protected open val mockConfig: MockConfig = MockConfig()
7 |
8 | override fun setUp() {
9 | mockConfig.setup()
10 | System.setProperty("ataman.configFolder", mockConfig.configFolder.root.absolutePath)
11 | super.setUp()
12 | }
13 |
14 | override fun tearDown() {
15 | super.tearDown()
16 | mockConfig.teardown()
17 | setProject(null)
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/test/kotlin/io/github/mishkun/ataman/AtamanStartupTest.kt:
--------------------------------------------------------------------------------
1 | package io.github.mishkun.ataman
2 |
3 | import com.intellij.openapi.components.service
4 | import io.github.mishkun.ataman.core.BaseTestWithConfig
5 | import io.github.mishkun.ataman.core.MockConfig
6 | import org.hamcrest.MatcherAssert
7 | import org.hamcrest.Matchers
8 | import org.junit.Test
9 |
10 | class AtamanStartupTest : BaseTestWithConfig() {
11 |
12 | override val mockConfig: MockConfig = MockConfig()
13 |
14 | @Test
15 | fun `executes startup`() {
16 | MatcherAssert.assertThat(service().parsedBindings, Matchers.not(Matchers.empty()))
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/uiTest/kotlin/io/github/mishkun/ataman/MyTestCase.kt:
--------------------------------------------------------------------------------
1 | package io.github.mishkun.ataman
2 |
3 | import com.intellij.ide.starter.ide.IdeProductProvider
4 | import com.intellij.ide.starter.project.GitHubProject
5 | import com.intellij.ide.starter.project.TestCaseTemplate
6 |
7 | object UltimateCase : TestCaseTemplate(IdeProductProvider.IU) {
8 |
9 | val simpleProject = withProject(
10 | GitHubProject.fromGithub(
11 | branchName = "master",
12 | repoRelativeUrl = "Mishkun/Puerh.git"
13 | )
14 | )
15 | }
16 |
17 | object MyTestCase : TestCaseTemplate(IdeProductProvider.IC) {
18 |
19 | val simpleProject = withProject(
20 | GitHubProject.fromGithub(
21 | branchName = "master",
22 | repoRelativeUrl = "Mishkun/Puerh.git"
23 | )
24 | )
25 | }
26 |
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: Build & Test
2 |
3 | on:
4 | push:
5 | branches: [ master ]
6 | pull_request:
7 | branches: [ master ]
8 |
9 | jobs:
10 | build:
11 | runs-on: ubuntu-latest
12 | steps:
13 | - uses: actions/checkout@v4
14 | - name: Set up JDK 17
15 | uses: actions/setup-java@v4
16 | with:
17 | java-version: '17'
18 | distribution: 'temurin'
19 | cache: gradle
20 | - name: Build with Gradle
21 | run: ./gradlew build
22 | - name: Upload build artifacts
23 | uses: actions/upload-artifact@v4
24 | with:
25 | name: plugin-archive
26 | path: build/libs/*.jar
27 |
28 | - name: Upload test and coverage reports
29 | if: always()
30 | uses: actions/upload-artifact@v4
31 | with:
32 | name: build-reports
33 | path: |
34 | build/reports/tests/
35 | build/reports/kover/
36 | build/reports/plugin-verification/
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | Copyright © 2021 Mikhail Levchenko
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a
4 | copy of this software and associated documentation files (the “Software”),
5 | to deal in the Software without restriction, including without limitation
6 | the rights to use, copy, modify, merge, publish, distribute, sublicense,
7 | and/or sell copies of the Software, and to permit persons to whom the
8 | Software is furnished to do so, subject to the following conditions:
9 |
10 | The above copyright notice and this permission notice shall be included in
11 | all copies or substantial portions of the Software.
12 |
13 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
16 | THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
19 |
--------------------------------------------------------------------------------
/src/test/kotlin/io/github/mishkun/ataman/OpenAtamanConfigActionTest.kt:
--------------------------------------------------------------------------------
1 | package io.github.mishkun.ataman
2 |
3 | import com.intellij.openapi.components.service
4 | import com.intellij.openapi.editor.EditorFactory
5 | import com.intellij.openapi.vfs.newvfs.impl.VfsRootAccess
6 | import com.intellij.testFramework.PlatformTestUtil
7 | import io.github.mishkun.ataman.core.BaseTestWithConfig
8 | import org.hamcrest.MatcherAssert.assertThat
9 | import org.hamcrest.Matchers
10 | import org.junit.Test
11 |
12 | class OpenAtamanConfigActionTest : BaseTestWithConfig() {
13 | @Test
14 | fun `test action opens config file in editor`() {
15 | VfsRootAccess.allowRootAccess(testRootDisposable, mockConfig.configFile.canonicalPath)
16 | PlatformTestUtil.invokeNamedAction("OpenAtamanConfigAction")
17 | val editorFactory = service()
18 | val editor = editorFactory.allEditors.first()
19 | assertThat(editor, Matchers.notNullValue())
20 | assertThat(editor.virtualFile.canonicalPath, Matchers.equalTo(mockConfig.configFile.canonicalPath))
21 | editorFactory.releaseEditor(editor!!)
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/test/kotlin/io/github/mishkun/ataman/core/HomeDirStub.kt:
--------------------------------------------------------------------------------
1 | package io.github.mishkun.ataman.core
2 |
3 | import io.github.mishkun.ataman.ATAMAN_RC_FILENAME
4 | import io.github.mishkun.ataman.RC_TEMPLATE
5 | import org.junit.rules.TemporaryFolder
6 | import java.io.File
7 |
8 | class MockConfig(private val config: String = RC_TEMPLATE) {
9 |
10 | val configFolder: TemporaryFolder
11 | get() = Companion.configFolder
12 |
13 | val configFile: File
14 | get() = configFolder.root.resolve(ATAMAN_RC_FILENAME)
15 |
16 | fun setup() {
17 | configFolder.create()
18 | configFolder.setupStubConfigDir(config)
19 | }
20 |
21 | fun stubConfig(text: String) {
22 | configFolder.setupStubConfigDir(text)
23 | }
24 |
25 | fun teardown() {
26 | configFolder.delete()
27 | }
28 |
29 | companion object {
30 | @JvmStatic
31 | private val configFolder = TemporaryFolder()
32 | }
33 | }
34 |
35 | fun TemporaryFolder.setupEmptyConfigDir(): File = this.root
36 |
37 | fun TemporaryFolder.setupStubConfigDir(text: String = RC_TEMPLATE): File = this.root.also {
38 | File(it, ATAMAN_RC_FILENAME).writeText(text)
39 | }
40 |
--------------------------------------------------------------------------------
/src/main/kotlin/io/github/mishkun/ataman/LeaderBinding.kt:
--------------------------------------------------------------------------------
1 | package io.github.mishkun.ataman
2 |
3 | import javax.swing.KeyStroke
4 |
5 | sealed class LeaderBinding {
6 | abstract val key: KeyStroke
7 | abstract val char: String
8 | abstract val description: String
9 |
10 | data class SingleBinding(
11 | override val key: KeyStroke,
12 | override val char: String,
13 | override val description: String,
14 | val action: List
15 | ) : LeaderBinding() {
16 | constructor(
17 | key: KeyStroke,
18 | char: String,
19 | description: String,
20 | action: String
21 | ) : this(key, char, description, listOf(action))
22 | }
23 |
24 | data class GroupBinding(
25 | override val key: KeyStroke,
26 | override val char: String,
27 | override val description: String,
28 | val bindings: List
29 | ) : LeaderBinding()
30 | }
31 |
32 | fun List.toKeyStrokeMap(): Map = associateBy { it.key }
33 |
34 | fun List.toCharMap(): Map =
35 | filter { it.char.length == 1 }.associateBy { it.char[0] }
36 |
--------------------------------------------------------------------------------
/example-config/.atamanrc.config:
--------------------------------------------------------------------------------
1 | bindings { // always present
2 | // You can use actionId for calling actions from the IDE
3 | m {actionId: ShowIntentionActions, description: Local Intentions}
4 | // You can also define nested bindings
5 | q {
6 | // Description is required to show in the action list
7 | description: Session...
8 | // These bindings are nested under 'q' key
9 | bindings {
10 | // Even more nested bindings!
11 | a {
12 | description: Ataman...
13 | bindings {
14 | f {actionId: OpenAtamanConfigAction, description: 'Open ~/.atamanrc.config'}
15 | i {actionId: ReloadAtamanConfigAction, description: 'Reload from ~/.atamanrc.config'}
16 | }
17 | }
18 |
19 | // You can use F keys as well
20 | F1 {actionId: CloseProject, description: Close Project}
21 | }
22 | }
23 | }
24 |
25 | // Binding specific to the IDE. Intellij Ultimate in this case. See your IDE's about dialog for the correct value under the
26 | // build number section.
27 | // Build #-242.23339.11
28 | IU {
29 | // this binding will replace the default action for the key 'q'
30 | q { actionId: Vcs.Show.Log, description: Log }
31 | }
32 |
--------------------------------------------------------------------------------
/src/test/kotlin/io/github/mishkun/ataman/AtamanConfigFileFinderTest.kt:
--------------------------------------------------------------------------------
1 | package io.github.mishkun.ataman
2 |
3 | import io.github.mishkun.ataman.core.setupEmptyConfigDir
4 | import io.github.mishkun.ataman.core.setupStubConfigDir
5 | import org.hamcrest.MatcherAssert.assertThat
6 | import org.hamcrest.Matchers.allOf
7 | import org.hamcrest.Matchers.`is`
8 | import org.hamcrest.Matchers.notNullValue
9 | import org.hamcrest.io.FileMatchers.aFileNamed
10 | import org.hamcrest.io.FileMatchers.anExistingFile
11 | import org.junit.Rule
12 | import org.junit.Test
13 | import org.junit.rules.TemporaryFolder
14 |
15 | class AtamanConfigFileFinderTest {
16 |
17 | @get:Rule
18 | val tmpFolder = TemporaryFolder()
19 |
20 | @Test
21 | fun `creates rc file if it does not exist`() {
22 | val foundRcFile = findOrCreateRcFile(tmpFolder.setupEmptyConfigDir())
23 | assertThat(foundRcFile, notNullValue())
24 | assertThat(
25 | foundRcFile, allOf(
26 | anExistingFile(),
27 | aFileNamed(`is`(ATAMAN_RC_FILENAME)),
28 | )
29 | )
30 | assertThat(foundRcFile.readText(), `is`(RC_TEMPLATE))
31 | }
32 |
33 | @Test
34 | fun `finds rc file if it exists`() {
35 | val foundRcFile = findOrCreateRcFile(tmpFolder.setupStubConfigDir())
36 | assertThat(foundRcFile, notNullValue())
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/test/kotlin/io/github/mishkun/ataman/ReloadAtamanConfigActionTest.kt:
--------------------------------------------------------------------------------
1 | package io.github.mishkun.ataman
2 |
3 | import com.intellij.notification.Notification
4 | import com.intellij.notification.NotificationsManager
5 | import com.intellij.openapi.components.service
6 | import com.intellij.openapi.project.Project
7 | import com.intellij.testFramework.PlatformTestUtil
8 | import io.github.mishkun.ataman.core.BaseTestWithConfig
9 | import org.hamcrest.MatcherAssert.assertThat
10 | import org.hamcrest.Matchers
11 | import org.junit.Test
12 |
13 | class ReloadAtamanConfigActionTest : BaseTestWithConfig() {
14 |
15 | @Test
16 | fun `reloads config successfully`() {
17 | mockConfig.stubConfig("bindings { q { actionId: A, description: Session }, w { actionId: B, description: Session } }")
18 | reloadWithAction()
19 | assertThat(service().parsedBindings.size, Matchers.equalTo(2))
20 | }
21 |
22 | @Test
23 | fun `displays a notification if bindings schema is invalid`() {
24 | mockConfig.stubConfig("bindings { q { description: Session } }")
25 | reloadWithAction()
26 | project.checkNotification("Bindings schema is invalid. Aborting...")
27 | }
28 |
29 | @Test
30 | fun `displays a notification if config is malformed`() {
31 | mockConfig.stubConfig("}malformed{")
32 | reloadWithAction()
33 | project.checkNotification("Config is malformed. Aborting...")
34 | }
35 | }
36 |
37 | private fun reloadWithAction() {
38 | PlatformTestUtil.invokeNamedAction("ReloadAtamanConfigAction")
39 | }
40 |
41 | private fun Project.checkNotification(notification: String) {
42 | val notifications = NotificationsManager.getNotificationsManager()
43 | .getNotificationsOfType(Notification::class.java, this)
44 | assertThat(
45 | notifications.toList(), Matchers.allOf(
46 | Matchers.not(Matchers.empty()),
47 | Matchers.hasSize(1)
48 | )
49 | )
50 | assertThat(
51 | notifications.first().content,
52 | Matchers.containsString(notification)
53 | )
54 | }
55 |
--------------------------------------------------------------------------------
/src/test/kotlin/io/github/mishkun/ataman/RepeatLatestAtamanCommandActionTest.kt:
--------------------------------------------------------------------------------
1 | package io.github.mishkun.ataman
2 |
3 | import com.intellij.ide.DataManager
4 | import com.intellij.openapi.actionSystem.AnAction
5 | import com.intellij.openapi.actionSystem.AnActionEvent
6 | import com.intellij.openapi.actionSystem.ex.AnActionListener
7 | import com.intellij.testFramework.PlatformTestUtil
8 | import com.intellij.util.application
9 | import io.github.mishkun.ataman.core.BaseTestWithConfig
10 | import org.hamcrest.MatcherAssert.assertThat
11 | import org.hamcrest.Matchers
12 | import org.junit.Test
13 |
14 | class RepeatLatestAtamanCommandActionTest : BaseTestWithConfig() {
15 |
16 | private val recentActions = mutableListOf()
17 |
18 | private val myActionListener = object : AnActionListener {
19 | override fun beforeActionPerformed(action: AnAction, event: AnActionEvent) {
20 | recentActions.add(action.javaClass.simpleName)
21 | }
22 | }
23 |
24 | @Test
25 | fun `executes action`() {
26 | application.messageBus.connect().subscribe(AnActionListener.TOPIC, myActionListener)
27 | val bindings = listOf(
28 | LeaderBinding.SingleBinding(
29 | getKeyStroke('c'),
30 | "c",
31 | "CommentAction",
32 | "CommentByLineComment"
33 | )
34 | )
35 | val popup = LeaderPopupUI(
36 | project,
37 | LeaderListStep(
38 | "Ataman",
39 | DataManager.getInstance().dataContext,
40 | bindings,
41 | )
42 | )
43 | popup.selectAndExecuteValue('c')
44 | popup.handleSelect(true)
45 | PlatformTestUtil.invokeNamedAction("RepeatLatestAtamanCommandAction")
46 | PlatformTestUtil.dispatchAllEventsInIdeEventQueue()
47 | assertThat(
48 | recentActions,
49 | Matchers.containsInRelativeOrder(
50 | "CommentByLineCommentAction",
51 | "RepeatLatestAtamanCommandAction",
52 | "CommentByLineCommentAction"
53 | )
54 | )
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/.github/workflows/release-snapshot.yml:
--------------------------------------------------------------------------------
1 | name: Release Snapshot
2 |
3 | on:
4 | push:
5 | branches: [ master ]
6 |
7 | jobs:
8 | release-snapshot:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - uses: actions/checkout@v4
12 | with:
13 | fetch-depth: 2 # Need at least 2 commits to get the commit message
14 |
15 | - name: Set up JDK 21
16 | uses: actions/setup-java@v4
17 | with:
18 | java-version: '21'
19 | distribution: 'temurin'
20 | cache: gradle
21 |
22 | - name: Build with Gradle
23 | run: ./gradlew buildPlugin
24 |
25 | - name: Get plugin version
26 | id: get_version
27 | run: echo "VERSION=$(grep 'version =' build.gradle.kts | head -n 1 | cut -d '"' -f 2)-SNAPSHOT" >> $GITHUB_OUTPUT
28 |
29 | - name: Get commit details
30 | id: get_commit
31 | run: |
32 | COMMIT_HASH=$(git rev-parse --short HEAD)
33 | COMMIT_MESSAGE=$(git log -1 --pretty=%B)
34 | echo "COMMIT=${COMMIT_HASH}" >> $GITHUB_OUTPUT
35 | # Escape newlines and special characters for GitHub Actions
36 | COMMIT_MESSAGE="${COMMIT_MESSAGE//'%'/'%25'}"
37 | COMMIT_MESSAGE="${COMMIT_MESSAGE//$'\n'/'%0A'}"
38 | COMMIT_MESSAGE="${COMMIT_MESSAGE//$'\r'/'%0D'}"
39 | echo "COMMIT_MESSAGE=${COMMIT_MESSAGE}" >> $GITHUB_OUTPUT
40 |
41 | - name: Create GitHub Release
42 | uses: softprops/action-gh-release@v2
43 | with:
44 | tag_name: snapshot-${{ steps.get_commit.outputs.COMMIT }}
45 | name: Snapshot ${{ steps.get_version.outputs.VERSION }}-${{ steps.get_commit.outputs.COMMIT }}
46 | body: |
47 | Automated snapshot build from commit ${{ steps.get_commit.outputs.COMMIT }}
48 |
49 | **Commit Message:**
50 | ${{ steps.get_commit.outputs.COMMIT_MESSAGE }}
51 |
52 | This is an automated snapshot release created from the latest commit to the master branch.
53 | files: build/distributions/*.jar
54 | prerelease: true
55 | env:
56 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
--------------------------------------------------------------------------------
/src/test/kotlin/io/github/mishkun/ataman/ConfigServiceTest.kt:
--------------------------------------------------------------------------------
1 | package io.github.mishkun.ataman
2 |
3 | import org.junit.Assert.assertEquals
4 | import org.junit.Assert.assertTrue
5 | import org.junit.Test
6 | import java.awt.event.KeyEvent
7 | import javax.swing.KeyStroke
8 |
9 | class LeaderBindingExtensionsTest {
10 |
11 | @Test
12 | fun `toKeyStrokeMap returns empty map for empty list`() {
13 | val bindings = emptyList()
14 | assertTrue(bindings.toKeyStrokeMap().isEmpty())
15 | }
16 |
17 | @Test
18 | fun `toKeyStrokeMap creates map keyed by keystroke`() {
19 | val keyStrokeA = KeyStroke.getKeyStroke(KeyEvent.VK_A, 0, true)
20 | val keyStrokeB = KeyStroke.getKeyStroke(KeyEvent.VK_B, 0, true)
21 |
22 | val bindings = listOf(
23 | LeaderBinding.SingleBinding(keyStrokeA, "a", "Action A", "ActionA"),
24 | LeaderBinding.SingleBinding(keyStrokeB, "b", "Action B", "ActionB")
25 | )
26 |
27 | val map = bindings.toKeyStrokeMap()
28 |
29 | assertEquals(2, map.size)
30 | assertEquals(bindings[0], map[keyStrokeA])
31 | assertEquals(bindings[1], map[keyStrokeB])
32 | }
33 |
34 | @Test
35 | fun `toKeyStrokeMap works with group bindings`() {
36 | val keyStrokeG = KeyStroke.getKeyStroke(KeyEvent.VK_G, 0, false)
37 | val keyStrokeA = KeyStroke.getKeyStroke(KeyEvent.VK_A, 0, true)
38 |
39 | val nestedBindings = listOf(
40 | LeaderBinding.SingleBinding(keyStrokeA, "a", "Nested Action", "NestedAction")
41 | )
42 | val groupBinding = LeaderBinding.GroupBinding(keyStrokeG, "g", "Group", nestedBindings)
43 |
44 | val map = listOf(groupBinding).toKeyStrokeMap()
45 |
46 | assertEquals(1, map.size)
47 | assertEquals(groupBinding, map[keyStrokeG])
48 | }
49 |
50 | @Test
51 | fun `toKeyStrokeMap works for nested bindings list`() {
52 | val keyStrokeA = KeyStroke.getKeyStroke(KeyEvent.VK_A, 0, true)
53 | val keyStrokeB = KeyStroke.getKeyStroke(KeyEvent.VK_B, 0, true)
54 |
55 | val nestedBindings = listOf(
56 | LeaderBinding.SingleBinding(keyStrokeA, "a", "Action A", "ActionA"),
57 | LeaderBinding.SingleBinding(keyStrokeB, "b", "Action B", "ActionB")
58 | )
59 |
60 | val nestedMap = nestedBindings.toKeyStrokeMap()
61 |
62 | assertEquals(2, nestedMap.size)
63 | assertEquals(nestedBindings[0], nestedMap[keyStrokeA])
64 | assertEquals(nestedBindings[1], nestedMap[keyStrokeB])
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/src/test/kotlin/io/github/mishkun/ataman/AtamanActionUnitTest.kt:
--------------------------------------------------------------------------------
1 | package io.github.mishkun.ataman
2 |
3 | import com.intellij.ide.DataManager
4 | import com.intellij.openapi.actionSystem.AnAction
5 | import com.intellij.openapi.actionSystem.AnActionEvent
6 | import com.intellij.openapi.actionSystem.ex.AnActionListener
7 | import com.intellij.testFramework.LightPlatform4TestCase
8 | import com.intellij.util.application
9 | import org.hamcrest.MatcherAssert.assertThat
10 | import org.hamcrest.Matchers
11 | import org.junit.Test
12 |
13 | class AtamanActionUnitTest : LightPlatform4TestCase() {
14 |
15 | private val recentActions = mutableListOf()
16 |
17 | private val myActionListener = object : AnActionListener {
18 | override fun beforeActionPerformed(action: AnAction, event: AnActionEvent) {
19 | recentActions.add(action.javaClass.simpleName)
20 | }
21 | }
22 |
23 | @Test
24 | fun `executes multiple actions`() {
25 | application.messageBus.connect().subscribe(AnActionListener.TOPIC, myActionListener)
26 | val bindings = listOf(
27 | LeaderBinding.SingleBinding(
28 | getKeyStroke('w'),
29 | "w",
30 | "split and unsplit",
31 | listOf("SplitVertically", "Unsplit")
32 | )
33 | )
34 | val popup = LeaderPopupUI(
35 | project,
36 | LeaderListStep(
37 | "Ataman",
38 | dataContext(),
39 | bindings,
40 | )
41 | )
42 | popup.selectAndExecuteValue('w')
43 | popup.handleSelect(true)
44 | assertThat(
45 | recentActions,
46 | Matchers.containsInRelativeOrder(
47 | "SplitVerticallyAction",
48 | "Unsplit"
49 | )
50 | )
51 | }
52 |
53 | @Test
54 | fun `executes action`() {
55 | application.messageBus.connect().subscribe(AnActionListener.TOPIC, myActionListener)
56 | val bindings = listOf(
57 | LeaderBinding.SingleBinding(
58 | getKeyStroke('c'),
59 | "c",
60 | "CommentAction",
61 | "CommentByLineComment"
62 | )
63 | )
64 | val popup = LeaderPopupUI(
65 | project,
66 | LeaderListStep(
67 | "Ataman",
68 | dataContext(),
69 | bindings,
70 | )
71 | )
72 | popup.selectAndExecuteValue('c')
73 | popup.handleSelect(true)
74 | assertThat(recentActions, Matchers.contains("CommentByLineCommentAction"))
75 | }
76 |
77 | @Suppress("DEPRECATION")
78 | private fun dataContext() = DataManager.getInstance().dataContext
79 | }
80 |
--------------------------------------------------------------------------------
/src/main/resources/META-INF/plugin.xml:
--------------------------------------------------------------------------------
1 |
2 | io.github.mishkun.ataman-plugin
3 | Ataman
4 | Mishkun
5 |
6 |
11 | LeaderAction can be used to access leader menu everywhere
12 | TransparentLeaderAction triggers only when no editable text is focused
13 |
14 | If you want to use modifier-base shortcut like C-c, just assign LeaderAction to it.
15 | If you want to use SPACE as leader, install IdeaVim plugin and set SPACE as a shortcut
16 | for TransparentLeaderAction and add `:nnoremap :action LeaderAction`
17 | to your ~/.ideavimrc file. This way SPACE will work as a leader unless you are entering
18 | some text anywhere
19 |
20 | More info and examples can be found at the plugin's GitHub.
21 | ]]>
22 |
23 |
25 | com.intellij.modules.platform
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
37 |
38 |
39 |
42 |
43 |
45 |
46 |
47 |
48 |
51 |
54 |
55 |
56 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%"=="" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%"=="" set DIRNAME=.
29 | @rem This is normally unused
30 | set APP_BASE_NAME=%~n0
31 | set APP_HOME=%DIRNAME%
32 |
33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
35 |
36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
38 |
39 | @rem Find java.exe
40 | if defined JAVA_HOME goto findJavaFromJavaHome
41 |
42 | set JAVA_EXE=java.exe
43 | %JAVA_EXE% -version >NUL 2>&1
44 | if %ERRORLEVEL% equ 0 goto execute
45 |
46 | echo.
47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
48 | echo.
49 | echo Please set the JAVA_HOME variable in your environment to match the
50 | echo location of your Java installation.
51 |
52 | goto fail
53 |
54 | :findJavaFromJavaHome
55 | set JAVA_HOME=%JAVA_HOME:"=%
56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
57 |
58 | if exist "%JAVA_EXE%" goto execute
59 |
60 | echo.
61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
62 | echo.
63 | echo Please set the JAVA_HOME variable in your environment to match the
64 | echo location of your Java installation.
65 |
66 | goto fail
67 |
68 | :execute
69 | @rem Setup the command line
70 |
71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
72 |
73 |
74 | @rem Execute Gradle
75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
76 |
77 | :end
78 | @rem End local scope for the variables with windows NT shell
79 | if %ERRORLEVEL% equ 0 goto mainEnd
80 |
81 | :fail
82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
83 | rem the _cmd.exe /c_ return code!
84 | set EXIT_CODE=%ERRORLEVEL%
85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1
86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
87 | exit /b %EXIT_CODE%
88 |
89 | :mainEnd
90 | if "%OS%"=="Windows_NT" endlocal
91 |
92 | :omega
93 |
--------------------------------------------------------------------------------
/src/main/kotlin/io/github/mishkun/ataman/AtamanAction.kt:
--------------------------------------------------------------------------------
1 | package io.github.mishkun.ataman
2 |
3 | import com.intellij.openapi.actionSystem.ActionManager
4 | import com.intellij.openapi.actionSystem.ActionPlaces
5 | import com.intellij.openapi.actionSystem.ActionUiKind
6 | import com.intellij.openapi.actionSystem.ActionUpdateThread
7 | import com.intellij.openapi.actionSystem.AnActionEvent
8 | import com.intellij.openapi.actionSystem.DataContext
9 | import com.intellij.openapi.actionSystem.ex.ActionUtil
10 | import com.intellij.openapi.components.service
11 | import com.intellij.openapi.project.DumbAwareAction
12 | import com.intellij.openapi.ui.popup.JBPopupListener
13 | import com.intellij.openapi.ui.popup.LightweightWindowEvent
14 | import com.intellij.ui.speedSearch.SpeedSearchSupply
15 | import java.awt.KeyboardFocusManager
16 | import javax.swing.JComponent
17 | import javax.swing.text.JTextComponent
18 |
19 | class TransparentLeaderAction : DumbAwareAction() {
20 |
21 | private val delegateAction = LeaderAction()
22 |
23 | override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT
24 |
25 | override fun update(e: AnActionEvent) {
26 | super.update(e)
27 | val focusOwner = getCurrentFocus() ?: kotlin.run {
28 | e.presentation.isEnabled = false
29 | return
30 | }
31 | val isSupplyActive = isSpeedSearchActive(focusOwner)
32 | val isTextField = isTextField(focusOwner)
33 | e.presentation.isEnabled = !(isSupplyActive || isTextField)
34 | }
35 |
36 | override fun actionPerformed(e: AnActionEvent) {
37 | delegateAction.actionPerformed(e)
38 | }
39 |
40 | private fun getCurrentFocus(): JComponent? {
41 | val focusManager = KeyboardFocusManager.getCurrentKeyboardFocusManager()
42 | return focusManager.focusOwner as? JComponent
43 | }
44 |
45 | private fun isTextField(focusOwner: JComponent) = focusOwner is JTextComponent
46 |
47 | private fun isSpeedSearchActive(focusOwner: JComponent): Boolean {
48 | fun JComponent.getSupply(): SpeedSearchSupply? =
49 | SpeedSearchSupply.getSupply(this) ?: (parent as? JComponent)?.getSupply()
50 |
51 | val supply = focusOwner.getSupply()
52 | return supply?.isPopupActive == true
53 | }
54 | }
55 |
56 | class LeaderAction : DumbAwareAction() {
57 |
58 | override fun actionPerformed(event: AnActionEvent) {
59 | val dispatcher = service()
60 | val bindings = service().parsedBindings
61 |
62 | // Start key capture FIRST, before any UI work
63 | dispatcher.start(bindings, event.dataContext)
64 |
65 | // Create display-only popup
66 | val popup = LeaderPopupUI(
67 | event.project,
68 | LeaderListStep(
69 | "Ataman",
70 | event.dataContext,
71 | values = bindings
72 | )
73 | )
74 |
75 | // Wire up lifecycle: popup close -> stop dispatcher
76 | popup.addListener(object : JBPopupListener {
77 | override fun onClosed(event: LightweightWindowEvent) {
78 | dispatcher.stop()
79 | }
80 | })
81 |
82 | // Show popup
83 | val project = event.project
84 | if (project != null) {
85 | popup.showCenteredInCurrentWindow(project)
86 | } else {
87 | popup.showInFocusCenter()
88 | }
89 | }
90 | }
91 |
92 | fun executeAction(actionId: String, context: DataContext) {
93 | val action = ActionManager.getInstance().getAction(actionId)
94 | val event = AnActionEvent.createEvent(
95 | action, context, null, ActionPlaces.KEYBOARD_SHORTCUT, ActionUiKind.NONE, null,
96 | )
97 | ActionUtil.performDumbAwareUpdate(action, event, true)
98 | ActionUtil.invokeAction(action, event, null)
99 | service().latestCommand = actionId
100 | }
101 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Created by .ignore support plugin (hsz.mobi)
2 | ### Java template
3 | # Compiled class file
4 | *.class
5 |
6 | # Log file
7 | *.log
8 |
9 | # BlueJ files
10 | *.ctxt
11 |
12 | # Mobile Tools for Java (J2ME)
13 | .mtj.tmp/
14 |
15 | # Package Files #
16 | *.jar
17 | *.war
18 | *.nar
19 | *.ear
20 | *.zip
21 | *.tar.gz
22 | *.rar
23 |
24 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
25 | hs_err_pid*
26 |
27 | ### JetBrains template
28 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm
29 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
30 |
31 | # User-specific stuff
32 | .idea/**/workspace.xml
33 | .idea/**/tasks.xml
34 | .idea/**/usage.statistics.xml
35 | .idea/**/dictionaries
36 | .idea/**/shelf
37 |
38 | # Generated files
39 | .idea/**/contentModel.xml
40 |
41 | # Sensitive or high-churn files
42 | .idea/**/dataSources/
43 | .idea/**/dataSources.ids
44 | .idea/**/dataSources.local.xml
45 | .idea/**/sqlDataSources.xml
46 | .idea/**/dynamic.xml
47 | .idea/**/uiDesigner.xml
48 | .idea/**/dbnavigator.xml
49 |
50 | # Gradle
51 | .idea/**/gradle.xml
52 | .idea/**/libraries
53 |
54 | # Gradle and Maven with auto-import
55 | # When using Gradle or Maven with auto-import, you should exclude module files,
56 | # since they will be recreated, and may cause churn. Uncomment if using
57 | # auto-import.
58 | # .idea/modules.xml
59 | # .idea/*.iml
60 | # .idea/modules
61 | # *.iml
62 | # *.ipr
63 |
64 | # CMake
65 | cmake-build-*/
66 |
67 | # Mongo Explorer plugin
68 | .idea/**/mongoSettings.xml
69 |
70 | # File-based project format
71 | *.iws
72 |
73 | # IntelliJ
74 | out/
75 |
76 | # mpeltonen/sbt-idea plugin
77 | .idea_modules/
78 |
79 | # JIRA plugin
80 | atlassian-ide-plugin.xml
81 |
82 | # Cursive Clojure plugin
83 | .idea/replstate.xml
84 |
85 | # Crashlytics plugin (for Android Studio and IntelliJ)
86 | com_crashlytics_export_strings.xml
87 | crashlytics.properties
88 | crashlytics-build.properties
89 | fabric.properties
90 |
91 | # Editor-based Rest Client
92 | .idea/httpRequests
93 |
94 | # Android studio 3.1+ serialized cache file
95 | .idea/caches/build_file_checksums.ser
96 |
97 | ### Vim template
98 | # Swap
99 | [._]*.s[a-v][a-z]
100 | [._]*.sw[a-p]
101 | [._]s[a-rt-v][a-z]
102 | [._]ss[a-gi-z]
103 | [._]sw[a-p]
104 |
105 | # Session
106 | Session.vim
107 | Sessionx.vim
108 |
109 | # Temporary
110 | .netrwhist
111 | *~
112 | # Auto-generated tag files
113 | tags
114 | # Persistent undo
115 | [._]*.un~
116 |
117 | ### Gradle template
118 | .gradle
119 | /build/
120 |
121 | # Ignore Gradle GUI config
122 | gradle-app.setting
123 |
124 | # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored)
125 | !gradle-wrapper.jar
126 |
127 | # Cache of project
128 | .gradletasknamecache
129 |
130 | # # Work around https://youtrack.jetbrains.com/issue/IDEA-116898
131 | # gradle/wrapper/gradle-wrapper.properties
132 |
133 | ### macOS template
134 | # General
135 | .DS_Store
136 | .AppleDouble
137 | .LSOverride
138 |
139 | # Icon must end with two \r
140 | Icon
141 |
142 | # Thumbnails
143 | ._*
144 |
145 | # Files that might appear in the root of a volume
146 | .DocumentRevisions-V100
147 | .fseventsd
148 | .Spotlight-V100
149 | .TemporaryItems
150 | .Trashes
151 | .VolumeIcon.icns
152 | .com.apple.timemachine.donotpresent
153 |
154 | # Directories potentially created on remote AFP share
155 | .AppleDB
156 | .AppleDesktop
157 | Network Trash Folder
158 | Temporary Items
159 | .apdisk
160 |
161 | /AceJump/
162 | /ideavim/
163 | /.idea/$PRODUCT_WORKSPACE_FILE$
164 | /.idea/StickySelectionHighlights.xml
165 | /.idea/vcs.xml
166 | /.idea/jarRepositories.xml
167 | /.idea/modules.xml
168 | /allure-results/
169 | /.intellijPlatform/
170 |
171 | # Claude Code
172 | .claude/
173 |
174 | # Auto-generated module files
175 | .idea/modules/
176 | .idea/*.iml
177 | *.iml
178 |
179 | # Scratchpad
180 | scratchpad.txt
181 |
--------------------------------------------------------------------------------
/.idea/codeStyles:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | xmlns:android
16 |
17 | ^$
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | xmlns:.*
27 |
28 | ^$
29 |
30 |
31 | BY_NAME
32 |
33 |
34 |
35 |
36 |
37 |
38 | .*:id
39 |
40 | http://schemas.android.com/apk/res/android
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 | .*:name
50 |
51 | http://schemas.android.com/apk/res/android
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 | name
61 |
62 | ^$
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 | style
72 |
73 | ^$
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 | .*
83 |
84 | ^$
85 |
86 |
87 | BY_NAME
88 |
89 |
90 |
91 |
92 |
93 |
94 | .*
95 |
96 | http://schemas.android.com/apk/res/android
97 |
98 |
99 | ANDROID_ATTRIBUTE_ORDER
100 |
101 |
102 |
103 |
104 |
105 |
106 | .*
107 |
108 | .*
109 |
110 |
111 | BY_NAME
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
--------------------------------------------------------------------------------
/.github/workflows/release-plugin.yml:
--------------------------------------------------------------------------------
1 | name: Build & Release Plugin
2 |
3 | on:
4 | workflow_dispatch:
5 | inputs:
6 | release_type:
7 | description: 'Release Type'
8 | required: true
9 | default: 'patch'
10 | type: choice
11 | options:
12 | - patch
13 | - minor
14 | - major
15 | additional_notes:
16 | description: 'Additional Release Notes (optional)'
17 | required: false
18 | type: string
19 |
20 | jobs:
21 | build-and-release:
22 | runs-on: ubuntu-latest
23 | steps:
24 | - uses: actions/checkout@v4
25 | with:
26 | fetch-depth: 0 # Fetch all history for tags and commits
27 |
28 | - name: Set up JDK 21
29 | uses: actions/setup-java@v4
30 | with:
31 | java-version: '21'
32 | distribution: 'temurin'
33 | cache: gradle
34 |
35 | - name: Get current version
36 | id: get_current_version
37 | run: echo "CURRENT_VERSION=$(grep 'version =' build.gradle.kts | head -n 1 | cut -d '"' -f 2)" >> $GITHUB_OUTPUT
38 |
39 | - name: Calculate new version
40 | id: calculate_version
41 | run: |
42 | CURRENT_VERSION=${{ steps.get_current_version.outputs.CURRENT_VERSION }}
43 | RELEASE_TYPE=${{ github.event.inputs.release_type }}
44 |
45 | major=$(echo $CURRENT_VERSION | cut -d. -f1)
46 | minor=$(echo $CURRENT_VERSION | cut -d. -f2)
47 | patch=$(echo $CURRENT_VERSION | cut -d. -f3)
48 |
49 | if [ "$RELEASE_TYPE" == "major" ]; then
50 | major=$((major + 1))
51 | minor=0
52 | patch=0
53 | elif [ "$RELEASE_TYPE" == "minor" ]; then
54 | minor=$((minor + 1))
55 | patch=0
56 | else
57 | patch=$((patch + 1))
58 | fi
59 |
60 | NEW_VERSION="${major}.${minor}.${patch}"
61 | echo "NEW_VERSION=${NEW_VERSION}" >> $GITHUB_OUTPUT
62 |
63 | - name: Update version in build.gradle.kts
64 | run: |
65 | sed -i "s/version = \"${{ steps.get_current_version.outputs.CURRENT_VERSION }}\"/version = \"${{ steps.calculate_version.outputs.NEW_VERSION }}\"/" build.gradle.kts
66 |
67 | - name: Generate changelog from commits
68 | id: generate_changelog
69 | run: |
70 | # Find the latest tag
71 | LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
72 |
73 | if [ -z "$LATEST_TAG" ]; then
74 | # If no tags exist, use all commits
75 | echo "No previous tags found. Using all commits for changelog."
76 | CHANGELOG=$(git log --pretty=format:"- %s" --no-merges)
77 | else
78 | echo "Found latest tag: $LATEST_TAG"
79 | # Get commits since the latest tag
80 | CHANGELOG=$(git log ${LATEST_TAG}..HEAD --pretty=format:"- %s" --no-merges)
81 | fi
82 |
83 | # Add additional notes if provided
84 | ADDITIONAL_NOTES="${{ github.event.inputs.additional_notes }}"
85 | if [ ! -z "$ADDITIONAL_NOTES" ]; then
86 | CHANGELOG="${ADDITIONAL_NOTES}
87 |
88 | ${CHANGELOG}"
89 | fi
90 |
91 | # Save changelog to file for build
92 | echo "$CHANGELOG" > changelog.txt
93 |
94 | # Save changelog for GitHub release body
95 | CHANGELOG="${CHANGELOG//'%'/'%25'}"
96 | CHANGELOG="${CHANGELOG//$'\n'/'%0A'}"
97 | CHANGELOG="${CHANGELOG//$'\r'/'%0D'}"
98 | echo "CHANGELOG=${CHANGELOG}" >> $GITHUB_OUTPUT
99 |
100 | - name: Build plugin
101 | run: ./gradlew buildPlugin
102 |
103 | - name: Commit version bump and changelog
104 | run: |
105 | git config --local user.email "action@github.com"
106 | git config --local user.name "GitHub Action"
107 | git add build.gradle.kts changelog.txt
108 | git commit -m "Bump version to ${{ steps.calculate_version.outputs.NEW_VERSION }}"
109 | git tag -a "v${{ steps.calculate_version.outputs.NEW_VERSION }}" -m "Version ${{ steps.calculate_version.outputs.NEW_VERSION }}"
110 | git push --follow-tags
111 |
112 | - name: Create GitHub Release
113 | uses: softprops/action-gh-release@v2
114 | with:
115 | tag_name: v${{ steps.calculate_version.outputs.NEW_VERSION }}
116 | name: Release v${{ steps.calculate_version.outputs.NEW_VERSION }}
117 | body: ${{ steps.generate_changelog.outputs.CHANGELOG }}
118 | files: build/distributions/*.jar
119 | draft: false
120 | prerelease: false
121 | env:
122 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
123 |
--------------------------------------------------------------------------------
/src/test/kotlin/io/github/mishkun/ataman/MergeBindingsTest.kt:
--------------------------------------------------------------------------------
1 | package io.github.mishkun.ataman
2 |
3 | import org.hamcrest.MatcherAssert.assertThat
4 | import org.hamcrest.Matchers
5 | import org.junit.Test
6 |
7 | class MergeBindingsTest {
8 |
9 | @Test
10 | fun `replaces with override if type mismatches`() {
11 | val singleBindingOriginal = LeaderBinding.GroupBinding(
12 | getKeyStroke('c'),
13 | "c",
14 | "Comment...",
15 | bindings = listOf(
16 | LeaderBinding.SingleBinding(
17 | getKeyStroke('c'),
18 | "c",
19 | "CommentAction",
20 | "CommentByLineComment"
21 | )
22 | )
23 | )
24 | val singleBindingOverriden = LeaderBinding.SingleBinding(
25 | getKeyStroke('c'),
26 | "c",
27 | "DeleteAction",
28 | "DeleteLine"
29 | )
30 | val bindings = mergeBindings(
31 | bindingConfig = listOf(
32 | singleBindingOriginal
33 | ),
34 | overrideConfig = listOf(
35 | singleBindingOverriden
36 | )
37 | )
38 | assertThat(
39 | bindings, Matchers.containsInAnyOrder(
40 | singleBindingOverriden
41 | )
42 | )
43 | }
44 |
45 | @Test
46 | fun `merges nested binding`() {
47 | val singleBindingOriginal = LeaderBinding.SingleBinding(
48 | getKeyStroke('c'),
49 | "c",
50 | "CommentAction",
51 | "CommentByLineComment"
52 | )
53 | val singleBindingOverriden = LeaderBinding.SingleBinding(
54 | getKeyStroke('d'),
55 | "d",
56 | "DeleteAction",
57 | "DeleteLine"
58 | )
59 | val bindings = mergeBindings(
60 | bindingConfig = listOf(
61 | LeaderBinding.GroupBinding(
62 | getKeyStroke('c'),
63 | "c",
64 | description = "Comment...",
65 | listOf(
66 | singleBindingOriginal
67 | )
68 | )
69 | ),
70 | overrideConfig = listOf(
71 | LeaderBinding.GroupBinding(
72 | getKeyStroke('c'),
73 | "c",
74 | description = "Deletion...",
75 | listOf(
76 | singleBindingOverriden
77 | )
78 | )
79 | )
80 | )
81 | assertThat(
82 | bindings, Matchers.containsInAnyOrder(
83 | LeaderBinding.GroupBinding(
84 | getKeyStroke('c'),
85 | "c",
86 | description = "Deletion...",
87 | listOf(
88 | singleBindingOriginal,
89 | singleBindingOverriden
90 | )
91 | )
92 | )
93 | )
94 | }
95 |
96 | @Test
97 | fun `overrides top level binding`() {
98 | val singleBindingOriginal = LeaderBinding.SingleBinding(
99 | getKeyStroke('c'),
100 | "c",
101 | "CommentAction",
102 | "CommentByLineComment"
103 | )
104 | val singleBindingOverriden = LeaderBinding.SingleBinding(
105 | getKeyStroke('c'),
106 | "c",
107 | "DeleteAction",
108 | "DeleteLine"
109 | )
110 | val bindings = mergeBindings(
111 | bindingConfig = listOf(
112 | singleBindingOriginal
113 | ),
114 | overrideConfig = listOf(
115 | singleBindingOverriden
116 | )
117 | )
118 | assertThat(
119 | bindings, Matchers.containsInAnyOrder(
120 | singleBindingOverriden
121 | )
122 | )
123 | }
124 |
125 | @Test
126 | fun `merges top level bindings`() {
127 | val singleBindingOriginal = LeaderBinding.SingleBinding(
128 | getKeyStroke('c'),
129 | "c",
130 | "CommentAction",
131 | "CommentByLineComment"
132 | )
133 | val singleBindingOverriden = LeaderBinding.SingleBinding(
134 | getKeyStroke('d'),
135 | "d",
136 | "DeleteAction",
137 | "DeleteLine"
138 | )
139 | val bindings = mergeBindings(
140 | bindingConfig = listOf(
141 | singleBindingOriginal
142 | ),
143 | overrideConfig = listOf(
144 | singleBindingOverriden
145 | )
146 | )
147 | assertThat(
148 | bindings, Matchers.containsInAnyOrder(
149 | singleBindingOriginal,
150 | singleBindingOverriden
151 | )
152 | )
153 | }
154 | }
155 |
--------------------------------------------------------------------------------
/src/main/resources/META-INF/pluginIcon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
6 |
7 |
10 |
11 |
12 | Untitled 5
13 | Drawing exported from Concepts: Smarter Sketching
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
81 |
82 |
83 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | # Ataman
4 |
5 | [](https://plugins.jetbrains.com/plugin/17567-ataman)
6 | [](https://plugins.jetbrains.com/plugin/17567-ataman)
7 | [](https://github.com/mishkun/ataman-intellij/actions)
8 |
9 |
10 | > Ataman - an elected leader of the Cossack troops and settlements
11 |
12 | Ataman is an Intellij Idea plugin for using leader key for bindings (almost like in Spacemacs). Great way to enchance
13 | your
14 | IdeaVim productivity or just to have a more convenient way to access actions, as it is not required.
15 |
16 | ## Rationale
17 |
18 | IntelliJ IDEA is notorious for its tricky keybindings involving multiple modifiers and F1-F12 keys. Another approach of
19 | using `Cmd+Shift+A` command pallete and search for most of the actions, reducing the speed.
20 |
21 | There is another way, popularized by Spacemacs and Doom Emacs – leader (or sticky) keys. It works fairly simple – you
22 | choose a combination to use as a leader, e.g. `Ctrl-E`. After you activate leader, next keys can be simply typed one
23 | after another. For example, we can have `Ctrl-E c r` for opening refactoring menu and `Ctrl-E c f` to reformat file.
24 | With this approach keybindings are easier to type and memorize.
25 |
26 | This approach could already be done in IntelliJ using IdeaVim and
27 | some [tricks](https://ztlevi.github.io/posts/The-Minimal-Spacemacs-Tweaks-for-Jetbrain-IDES/). Ataman is independent of
28 | your choice to use IdeaVim and works everywhere across Intellij
29 |
30 | ## Easy setup
31 |
32 | Install plugin from Jetbrains Marketplace (or build it yourself as shown below). In your keymap
33 | settings (`Preferences -> Keymap`)
34 | find and bind `Ataman: Leader Key` to the shortcut of your choice. When executed first time, the only binding is to open
35 | your config. Enjoy!
36 |
37 | ## Advanced setup for IdeaVim users
38 |
39 | To use leader key without modifier (e.g. to use SPACE as leader), bind your desired leader key to
40 | the `Ataman: Transparent Leader Key` action and add these lines
41 |
42 | ```
43 | nnoremap :action LeaderAction
44 | vnoremap :action LeaderAction
45 | ```
46 |
47 | to your `~/.ideavimrc` file. This way leader key will work unless you are entering text anywhere
48 |
49 | ## Other actions
50 |
51 | - `Ataman: Open or Create ~/atamanrc.config` - opens your config file in the editor (creates if it does not exist yet)
52 | - `Ataman: Reload ~/.atamanrc.config File` - reloads your config file. Call this action after editing your config
53 | - `Ataman: Repeat Latest Command` - repeats the last command executed by Ataman
54 |
55 | ## Config structure
56 |
57 | Your mappings config is located at `~/.atamanrc.config`. File is
58 | in [HOCON](https://github.com/lightbend/config/blob/master/HOCON.md) format. Suggested style is here:
59 |
60 | ```hocon
61 | bindings { # always present
62 | c { # tree of bindings starting with 'c'
63 | description: Code...
64 | bindings {
65 | # some leaves with actions to call
66 | r { actionId: RefactoringMenu, description: Refactor this... }
67 | c { # you can nest arbitrary amount of tree groups
68 | description: Compile/Run...
69 | bindings {
70 | a { actionId: RunAnything, description: Run Anything... }
71 | r { actionId: ReRun, description: Rerun last build }
72 | }
73 | # actionId: ... -- error! do not mix actionId and bindings clause together!
74 | }
75 | # You can use F keys as well
76 | F12 {actionId: CloseProject, description: Close Project}
77 |
78 | # You can also do multiple actions in a row
79 | f { actionId: [ReformatCode, OptimizeImports], description: Reformat and Optimize Imports }
80 | }
81 | }
82 | }
83 |
84 | # You can also have ide-specific bindings
85 | # For example, to have different bindings for PyCharm, use its product code
86 | # To find out product code, look at the about screen build info section
87 | # Build #-242.23339.11
88 | PY {
89 | # This binding will override defaults for PyCharm
90 | c {
91 | description: Closing...
92 | bindings {
93 | r { actionId: CloseProject, description: Close Project }
94 | }
95 | }
96 | # This binding will be added to the list for PyCharm
97 | F12 { actionId: CloseProject, description: Close Project }
98 | }
99 | ```
100 |
101 | You can look at my own config [here](https://gist.github.com/Mishkun/b3fa501f82a5ad1205adf87c89c70031) for more examples
102 |
103 | ### Finding actionId
104 |
105 | To find actionId of the action you want to bind, you can use IdeaVim's action "IdeaVim: Track Action IDs"
106 |
107 | ## Building from source
108 |
109 | This repo uses [gradle-intellij-plugin](https://github.com/JetBrains/gradle-intellij-plugin/) for building.
110 | To build plugin, use this command:
111 |
112 | ```
113 | ./gradlew buildPlugin
114 | ```
115 |
116 | For running tests:
117 |
118 | ```
119 | ./gradlew test # Unit tests
120 | ./gradlew uiTests # UI tests
121 | ```
122 |
123 | For more advanced usecases, please refer to gradle-intellij-plugin documentation.
124 |
125 | ## License
126 |
127 | This project is distributed under MIT License. Please refer to [LICENSE.txt](LICENSE.txt) for details.
128 |
--------------------------------------------------------------------------------
/src/main/kotlin/io/github/mishkun/ataman/LeaderPopupUI.kt:
--------------------------------------------------------------------------------
1 | package io.github.mishkun.ataman
2 |
3 | import com.intellij.icons.AllIcons
4 | import com.intellij.openapi.actionSystem.DataContext
5 | import com.intellij.openapi.components.service
6 | import com.intellij.openapi.project.Project
7 | import com.intellij.openapi.ui.popup.PopupStep
8 | import com.intellij.openapi.ui.popup.util.BaseListPopupStep
9 | import com.intellij.ui.popup.WizardPopup
10 | import com.intellij.ui.popup.list.ListPopupImpl
11 | import com.intellij.util.ui.EmptyIcon
12 | import com.intellij.util.ui.GridBag
13 | import com.intellij.util.ui.JBUI
14 | import com.intellij.util.ui.UIUtil
15 | import java.awt.Font
16 | import java.awt.GridBagLayout
17 | import java.awt.event.KeyEvent
18 | import javax.swing.JComponent
19 | import javax.swing.JLabel
20 | import javax.swing.JList
21 | import javax.swing.JPanel
22 | import javax.swing.ListCellRenderer
23 |
24 | /**
25 | * Display-only popup for showing leader bindings.
26 | * All key handling is done by LeaderKeyDispatcher - this popup only renders the UI.
27 | */
28 | class LeaderPopupUI(
29 | project: Project? = null,
30 | step: LeaderListStep,
31 | parent: WizardPopup? = null,
32 | parentObject: Any? = null
33 | ) : ListPopupImpl(project, parent, step, parentObject), LeaderStateListener {
34 |
35 | private val dispatcher = service()
36 |
37 | init {
38 | dispatcher.addListener(this)
39 | }
40 |
41 | override fun onBindingSelected(binding: LeaderBinding.SingleBinding) {
42 | // Visual feedback - highlight the selected binding
43 | list.setSelectedValue(binding, true)
44 | }
45 |
46 | override fun onGroupEntered(group: LeaderBinding.GroupBinding) {
47 | // Navigate to sub-group using ListPopupImpl's built-in mechanism
48 | list.setSelectedValue(group, true)
49 | handleSelect(true)
50 | }
51 |
52 | override fun onDismiss() {
53 | cancel()
54 | }
55 |
56 | override fun dispose() {
57 | dispatcher.removeListener(this)
58 | super.dispose()
59 | }
60 |
61 | // Consume all key events - dispatcher handles them
62 | override fun process(aEvent: KeyEvent) {
63 | aEvent.consume()
64 | }
65 |
66 | override fun getListElementRenderer(): ListCellRenderer<*> = ActionItemRenderer()
67 |
68 | override fun createPopup(parent: WizardPopup?, step: PopupStep<*>?, parentValue: Any?): LeaderPopupUI {
69 | return LeaderPopupUI(
70 | parent?.project,
71 | step as LeaderListStep,
72 | parent,
73 | parentValue
74 | )
75 | }
76 | }
77 |
78 | /**
79 | * Step for the leader popup list.
80 | */
81 | class LeaderListStep(
82 | title: String? = null,
83 | val dataContext: DataContext,
84 | values: List
85 | ) : BaseListPopupStep(title, values) {
86 |
87 | override fun hasSubstep(selectedValue: LeaderBinding?): Boolean {
88 | return selectedValue is LeaderBinding.GroupBinding
89 | }
90 |
91 | override fun onChosen(selectedValue: LeaderBinding?, finalChoice: Boolean): PopupStep<*>? =
92 | when (selectedValue) {
93 | is LeaderBinding.SingleBinding -> {
94 | doFinalStep {
95 | selectedValue.action.forEach { action ->
96 | executeAction(action, dataContext)
97 | }
98 | }
99 | }
100 | is LeaderBinding.GroupBinding -> {
101 | LeaderListStep(null, dataContext, selectedValue.bindings)
102 | }
103 | null -> null
104 | }
105 |
106 | override fun getBackgroundFor(value: LeaderBinding?) = UIUtil.getPanelBackground()
107 | }
108 |
109 | /**
110 | * Renderer for leader binding items in the popup list.
111 | */
112 | class ActionItemRenderer : JPanel(GridBagLayout()), ListCellRenderer {
113 | private val leftInset = 12
114 | private val innerInset = 8
115 | private val emptyMenuRightArrowIcon = EmptyIcon.create(AllIcons.General.ArrowRight)
116 |
117 | private val listSelectionBackground = UIUtil.getListSelectionBackground(true)
118 |
119 | private val actionTextLabel = JLabel()
120 | private val bindingKeyLabel = JLabel()
121 |
122 | init {
123 | val topBottom = 3
124 | val insets = JBUI.insets(topBottom, leftInset, topBottom, 0)
125 |
126 | var gbc = GridBag().setDefaultAnchor(GridBag.WEST)
127 | gbc = gbc.nextLine().next().insets(insets)
128 | add(bindingKeyLabel, gbc)
129 | gbc = gbc.next().insetLeft(innerInset)
130 | add(actionTextLabel, gbc)
131 | gbc = gbc.next().fillCellHorizontally().weightx(1.0).anchor(GridBag.EAST)
132 | .insets(JBUI.insets(topBottom, leftInset, topBottom, innerInset))
133 | add(JLabel(emptyMenuRightArrowIcon), gbc)
134 | }
135 |
136 | override fun getListCellRendererComponent(
137 | list: JList?,
138 | value: LeaderBinding,
139 | index: Int,
140 | selected: Boolean,
141 | focused: Boolean
142 | ): JComponent {
143 | bindingKeyLabel.text = value.char
144 | bindingKeyLabel.font = UIUtil.getFontWithFallback("monospaced", Font.PLAIN, 12)
145 | bindingKeyLabel.foreground = UIUtil.getListForeground(selected, false)
146 | actionTextLabel.text = value.description
147 | actionTextLabel.foreground = UIUtil.getListForeground(selected, value is LeaderBinding.GroupBinding)
148 | UIUtil.setBackgroundRecursively(
149 | this,
150 | if (selected) listSelectionBackground else UIUtil.getPanelBackground()
151 | )
152 | return this
153 | }
154 | }
155 |
--------------------------------------------------------------------------------
/src/uiTest/kotlin/io/github/mishkun/ataman/AtamanActionTest.kt:
--------------------------------------------------------------------------------
1 | package io.github.mishkun.ataman
2 |
3 | import com.intellij.driver.client.Driver
4 | import com.intellij.driver.sdk.invokeAction
5 | import com.intellij.driver.sdk.ui.Finder
6 | import com.intellij.driver.sdk.ui.components.UiComponent
7 | import com.intellij.driver.sdk.ui.components.common.WelcomeScreenUI
8 | import com.intellij.driver.sdk.ui.components.common.ideFrame
9 | import com.intellij.driver.sdk.ui.components.common.toolwindows.projectView
10 | import com.intellij.driver.sdk.ui.components.common.vcsToolWindow
11 | import com.intellij.driver.sdk.ui.components.common.welcomeScreen
12 | import com.intellij.driver.sdk.ui.ui
13 | import com.intellij.driver.sdk.waitForProjectOpen
14 | import com.intellij.ide.starter.driver.engine.BackgroundRun
15 | import com.intellij.ide.starter.driver.engine.runIdeWithDriver
16 | import com.intellij.ide.starter.ide.IDETestContext
17 | import com.intellij.ide.starter.runner.IDECommandLine
18 | import com.intellij.ide.starter.runner.Starter
19 | import com.intellij.openapi.util.io.toNioPathOrNull
20 | import com.intellij.tools.ide.performanceTesting.commands.CommandChain
21 | import com.intellij.tools.ide.performanceTesting.commands.waitForDumbMode
22 | import org.hamcrest.MatcherAssert.assertThat
23 | import org.hamcrest.Matchers.equalTo
24 | import org.hamcrest.Matchers.notNullValue
25 | import org.junit.Test
26 | import java.awt.event.KeyEvent
27 |
28 | class AtamanActionTest {
29 |
30 | @Test
31 | fun `activates ide specific binding`() {
32 | val testContext = Starter
33 | .newContext("test_plugin_action", UltimateCase.simpleProject)
34 | .prepareProjectCleanImport()
35 | .disableAutoImport(disabled = true)
36 | testContext.pluginConfigurator.installPluginFromPath(System.getenv("ATAMAN_PLUGIN_PATH").toNioPathOrNull()!!)
37 | testContext.runIdeWithConfiguredDriver().useDriverAndCloseIde {
38 | this.invokeAction("LeaderAction")
39 | ideFrame {
40 | assertThat(atamanPopup().isVisible(), equalTo(true))
41 | }
42 | this.ui.robot.pressAndReleaseKey(KeyEvent.VK_Q)
43 | ideFrame {
44 | vcsToolWindow {
45 | assertThat(isVisible(), equalTo(true))
46 | }
47 | }
48 | }
49 | }
50 |
51 | @Test
52 | fun `should not activate transparent leader action if speed search is active`() {
53 | val testContext = Starter
54 | .newContext("transparent_action", MyTestCase.simpleProject)
55 | .prepareProjectCleanImport()
56 | .disableAutoImport(disabled = true)
57 | testContext.pluginConfigurator.installPluginFromPath(System.getenv("ATAMAN_PLUGIN_PATH").toNioPathOrNull()!!)
58 | testContext.runIdeWithConfiguredDriver().useDriverAndCloseIde {
59 | waitForProjectOpen()
60 | // trigger speed search in project view
61 | ideFrame {
62 | ui.robot.focus(this.projectView { }.component)
63 | ui.robot.type('p')
64 | }
65 | // trigger action. This time it should not display the popup because speed search is active
66 | runCatching { this.invokeAction("TransparentLeaderAction") }
67 | ideFrame {
68 | val popup = runCatching { atamanPopup().component }
69 | assertThat(popup, notNullValue())
70 | }
71 | // close speed search
72 | ui.robot.pressKey(KeyEvent.VK_ESCAPE)
73 |
74 | // trigger action again. This time it should display the popup
75 | this.invokeAction("TransparentLeaderAction")
76 | ideFrame {
77 | assertThat(atamanPopup().isVisible(), equalTo(true))
78 | }
79 | }
80 | }
81 |
82 | @Test
83 | fun test_plugin_main_action() {
84 | val testContext = Starter
85 | .newContext("test_plugin_action", MyTestCase.simpleProject)
86 | .prepareProjectCleanImport()
87 | .disableAutoImport(disabled = true)
88 | testContext.pluginConfigurator.installPluginFromPath(System.getenv("ATAMAN_PLUGIN_PATH").toNioPathOrNull()!!)
89 | CommandChain().waitForDumbMode(10)
90 | testContext.runIdeWithConfiguredDriver().useDriverAndCloseIde {
91 | waitForProjectOpen()
92 | this.invokeAction("LeaderAction")
93 | ideFrame {
94 | assertThat(atamanPopup().isVisible(), equalTo(true))
95 | }
96 | exitProjectViaAtamanAndCheckSuccess()
97 | }
98 | }
99 |
100 | private fun IDETestContext.runIdeWithConfiguredDriver(): BackgroundRun {
101 | val commands = CommandChain().waitForDumbMode(10)
102 |
103 | return this.runIdeWithDriver(
104 | commands = commands,
105 | commandLine = {
106 | IDECommandLine.OpenTestCaseProject(
107 | this,
108 | listOf("-Dataman.configFolder=${System.getProperty("ataman.configFolder")}")
109 | )
110 | }
111 | )
112 | }
113 |
114 | private fun Finder.atamanPopup(): UiComponent = xx(
115 | "//div[@text='Ataman']"
116 | ).list().first()
117 |
118 | private fun Driver.exitProjectViaAtamanAndCheckSuccess(): WelcomeScreenUI {
119 | this.ui.robot.waitForIdle()
120 | this.ui.robot.pressAndReleaseKey(KeyEvent.VK_Q)
121 | this.ui.robot.waitForIdle()
122 | takeScreenshot("test_plugin_main_action")
123 | this.ui.robot.pressAndReleaseKey(KeyEvent.VK_F1)
124 | this.ui.robot.waitForIdle()
125 | return this.welcomeScreen {
126 | this.isVisible()
127 | }
128 | }
129 | }
130 |
--------------------------------------------------------------------------------
/src/main/kotlin/io/github/mishkun/ataman/LeaderKeyDispatcher.kt:
--------------------------------------------------------------------------------
1 | package io.github.mishkun.ataman
2 |
3 | import com.intellij.openapi.actionSystem.DataContext
4 | import com.intellij.openapi.components.Service
5 | import com.intellij.openapi.diagnostic.Logger
6 | import java.awt.KeyEventDispatcher
7 | import java.awt.KeyboardFocusManager
8 | import java.awt.event.KeyEvent
9 | import javax.swing.KeyStroke
10 |
11 | private val LOG = Logger.getInstance(LeaderKeyDispatcher::class.java)
12 |
13 | /**
14 | * Listener interface for LeaderKeyDispatcher state changes.
15 | */
16 | interface LeaderStateListener {
17 | /** Called when a SingleBinding is matched and should be executed. */
18 | fun onBindingSelected(binding: LeaderBinding.SingleBinding)
19 |
20 | /** Called when entering a group (for nested popup creation). */
21 | fun onGroupEntered(group: LeaderBinding.GroupBinding)
22 |
23 | /** Called when the dispatcher is dismissed (escape or action complete). */
24 | fun onDismiss()
25 | }
26 |
27 | /**
28 | * State machine that captures all key events and matches against bindings.
29 | * This is the single source of truth for key handling in the leader popup flow.
30 | */
31 | @Service(Service.Level.APP)
32 | class LeaderKeyDispatcher {
33 | private var isCapturing = false
34 | private var keyEventDispatcher: KeyEventDispatcher? = null
35 | private val listeners = mutableListOf()
36 |
37 | private var currentBindings: List = emptyList()
38 | private var currentBindingsMap: Map = emptyMap()
39 | private var currentCharMap: Map = emptyMap()
40 | private var dataContext: DataContext? = null
41 |
42 | /**
43 | * Start capturing key events and matching against the provided bindings.
44 | * Call this immediately when LeaderAction is invoked, before creating the popup.
45 | */
46 | fun start(bindings: List, dataContext: DataContext) {
47 | if (isCapturing) {
48 | // Already capturing - stop first
49 | stop()
50 | }
51 |
52 | isCapturing = true
53 | currentBindings = bindings
54 | currentBindingsMap = bindings.toKeyStrokeMap()
55 | currentCharMap = bindings.toCharMap()
56 | this.dataContext = dataContext
57 |
58 | LOG.warn("LeaderKeyDispatcher started with ${bindings.size} bindings, charMap keys: ${currentCharMap.keys}")
59 |
60 | keyEventDispatcher = KeyEventDispatcher { event ->
61 | if (!isCapturing) return@KeyEventDispatcher false
62 | handleKeyEvent(event)
63 | }
64 |
65 | KeyboardFocusManager.getCurrentKeyboardFocusManager()
66 | .addKeyEventDispatcher(keyEventDispatcher)
67 |
68 | LOG.warn("KeyEventDispatcher registered")
69 | }
70 |
71 | /**
72 | * Stop capturing key events and clean up.
73 | */
74 | fun stop() {
75 | isCapturing = false
76 | keyEventDispatcher?.let {
77 | KeyboardFocusManager.getCurrentKeyboardFocusManager()
78 | .removeKeyEventDispatcher(it)
79 | }
80 | keyEventDispatcher = null
81 | currentBindings = emptyList()
82 | currentBindingsMap = emptyMap()
83 | currentCharMap = emptyMap()
84 | dataContext = null
85 | }
86 |
87 | /**
88 | * Update bindings for nested group navigation.
89 | * Called when entering a sub-group to update what keys we're matching against.
90 | */
91 | fun updateBindings(bindings: List) {
92 | currentBindings = bindings
93 | currentBindingsMap = bindings.toKeyStrokeMap()
94 | currentCharMap = bindings.toCharMap()
95 | }
96 |
97 | fun addListener(listener: LeaderStateListener) {
98 | listeners.add(listener)
99 | }
100 |
101 | fun removeListener(listener: LeaderStateListener) {
102 | listeners.remove(listener)
103 | }
104 |
105 | private fun handleKeyEvent(event: KeyEvent): Boolean {
106 | LOG.warn("LeaderKeyDispatcher received event: id=${event.id}, keyCode=${event.keyCode}, keyChar='${event.keyChar}', isCapturing=$isCapturing")
107 |
108 | when (event.id) {
109 | KeyEvent.KEY_PRESSED -> {
110 | LOG.warn("KEY_PRESSED: keyCode=${event.keyCode}")
111 | when (event.keyCode) {
112 | KeyEvent.VK_ESCAPE -> {
113 | LOG.warn("Escape pressed, dismissing")
114 | notifyDismiss()
115 | return true
116 | }
117 | // Skip modifier-only key presses
118 | KeyEvent.VK_SHIFT, KeyEvent.VK_CONTROL, KeyEvent.VK_ALT, KeyEvent.VK_META -> {
119 | return false
120 | }
121 | }
122 | // Consume KEY_PRESSED to prevent other handlers, but wait for KEY_RELEASED to act
123 | return true
124 | }
125 | KeyEvent.KEY_RELEASED -> {
126 | LOG.warn("KEY_RELEASED: keyCode=${event.keyCode}, keyChar='${event.keyChar}'")
127 | // Skip modifier-only key releases
128 | when (event.keyCode) {
129 | KeyEvent.VK_SHIFT, KeyEvent.VK_CONTROL, KeyEvent.VK_ALT, KeyEvent.VK_META, KeyEvent.VK_ESCAPE -> {
130 | return false
131 | }
132 | }
133 | val binding = findBindingForEvent(event)
134 | LOG.warn("Found binding: $binding for char '${event.keyChar}', charMap keys: ${currentCharMap.keys}")
135 | if (binding != null) {
136 | processBinding(binding)
137 | return true
138 | }
139 | // No match - consume anyway to prevent other handlers
140 | return true
141 | }
142 | }
143 | return false
144 | }
145 |
146 | private fun findBindingForEvent(event: KeyEvent): LeaderBinding? {
147 | // Match by character directly
148 | currentCharMap[event.keyChar]?.let { return it }
149 |
150 | // Also try matching by KeyStroke
151 | val keyStroke = event.toKeyStroke()
152 | if (keyStroke != null) {
153 | currentBindingsMap[keyStroke]?.let { return it }
154 | }
155 |
156 | return null
157 | }
158 |
159 | private fun processBinding(binding: LeaderBinding) {
160 | when (binding) {
161 | is LeaderBinding.SingleBinding -> {
162 | val ctx = dataContext
163 | if (ctx != null) {
164 | binding.action.forEach { actionId ->
165 | executeAction(actionId, ctx)
166 | }
167 | }
168 | listeners.toList().forEach { it.onBindingSelected(binding) }
169 | notifyDismiss()
170 | }
171 | is LeaderBinding.GroupBinding -> {
172 | updateBindings(binding.bindings)
173 | listeners.toList().forEach { it.onGroupEntered(binding) }
174 | }
175 | }
176 | }
177 |
178 | private fun notifyDismiss() {
179 | listeners.toList().forEach { it.onDismiss() }
180 | }
181 | }
182 |
183 | /**
184 | * Utility to convert a KeyEvent to a KeyStroke for matching against bindings.
185 | */
186 | fun KeyEvent.toKeyStroke(): KeyStroke? {
187 | return when (id) {
188 | KeyEvent.KEY_TYPED -> {
189 | if (keyChar != KeyEvent.CHAR_UNDEFINED) {
190 | KeyStroke.getKeyStroke(keyChar)
191 | } else null
192 | }
193 | KeyEvent.KEY_PRESSED -> {
194 | KeyStroke.getKeyStroke(keyCode, modifiersEx, false)
195 | }
196 | KeyEvent.KEY_RELEASED -> {
197 | KeyStroke.getKeyStroke(keyCode, modifiersEx, true)
198 | }
199 | else -> null
200 | }
201 | }
202 |
--------------------------------------------------------------------------------
/src/main/kotlin/io/github/mishkun/ataman/AtamanConfig.kt:
--------------------------------------------------------------------------------
1 | package io.github.mishkun.ataman
2 |
3 | import com.intellij.notification.NotificationGroupManager
4 | import com.intellij.notification.NotificationType
5 | import com.intellij.openapi.components.service
6 | import com.intellij.openapi.project.Project
7 | import com.intellij.openapi.ui.playback.commands.KeyStrokeMap
8 | import com.typesafe.config.ConfigFactory
9 | import java.awt.event.KeyEvent
10 | import java.io.File
11 | import java.io.IOException
12 | import javax.swing.KeyStroke
13 |
14 | const val ATAMAN_RC_FILENAME = ".atamanrc.config"
15 | private const val COMMON_BINDINGS_KEY = "bindings"
16 |
17 | val RC_TEMPLATE = """
18 | # This file is written in HOCON (Human-Optimized Config Object Notation) format.
19 | # For more information about HOCON see https://github.com/lightbend/config/blob/master/HOCON.md
20 |
21 | bindings {
22 | q {
23 | description: Session...
24 | bindings {
25 | f { actionId: OpenAtamanConfigAction, description: Open ~/.atamanrc.config }
26 | }
27 | },
28 | }
29 | """.trimIndent()
30 |
31 | fun updateConfig(project: Project, configDir: File, ideProductKey: String) {
32 | parseConfig(configDir, ideProductKey).fold(
33 | onSuccess = { values ->
34 | service().parsedBindings = values
35 | },
36 | onFailure = { error ->
37 | when (error) {
38 | is IllegalStateException -> project.showNotification(
39 | "Bindings schema is invalid. Aborting...\n${error.message}",
40 | NotificationType.ERROR
41 | )
42 |
43 | is IOException -> project.showNotification(
44 | "Config file is not found and I could not create it. Aborting...\n${error.message}",
45 | NotificationType.ERROR
46 | )
47 |
48 | else -> project.showNotification(
49 | "Config is malformed. Aborting...\n${error.message}",
50 | NotificationType.ERROR
51 | )
52 | }
53 | })
54 | }
55 |
56 | private fun Project.showNotification(
57 | message: String,
58 | notificationType: NotificationType = NotificationType.INFORMATION
59 | ) {
60 | NotificationGroupManager.getInstance()
61 | .getNotificationGroup("io.github.mishkun.ataman")
62 | .createNotification(
63 | "Ataman",
64 | message,
65 | notificationType
66 | )
67 | .notify(this)
68 | }
69 |
70 | fun mergeBindings(bindingConfig: List, overrideConfig: List): List {
71 | val commonBindingsMap = bindingConfig.associateBy { it.char }
72 | val productBindingsMap = overrideConfig.associateBy { it.char }
73 | val commonKeys = commonBindingsMap.keys.toSet().intersect(productBindingsMap.keys)
74 | val mergedBindings = commonKeys.associateWith { key ->
75 | val commonBinding = commonBindingsMap.getValue(key)
76 | val productBinding = productBindingsMap.getValue(key)
77 | when {
78 | commonBinding is LeaderBinding.GroupBinding && productBinding is LeaderBinding.GroupBinding -> {
79 | productBinding.copy(
80 | bindings = mergeBindings(commonBinding.bindings, productBinding.bindings)
81 | )
82 | }
83 |
84 | else -> productBinding
85 | }
86 | }
87 | return (commonBindingsMap + productBindingsMap + mergedBindings).values.toList()
88 | }
89 |
90 | fun parseConfig(configDir: File, ideProductKey: String): Result> {
91 | return runCatching {
92 | val rcFile = findOrCreateRcFile(configDir)
93 | val config = execFile(rcFile)
94 | val (commonBindingsParsed, commonThrowables) = buildBindingsTree(config, COMMON_BINDINGS_KEY)
95 | val (productBindingsParsed, productThrowables) = buildBindingsTree(config, ideProductKey)
96 | val bindings = mergeBindings(commonBindingsParsed, productBindingsParsed)
97 | val throwables = commonThrowables + productThrowables
98 | if (throwables.isNotEmpty()) {
99 | throw IllegalStateException(throwables.joinToString("\n"))
100 | } else {
101 | bindings
102 | }
103 | }
104 | }
105 | private val keyStrokeMap = KeyStrokeMap()
106 |
107 | // Extension function to get a keystroke from the map with the isLeafBinding parameter
108 | private fun KeyStrokeMap.get(char: Char, isLeafBinding: Boolean): KeyStroke {
109 | val keyStroke = get(char)
110 | // If we need to override the release value
111 | return KeyStroke.getKeyStroke(
112 | keyStroke.keyCode,
113 | keyStroke.modifiers,
114 | isLeafBinding // Use key release only for leaf bindings
115 | )
116 | }
117 |
118 | fun getKeyStroke(key: String, isLeafBinding: Boolean = false): KeyStroke {
119 | val trimmedKey = key.trim('"')
120 | return when (trimmedKey.length) {
121 | 1 -> keyStrokeMap.get(trimmedKey[0], isLeafBinding)
122 | else -> getFKeyStroke(key, isLeafBinding)
123 | }
124 | }
125 |
126 | fun getFKeyStroke(key: String, isLeafBinding: Boolean = false): KeyStroke = KeyStroke.getKeyStroke(
127 | key.substringAfter("F").toInt() + 111,
128 | 0,
129 | isLeafBinding // Only leaf bindings use key release
130 | )
131 |
132 | fun getKeyStroke(char: Char, isLeafBinding: Boolean = false): KeyStroke = KeyStroke.getKeyStroke(
133 | KeyEvent.getExtendedKeyCodeForChar(char.code),
134 | if (char.isUpperCase()) KeyEvent.SHIFT_DOWN_MASK else 0,
135 | isLeafBinding // Only leaf bindings use key release
136 | )
137 |
138 | private const val BINDINGS_KEYWORD = COMMON_BINDINGS_KEY
139 | private const val DESCRIPTION_KEYWORD = "description"
140 | private const val ACTION_ID_KEYWORD = "actionId"
141 |
142 | @Suppress("UNCHECKED_CAST")
143 | private fun execFile(file: File): Map>> =
144 | (ConfigFactory.parseFile(file).root().unwrapped() as Map>).mapValues { it.value.toList() }
145 |
146 | private fun buildBindingsTree(
147 | rootBindingConfig: Map>>,
148 | key: String
149 | ): Pair, List> {
150 | val commonBindings = rootBindingConfig[key] ?: emptyList()
151 | return buildBindingsTree(commonBindings)
152 | }
153 |
154 | @Suppress("UNCHECKED_CAST")
155 | private fun buildBindingsTree(bindingConfig: List>): Pair, List> {
156 | val errors = mutableListOf()
157 | val bindings = bindingConfig.mapNotNull { (keyword, bodyObject) ->
158 | val body = bodyObject as Map
159 | val description = bodyObject[DESCRIPTION_KEYWORD] as String
160 | when {
161 | body.containsKey(ACTION_ID_KEYWORD) -> {
162 | // This is a leaf binding, should use key release
163 | when (val actionId = body[ACTION_ID_KEYWORD]) {
164 | is String -> LeaderBinding.SingleBinding(
165 | getKeyStroke(keyword, isLeafBinding = true),
166 | keyword,
167 | description,
168 | actionId
169 | )
170 | is List<*> -> LeaderBinding.SingleBinding(
171 | getKeyStroke(keyword, isLeafBinding = true),
172 | keyword,
173 | description,
174 | actionId as List
175 | )
176 |
177 | else -> {
178 | errors.add("Expected either String or List for $ACTION_ID_KEYWORD but got $actionId")
179 | null
180 | }
181 | }
182 | }
183 |
184 | body.containsKey(BINDINGS_KEYWORD) -> {
185 | // This is a group binding, should use key press
186 | val childBindingsObject = body[BINDINGS_KEYWORD] as Map
187 | val childBindings = buildBindingsTree(childBindingsObject.toList()).first
188 | LeaderBinding.GroupBinding(
189 | getKeyStroke(keyword, isLeafBinding = false),
190 | keyword,
191 | description,
192 | childBindings
193 | )
194 | }
195 |
196 | else -> {
197 | errors.add("Expected either $ACTION_ID_KEYWORD or $BINDINGS_KEYWORD for $keyword but got $bodyObject")
198 | null
199 | }
200 | }
201 | }.sortedByDescending { it.char }.sortedBy { it.char.lowercase() }
202 | return bindings to errors
203 | }
204 |
205 | fun findOrCreateRcFile(homeDir: File): File {
206 | val file = File(homeDir, ATAMAN_RC_FILENAME)
207 | if (file.exists()) {
208 | return file
209 | } else {
210 | file.createNewFile()
211 | file.writeText(RC_TEMPLATE)
212 | return file
213 | }
214 | }
215 |
--------------------------------------------------------------------------------
/src/test/kotlin/io/github/mishkun/ataman/LeaderKeyDispatcherTest.kt:
--------------------------------------------------------------------------------
1 | package io.github.mishkun.ataman
2 |
3 | import org.junit.Assert.assertEquals
4 | import org.junit.Assert.assertFalse
5 | import org.junit.Assert.assertNull
6 | import org.junit.Assert.assertTrue
7 | import org.junit.Before
8 | import org.junit.Test
9 | import java.awt.Component
10 | import java.awt.event.KeyEvent
11 | import javax.swing.JPanel
12 | import javax.swing.KeyStroke
13 |
14 | class LeaderKeyDispatcherTest {
15 |
16 | private lateinit var dispatcher: LeaderKeyDispatcher
17 |
18 | @Before
19 | fun setUp() {
20 | dispatcher = LeaderKeyDispatcher()
21 | }
22 |
23 | @Test
24 | fun `start can be called multiple times safely`() {
25 | // This would require a DataContext mock, so we just verify no exception
26 | // In real usage, start() stops any existing capture first
27 | }
28 |
29 | @Test
30 | fun `stop can be called without start`() {
31 | dispatcher.stop() // Should not throw
32 | }
33 |
34 | @Test
35 | fun `stop can be called multiple times safely`() {
36 | dispatcher.stop()
37 | dispatcher.stop() // Should not throw
38 | }
39 |
40 | @Test
41 | fun `addListener and removeListener work correctly`() {
42 | var dismissed = false
43 | val listener = object : LeaderStateListener {
44 | override fun onBindingSelected(binding: LeaderBinding.SingleBinding) {}
45 | override fun onGroupEntered(group: LeaderBinding.GroupBinding) {}
46 | override fun onDismiss() { dismissed = true }
47 | }
48 |
49 | dispatcher.addListener(listener)
50 | dispatcher.removeListener(listener)
51 | // After removal, listener should not be called
52 | // (can't easily test without starting dispatcher with mock DataContext)
53 | }
54 |
55 | @Test
56 | fun `removeListener is safe when listener not present`() {
57 | val listener = object : LeaderStateListener {
58 | override fun onBindingSelected(binding: LeaderBinding.SingleBinding) {}
59 | override fun onGroupEntered(group: LeaderBinding.GroupBinding) {}
60 | override fun onDismiss() {}
61 | }
62 |
63 | dispatcher.removeListener(listener) // Should not throw
64 | }
65 | }
66 |
67 | class KeyEventToKeyStrokeTest {
68 |
69 | private lateinit var dummyComponent: Component
70 |
71 | @Before
72 | fun setUp() {
73 | dummyComponent = JPanel()
74 | }
75 |
76 | @Test
77 | fun `toKeyStroke converts KEY_TYPED event to KeyStroke`() {
78 | val event = KeyEvent(
79 | dummyComponent,
80 | KeyEvent.KEY_TYPED,
81 | System.currentTimeMillis(),
82 | 0,
83 | KeyEvent.VK_UNDEFINED,
84 | 'a'
85 | )
86 | val keyStroke = event.toKeyStroke()
87 | assertEquals(KeyStroke.getKeyStroke('a'), keyStroke)
88 | }
89 |
90 | @Test
91 | fun `toKeyStroke converts KEY_PRESSED event to KeyStroke`() {
92 | val event = KeyEvent(
93 | dummyComponent,
94 | KeyEvent.KEY_PRESSED,
95 | System.currentTimeMillis(),
96 | 0,
97 | KeyEvent.VK_UP,
98 | KeyEvent.CHAR_UNDEFINED
99 | )
100 | val keyStroke = event.toKeyStroke()
101 | assertEquals(KeyStroke.getKeyStroke(KeyEvent.VK_UP, 0), keyStroke)
102 | }
103 |
104 | @Test
105 | fun `toKeyStroke converts KEY_RELEASED event to KeyStroke with onKeyRelease true`() {
106 | val event = KeyEvent(
107 | dummyComponent,
108 | KeyEvent.KEY_RELEASED,
109 | System.currentTimeMillis(),
110 | 0,
111 | KeyEvent.VK_A,
112 | 'a'
113 | )
114 | val keyStroke = event.toKeyStroke()
115 | assertEquals(KeyStroke.getKeyStroke(KeyEvent.VK_A, 0, true), keyStroke)
116 | }
117 |
118 | @Test
119 | fun `toKeyStroke handles navigation keys`() {
120 | val event = KeyEvent(
121 | dummyComponent,
122 | KeyEvent.KEY_PRESSED,
123 | System.currentTimeMillis(),
124 | 0,
125 | KeyEvent.VK_ESCAPE,
126 | KeyEvent.CHAR_UNDEFINED
127 | )
128 | val keyStroke = event.toKeyStroke()
129 | assertEquals(KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), keyStroke)
130 | }
131 |
132 | @Test
133 | fun `toKeyStroke preserves modifiers for KEY_PRESSED`() {
134 | val event = KeyEvent(
135 | dummyComponent,
136 | KeyEvent.KEY_PRESSED,
137 | System.currentTimeMillis(),
138 | KeyEvent.CTRL_DOWN_MASK,
139 | KeyEvent.VK_C,
140 | 'c'
141 | )
142 | val keyStroke = event.toKeyStroke()
143 | assertEquals(KeyStroke.getKeyStroke(KeyEvent.VK_C, KeyEvent.CTRL_DOWN_MASK), keyStroke)
144 | }
145 |
146 | @Test
147 | fun `toKeyStroke preserves modifiers for KEY_RELEASED`() {
148 | val event = KeyEvent(
149 | dummyComponent,
150 | KeyEvent.KEY_RELEASED,
151 | System.currentTimeMillis(),
152 | KeyEvent.SHIFT_DOWN_MASK,
153 | KeyEvent.VK_A,
154 | 'A'
155 | )
156 | val keyStroke = event.toKeyStroke()
157 | assertEquals(KeyStroke.getKeyStroke(KeyEvent.VK_A, KeyEvent.SHIFT_DOWN_MASK, true), keyStroke)
158 | }
159 |
160 | @Test
161 | fun `toKeyStroke returns null for unknown event type`() {
162 | // Use an arbitrary event ID that's not KEY_PRESSED, KEY_RELEASED, or KEY_TYPED
163 | val event = KeyEvent(
164 | dummyComponent,
165 | 999, // Unknown event type
166 | System.currentTimeMillis(),
167 | 0,
168 | KeyEvent.VK_A,
169 | 'a'
170 | )
171 | val keyStroke = event.toKeyStroke()
172 | assertNull(keyStroke)
173 | }
174 | }
175 |
176 | class LeaderBindingCharMapTest {
177 |
178 | @Test
179 | fun `toCharMap returns empty map for empty list`() {
180 | val bindings = emptyList()
181 | assertTrue(bindings.toCharMap().isEmpty())
182 | }
183 |
184 | @Test
185 | fun `toCharMap creates map keyed by char`() {
186 | val keyStrokeA = KeyStroke.getKeyStroke(KeyEvent.VK_A, 0, true)
187 | val keyStrokeB = KeyStroke.getKeyStroke(KeyEvent.VK_B, 0, true)
188 |
189 | val bindings = listOf(
190 | LeaderBinding.SingleBinding(keyStrokeA, "a", "Action A", "ActionA"),
191 | LeaderBinding.SingleBinding(keyStrokeB, "b", "Action B", "ActionB")
192 | )
193 |
194 | val map = bindings.toCharMap()
195 |
196 | assertEquals(2, map.size)
197 | assertEquals(bindings[0], map['a'])
198 | assertEquals(bindings[1], map['b'])
199 | }
200 |
201 | @Test
202 | fun `toCharMap handles uppercase chars`() {
203 | val keyStrokeA = KeyStroke.getKeyStroke(KeyEvent.VK_A, KeyEvent.SHIFT_DOWN_MASK, true)
204 |
205 | val bindings = listOf(
206 | LeaderBinding.SingleBinding(keyStrokeA, "A", "Action A", "ActionA")
207 | )
208 |
209 | val map = bindings.toCharMap()
210 |
211 | assertEquals(1, map.size)
212 | assertEquals(bindings[0], map['A'])
213 | }
214 |
215 | @Test
216 | fun `toCharMap skips bindings with multi-char keys`() {
217 | val keyStrokeF1 = KeyStroke.getKeyStroke(KeyEvent.VK_F1, 0, true)
218 |
219 | val bindings = listOf(
220 | LeaderBinding.SingleBinding(keyStrokeF1, "F1", "Action F1", "ActionF1")
221 | )
222 |
223 | val map = bindings.toCharMap()
224 |
225 | assertTrue(map.isEmpty())
226 | }
227 |
228 | @Test
229 | fun `toCharMap works with group bindings`() {
230 | val keyStrokeG = KeyStroke.getKeyStroke(KeyEvent.VK_G, 0, false)
231 | val keyStrokeA = KeyStroke.getKeyStroke(KeyEvent.VK_A, 0, true)
232 |
233 | val nestedBindings = listOf(
234 | LeaderBinding.SingleBinding(keyStrokeA, "a", "Nested Action", "NestedAction")
235 | )
236 | val groupBinding = LeaderBinding.GroupBinding(keyStrokeG, "g", "Group", nestedBindings)
237 |
238 | val map = listOf(groupBinding).toCharMap()
239 |
240 | assertEquals(1, map.size)
241 | assertEquals(groupBinding, map['g'])
242 | }
243 |
244 | @Test
245 | fun `toCharMap mixed single and multi-char bindings`() {
246 | val keyStrokeA = KeyStroke.getKeyStroke(KeyEvent.VK_A, 0, true)
247 | val keyStrokeF1 = KeyStroke.getKeyStroke(KeyEvent.VK_F1, 0, true)
248 | val keyStrokeB = KeyStroke.getKeyStroke(KeyEvent.VK_B, 0, true)
249 |
250 | val bindings = listOf(
251 | LeaderBinding.SingleBinding(keyStrokeA, "a", "Action A", "ActionA"),
252 | LeaderBinding.SingleBinding(keyStrokeF1, "F1", "Action F1", "ActionF1"),
253 | LeaderBinding.SingleBinding(keyStrokeB, "b", "Action B", "ActionB")
254 | )
255 |
256 | val map = bindings.toCharMap()
257 |
258 | assertEquals(2, map.size)
259 | assertEquals(bindings[0], map['a'])
260 | assertEquals(bindings[2], map['b'])
261 | assertFalse(map.containsKey('F'))
262 | }
263 | }
264 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | #
4 | # Copyright © 2015-2021 the original authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 |
19 | ##############################################################################
20 | #
21 | # Gradle start up script for POSIX generated by Gradle.
22 | #
23 | # Important for running:
24 | #
25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
26 | # noncompliant, but you have some other compliant shell such as ksh or
27 | # bash, then to run this script, type that shell name before the whole
28 | # command line, like:
29 | #
30 | # ksh Gradle
31 | #
32 | # Busybox and similar reduced shells will NOT work, because this script
33 | # requires all of these POSIX shell features:
34 | # * functions;
35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»;
37 | # * compound commands having a testable exit status, especially «case»;
38 | # * various built-in commands including «command», «set», and «ulimit».
39 | #
40 | # Important for patching:
41 | #
42 | # (2) This script targets any POSIX shell, so it avoids extensions provided
43 | # by Bash, Ksh, etc; in particular arrays are avoided.
44 | #
45 | # The "traditional" practice of packing multiple parameters into a
46 | # space-separated string is a well documented source of bugs and security
47 | # problems, so this is (mostly) avoided, by progressively accumulating
48 | # options in "$@", and eventually passing that to Java.
49 | #
50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
52 | # see the in-line comments for details.
53 | #
54 | # There are tweaks for specific operating systems such as AIX, CygWin,
55 | # Darwin, MinGW, and NonStop.
56 | #
57 | # (3) This script is generated from the Groovy template
58 | # https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
59 | # within the Gradle project.
60 | #
61 | # You can find Gradle at https://github.com/gradle/gradle/.
62 | #
63 | ##############################################################################
64 |
65 | # Attempt to set APP_HOME
66 |
67 | # Resolve links: $0 may be a link
68 | app_path=$0
69 |
70 | # Need this for daisy-chained symlinks.
71 | while
72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
73 | [ -h "$app_path" ]
74 | do
75 | ls=$( ls -ld "$app_path" )
76 | link=${ls#*' -> '}
77 | case $link in #(
78 | /*) app_path=$link ;; #(
79 | *) app_path=$APP_HOME$link ;;
80 | esac
81 | done
82 |
83 | # This is normally unused
84 | # shellcheck disable=SC2034
85 | APP_BASE_NAME=${0##*/}
86 | APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
87 |
88 | # Use the maximum available, or set MAX_FD != -1 to use that value.
89 | MAX_FD=maximum
90 |
91 | warn () {
92 | echo "$*"
93 | } >&2
94 |
95 | die () {
96 | echo
97 | echo "$*"
98 | echo
99 | exit 1
100 | } >&2
101 |
102 | # OS specific support (must be 'true' or 'false').
103 | cygwin=false
104 | msys=false
105 | darwin=false
106 | nonstop=false
107 | case "$( uname )" in #(
108 | CYGWIN* ) cygwin=true ;; #(
109 | Darwin* ) darwin=true ;; #(
110 | MSYS* | MINGW* ) msys=true ;; #(
111 | NONSTOP* ) nonstop=true ;;
112 | esac
113 |
114 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
115 |
116 |
117 | # Determine the Java command to use to start the JVM.
118 | if [ -n "$JAVA_HOME" ] ; then
119 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
120 | # IBM's JDK on AIX uses strange locations for the executables
121 | JAVACMD=$JAVA_HOME/jre/sh/java
122 | else
123 | JAVACMD=$JAVA_HOME/bin/java
124 | fi
125 | if [ ! -x "$JAVACMD" ] ; then
126 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
127 |
128 | Please set the JAVA_HOME variable in your environment to match the
129 | location of your Java installation."
130 | fi
131 | else
132 | JAVACMD=java
133 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
134 |
135 | Please set the JAVA_HOME variable in your environment to match the
136 | location of your Java installation."
137 | fi
138 |
139 | # Increase the maximum file descriptors if we can.
140 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
141 | case $MAX_FD in #(
142 | max*)
143 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
144 | # shellcheck disable=SC3045
145 | MAX_FD=$( ulimit -H -n ) ||
146 | warn "Could not query maximum file descriptor limit"
147 | esac
148 | case $MAX_FD in #(
149 | '' | soft) :;; #(
150 | *)
151 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
152 | # shellcheck disable=SC3045
153 | ulimit -n "$MAX_FD" ||
154 | warn "Could not set maximum file descriptor limit to $MAX_FD"
155 | esac
156 | fi
157 |
158 | # Collect all arguments for the java command, stacking in reverse order:
159 | # * args from the command line
160 | # * the main class name
161 | # * -classpath
162 | # * -D...appname settings
163 | # * --module-path (only if needed)
164 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
165 |
166 | # For Cygwin or MSYS, switch paths to Windows format before running java
167 | if "$cygwin" || "$msys" ; then
168 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
169 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
170 |
171 | JAVACMD=$( cygpath --unix "$JAVACMD" )
172 |
173 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
174 | for arg do
175 | if
176 | case $arg in #(
177 | -*) false ;; # don't mess with options #(
178 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
179 | [ -e "$t" ] ;; #(
180 | *) false ;;
181 | esac
182 | then
183 | arg=$( cygpath --path --ignore --mixed "$arg" )
184 | fi
185 | # Roll the args list around exactly as many times as the number of
186 | # args, so each arg winds up back in the position where it started, but
187 | # possibly modified.
188 | #
189 | # NB: a `for` loop captures its iteration list before it begins, so
190 | # changing the positional parameters here affects neither the number of
191 | # iterations, nor the values presented in `arg`.
192 | shift # remove old arg
193 | set -- "$@" "$arg" # push replacement arg
194 | done
195 | fi
196 |
197 |
198 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
199 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
200 |
201 | # Collect all arguments for the java command;
202 | # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
203 | # shell script including quotes and variable substitutions, so put them in
204 | # double quotes to make sure that they get re-expanded; and
205 | # * put everything else in single quotes, so that it's not re-expanded.
206 |
207 | set -- \
208 | "-Dorg.gradle.appname=$APP_BASE_NAME" \
209 | -classpath "$CLASSPATH" \
210 | org.gradle.wrapper.GradleWrapperMain \
211 | "$@"
212 |
213 | # Stop when "xargs" is not available.
214 | if ! command -v xargs >/dev/null 2>&1
215 | then
216 | die "xargs is not available"
217 | fi
218 |
219 | # Use "xargs" to parse quoted args.
220 | #
221 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed.
222 | #
223 | # In Bash we could simply go:
224 | #
225 | # readarray ARGS < <( xargs -n1 <<<"$var" ) &&
226 | # set -- "${ARGS[@]}" "$@"
227 | #
228 | # but POSIX shell has neither arrays nor command substitution, so instead we
229 | # post-process each arg (as a line of input to sed) to backslash-escape any
230 | # character that might be a shell metacharacter, then use eval to reverse
231 | # that process (while maintaining the separation between arguments), and wrap
232 | # the whole thing up as a single "set" statement.
233 | #
234 | # This will of course break if any of these variables contains a newline or
235 | # an unmatched quote.
236 | #
237 |
238 | eval "set -- $(
239 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
240 | xargs -n1 |
241 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
242 | tr '\n' ' '
243 | )" '"$@"'
244 |
245 | exec "$JAVACMD" "$@"
246 |
--------------------------------------------------------------------------------
/src/test/kotlin/io/github/mishkun/ataman/AtamanConfigParsingTest.kt:
--------------------------------------------------------------------------------
1 | package io.github.mishkun.ataman
2 |
3 | import io.github.mishkun.ataman.core.setupStubConfigDir
4 | import org.hamcrest.MatcherAssert.assertThat
5 | import org.hamcrest.Matchers
6 | import org.junit.Rule
7 | import org.junit.Test
8 | import org.junit.rules.TemporaryFolder
9 | import java.awt.event.KeyEvent
10 | import javax.swing.KeyStroke
11 |
12 | class AtamanConfigParsingTest {
13 |
14 | @get:Rule
15 | val tmpFolder = TemporaryFolder()
16 |
17 | @Test
18 | fun `supports special characters`() {
19 | val parsedBindings = parseConfig(
20 | configDir = tmpFolder.setupStubConfigDir(
21 | text = """
22 | |bindings {
23 | | "." { actionId: CommentByLineComment, description: Comment }
24 | | ">" { actionId: CommentByLineComment, description: Comment }
25 | |}""".trimMargin()
26 | ),
27 | ideProductKey = "IC"
28 | )
29 | assertThat(parsedBindings.exceptionOrNull(), Matchers.nullValue())
30 |
31 | // Get the parsed bindings
32 | val bindings = parsedBindings.getOrNull()!!
33 |
34 | assertThat(
35 | bindings, Matchers.equalTo(
36 | listOf(
37 | LeaderBinding.SingleBinding(
38 | KeyStroke.getKeyStroke(KeyEvent.VK_PERIOD, 0, true), // Use key release for leaf binding
39 | ".",
40 | "Comment",
41 | "CommentByLineComment",
42 | ),
43 | LeaderBinding.SingleBinding(
44 | KeyStroke.getKeyStroke(KeyEvent.VK_PERIOD, KeyEvent.SHIFT_DOWN_MASK, true), // Use key release for leaf binding
45 | ">",
46 | "Comment",
47 | "CommentByLineComment",
48 | )
49 | )
50 | )
51 | )
52 |
53 | // Verify that single bindings use key release
54 | bindings.forEach {
55 | assertThat("Leaf binding should use key release event", (it as LeaderBinding.SingleBinding).key.isOnKeyRelease, Matchers.equalTo(true))
56 | }
57 | }
58 |
59 | @Test
60 | fun `supports multibindings`() {
61 | val parsedBindings = parseConfig(
62 | configDir = tmpFolder.setupStubConfigDir(
63 | text = """
64 | |bindings {
65 | | w { actionId: [SplitVertically, Unsplit], description: Split vertically and unsplit }
66 | |}""".trimMargin()
67 | ),
68 | ideProductKey = "IC"
69 | )
70 |
71 | // Get the parsed bindings
72 | val bindings = parsedBindings.getOrNull()!!
73 |
74 | assertThat(
75 | bindings, Matchers.equalTo(
76 | listOf(
77 | LeaderBinding.SingleBinding(
78 | KeyStroke.getKeyStroke(KeyEvent.VK_W, 0, true), // Use key release for leaf binding
79 | "w",
80 | "Split vertically and unsplit",
81 | listOf("SplitVertically", "Unsplit")
82 | )
83 | )
84 | )
85 | )
86 |
87 | // Verify that single bindings use key release
88 | assertThat("Leaf binding should use key release event", (bindings[0] as LeaderBinding.SingleBinding).key.isOnKeyRelease, Matchers.equalTo(true))
89 | }
90 |
91 | @Test
92 | fun `merges ide specific config`() {
93 | val parsedBindings = parseConfig(
94 | configDir = tmpFolder.setupStubConfigDir(
95 | text = """
96 | |bindings {
97 | | q { actionId: CommentByLineComment, description: Comment }
98 | |}
99 | |IU {
100 | | q { actionId: OpenAtamanConfigAction, description: Open ~/.atamanrc.config }
101 | |}""".trimMargin()
102 | ), ideProductKey = "IU"
103 | )
104 |
105 | // Get the parsed bindings
106 | val bindings = parsedBindings.getOrNull()!!
107 |
108 | assertThat(
109 | bindings, Matchers.equalTo(
110 | listOf(
111 | LeaderBinding.SingleBinding(
112 | KeyStroke.getKeyStroke(KeyEvent.VK_Q, 0, true), // Use key release for leaf binding
113 | "q",
114 | "Open ~/.atamanrc.config",
115 | "OpenAtamanConfigAction",
116 | )
117 | )
118 | )
119 | )
120 |
121 | // Verify that single bindings use key release
122 | assertThat("Leaf binding should use key release event", (bindings[0] as LeaderBinding.SingleBinding).key.isOnKeyRelease, Matchers.equalTo(true))
123 | }
124 |
125 | @Test
126 | fun `supports f keys and does not mistake them with capitalized F`() {
127 | val parsedBindings = parseConfig(
128 | configDir = tmpFolder.setupStubConfigDir(
129 | text = """
130 | |bindings {
131 | | F { actionId: Unsplit, description: Unsplit }
132 | | F1 { actionId: CommentByLineComment, description: Comment }
133 | | F12 { actionId: OpenAtamanConfigAction, description: Open ~/.atamanrc.config }
134 | |}""".trimMargin()
135 | ),
136 | ideProductKey = "IC"
137 | )
138 |
139 | // Get the parsed bindings
140 | val bindings = parsedBindings.getOrNull()!!
141 |
142 | assertThat(
143 | bindings, Matchers.equalTo(
144 | listOf(
145 | LeaderBinding.SingleBinding(
146 | KeyStroke.getKeyStroke(KeyEvent.VK_F, KeyEvent.SHIFT_DOWN_MASK, true), // Use key release
147 | "F",
148 | "Unsplit",
149 | "Unsplit",
150 | ),
151 | LeaderBinding.SingleBinding(
152 | KeyStroke.getKeyStroke(KeyEvent.VK_F1, 0, true), // Use key release
153 | "F1",
154 | "Comment",
155 | "CommentByLineComment",
156 | ),
157 | LeaderBinding.SingleBinding(
158 | KeyStroke.getKeyStroke(KeyEvent.VK_F12, 0, true), // Use key release
159 | "F12",
160 | "Open ~/.atamanrc.config",
161 | "OpenAtamanConfigAction",
162 | )
163 | )
164 | )
165 | )
166 |
167 | // Verify that all bindings use key release
168 | bindings.forEach { binding ->
169 | assertThat("Leaf binding should use key release event",
170 | (binding as LeaderBinding.SingleBinding).key.isOnKeyRelease,
171 | Matchers.equalTo(true))
172 | }
173 | }
174 |
175 | @Test
176 | fun `returns error on malformed config`() {
177 | val parsedBindings = parseConfig(
178 | configDir = tmpFolder.setupStubConfigDir(text = "}malformed{"),
179 | ideProductKey = "IC"
180 | )
181 | assertThat(parsedBindings.isFailure, Matchers.equalTo(true))
182 | }
183 |
184 | @Test
185 | fun `throws if bindings are not set up properly`() {
186 | val parsedBindings = parseConfig(
187 | configDir = tmpFolder.setupStubConfigDir(text = "bindings { q { description: Session } }"),
188 | ideProductKey = "IC"
189 | )
190 | assertThat(parsedBindings.isFailure, Matchers.equalTo(true))
191 | println(parsedBindings.exceptionOrNull())
192 | }
193 |
194 | @Test
195 | fun `parses config to the list of bindings`() {
196 | val parsedBindings = parseConfig(
197 | configDir = tmpFolder.setupStubConfigDir(
198 | text = """
199 | |bindings {
200 | | q {
201 | | description: Session...
202 | | bindings {
203 | | F { actionId: OpenAtamanConfigAction, description: Open ~/.atamanrc.config }
204 | | }
205 | | }
206 | |}""".trimMargin()
207 | ),
208 | ideProductKey = "IC"
209 | )
210 | parsedBindings.exceptionOrNull()?.printStackTrace()
211 | assertThat(parsedBindings.exceptionOrNull(), Matchers.nullValue())
212 |
213 | // Get the parsed bindings
214 | val bindings = parsedBindings.getOrNull()!!
215 |
216 | // Check the structure of the bindings
217 | assertThat(
218 | bindings, Matchers.equalTo(
219 | listOf(
220 | LeaderBinding.GroupBinding(
221 | KeyStroke.getKeyStroke(KeyEvent.VK_Q, 0, false), // Group binding should use key press (released=false)
222 | "q",
223 | "Session...",
224 | listOf(
225 | LeaderBinding.SingleBinding(
226 | KeyStroke.getKeyStroke(KeyEvent.VK_F, KeyEvent.SHIFT_DOWN_MASK, true), // Leaf binding should use key release (released=true)
227 | "F",
228 | "Open ~/.atamanrc.config",
229 | "OpenAtamanConfigAction",
230 | )
231 | )
232 | )
233 | )
234 | )
235 | )
236 |
237 | // Verify explicitly that group bindings use key press and leaf bindings use key release
238 | val groupBinding = bindings[0] as LeaderBinding.GroupBinding
239 | assertThat("Group binding should use key press event", groupBinding.key.isOnKeyRelease, Matchers.equalTo(false))
240 |
241 | val leafBinding = groupBinding.bindings[0] as LeaderBinding.SingleBinding
242 | assertThat("Leaf binding should use key release event", leafBinding.key.isOnKeyRelease, Matchers.equalTo(true))
243 | }
244 | }
245 |
--------------------------------------------------------------------------------