├── .gitignore
├── settings.gradle
├── gradle.properties
├── screenshot.png
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── src
├── resources
│ └── META-INF
│ │ ├── tasks-integration.xml
│ │ ├── java-integration.xml
│ │ └── plugin.xml
├── main
│ └── activitytracker
│ │ ├── tracking
│ │ ├── TaskNameProvider.kt
│ │ ├── CompilationTracker.kt
│ │ ├── PsiPathProvider.kt
│ │ └── ActivityTracker.kt
│ │ ├── Main.kt
│ │ ├── liveplugin
│ │ ├── Misc.kt
│ │ ├── VcsActions.kt
│ │ └── PluginUtil.kt
│ │ ├── TrackerLog.kt
│ │ ├── EventAnalyzer.kt
│ │ ├── TrackerEvent.kt
│ │ ├── Plugin.kt
│ │ ├── StatsToolWindow.kt
│ │ └── PluginUI.kt
├── test
│ └── activitytracker
│ │ ├── StatsAnalyzerTests.kt
│ │ ├── TrackerLogTests.kt
│ │ ├── TrackerEventTests.kt
│ │ ├── shortcuts-to-sbv.kt
│ │ └── analyze-events-script.kt
└── plugin.groovy
├── .github
└── workflows
│ └── gradle.yml
├── gradlew.bat
├── README.md
└── gradlew
/.gitignore:
--------------------------------------------------------------------------------
1 | .gradle
2 | .idea
3 | build
--------------------------------------------------------------------------------
/settings.gradle:
--------------------------------------------------------------------------------
1 | rootProject.name = 'activity-tracker'
2 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | kotlin.stdlib.default.dependency = false
2 |
--------------------------------------------------------------------------------
/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dkandalov/activity-tracker/HEAD/screenshot.png
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dkandalov/activity-tracker/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | distributionBase=GRADLE_USER_HOME
2 | distributionPath=wrapper/dists
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip
4 | zipStoreBase=GRADLE_USER_HOME
5 | zipStorePath=wrapper/dists
6 |
--------------------------------------------------------------------------------
/src/resources/META-INF/tasks-integration.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
5 |
6 |
--------------------------------------------------------------------------------
/src/resources/META-INF/java-integration.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
5 |
7 |
8 |
--------------------------------------------------------------------------------
/.github/workflows/gradle.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches: [ master ]
6 |
7 | jobs:
8 | build:
9 | runs-on: ubuntu-20.04
10 | steps:
11 | - uses: actions/checkout@v2
12 | - name: Set up JDK 11
13 | uses: actions/setup-java@v1
14 | with:
15 | java-version: 11
16 | - name: Build plugin
17 | run: |
18 | chmod +x gradlew
19 | ./gradlew check buildPlugin --info
20 |
--------------------------------------------------------------------------------
/src/main/activitytracker/tracking/TaskNameProvider.kt:
--------------------------------------------------------------------------------
1 | package activitytracker.tracking
2 |
3 | import com.intellij.ide.AppLifecycleListener
4 | import com.intellij.openapi.project.Project
5 | import com.intellij.openapi.vcs.changes.ChangeListManager
6 | import com.intellij.tasks.TaskManager
7 |
8 | interface TaskNameProvider {
9 | fun taskName(project: Project): String
10 |
11 | companion object {
12 | var instance = object: TaskNameProvider {
13 | override fun taskName(project: Project) = ChangeListManager.getInstance(project).defaultChangeList.name
14 | }
15 | }
16 | }
17 |
18 | class InitTaskNameProviderViaTaskManager: AppLifecycleListener {
19 | override fun appFrameCreated(commandLineArgs: List) {
20 | TaskNameProvider.instance = object: TaskNameProvider {
21 | override fun taskName(project: Project) =
22 | TaskManager.getManager(project)?.activeTask?.presentableName
23 | ?: ChangeListManager.getInstance(project).defaultChangeList.name
24 | }
25 | }
26 | }
--------------------------------------------------------------------------------
/src/test/activitytracker/StatsAnalyzerTests.kt:
--------------------------------------------------------------------------------
1 | package activitytracker
2 |
3 | import activitytracker.TrackerEvent.Companion.parseDateTime
4 | import activitytracker.TrackerEvent.Type.IdeState
5 | import org.hamcrest.CoreMatchers.equalTo
6 | import org.hamcrest.MatcherAssert.assertThat
7 | import org.joda.time.DateTime
8 | import org.junit.Test
9 |
10 | class StatsAnalyzerTests {
11 | @Test fun `count amount of seconds spent in editor per file`() {
12 | val event = TrackerEvent(DateTime(0), "", IdeState, "", "", "Editor", "", "", 0, 0, "")
13 | val eventSequence = sequenceOf(
14 | event.copy(time = parseDateTime("2016-03-03T01:02:03.000"), file = "1.txt"),
15 | event.copy(time = parseDateTime("2016-03-03T01:02:05.000"), file = "1.txt"),
16 | event.copy(time = parseDateTime("2016-03-03T01:02:06.000"), file = "2.txt")
17 | ).constrainOnce()
18 |
19 | val stats = analyze(eventSequence)
20 |
21 | assertThat(stats.secondsInEditorByFile, equalTo(listOf(
22 | Pair("1.txt", 2),
23 | Pair("2.txt", 1),
24 | Pair("Total", 3)
25 | )))
26 | }
27 | }
--------------------------------------------------------------------------------
/src/plugin.groovy:
--------------------------------------------------------------------------------
1 | import activitytracker.*
2 | import com.intellij.ide.util.PropertiesComponent
3 | import com.intellij.openapi.application.PathManager
4 |
5 | import static liveplugin.PluginUtil.invokeOnEDT
6 | import static liveplugin.PluginUtil.show
7 | // add-to-classpath $PLUGIN_PATH/build/classes/main/
8 | // add-to-classpath $PLUGIN_PATH/lib/commons-csv-1.3.jar
9 | // add-to-classpath $PLUGIN_PATH/lib/joda-time-2.9.2.jar
10 |
11 | if (isIdeStartup) return
12 |
13 | invokeOnEDT {
14 | // def analyzer = new EventAnalyzer(new TrackerLog(""))
15 | // new StatsToolWindow.Companion().showIn(project, new Stats([], [], [], "some-file.csv"), analyzer, pluginDisposable)
16 | // return
17 |
18 | def pathToTrackingLogFile = "${PathManager.pluginsPath}/activity-tracker/ide-events.csv"
19 | def trackerLog = new TrackerLog(pathToTrackingLogFile).initWriter(1000L, pluginDisposable)
20 | def tracker = new ActivityTracker(trackerLog, pluginDisposable, false)
21 | def plugin = new ActivityTrackerPlugin(tracker, trackerLog, PropertiesComponent.instance).init()
22 | def eventAnalyzer = new EventAnalyzer(trackerLog)
23 | new PluginUI(plugin, trackerLog, eventAnalyzer, pluginDisposable).init()
24 |
25 | if (!isIdeStartup) show("Reloaded ActivityTracker")
26 | }
27 |
--------------------------------------------------------------------------------
/src/test/activitytracker/TrackerLogTests.kt:
--------------------------------------------------------------------------------
1 | package activitytracker
2 |
3 | import activitytracker.TrackerEvent.Type.IdeState
4 | import com.intellij.openapi.util.io.FileUtil
5 | import org.hamcrest.CoreMatchers.equalTo
6 | import org.hamcrest.MatcherAssert.assertThat
7 | import org.junit.Test
8 |
9 | class TrackerLogTests {
10 | @Test fun `read log events from file`() {
11 | val tempFile = FileUtil.createTempFile("event-log", "")
12 | tempFile.writeText("""
13 | |2016-08-17T13:20:40.113+01:00,user,IdeState,Active,activity-tracker,Editor,/path/to/plugin.groovy,,12,34
14 | |2016-08-17T13:20:41.120+01:00,user,IdeState,Active,activity-tracker,Popup,/path/to/plugin.groovy,,56,78
15 | """.trimMargin().trim())
16 |
17 | val events = TrackerLog(tempFile.absolutePath).readEvents { _, e -> e.printStackTrace() }
18 |
19 | assertThat(events.toList(), equalTo(listOf(
20 | TrackerEvent(TrackerEvent.parseDateTime("2016-08-17T13:20:40.113+01:00"), "user", IdeState, "Active", "activity-tracker", "Editor", "/path/to/plugin.groovy", "", 12, 34, ""),
21 | TrackerEvent(TrackerEvent.parseDateTime("2016-08-17T13:20:41.120+01:00"), "user", IdeState, "Active", "activity-tracker", "Popup", "/path/to/plugin.groovy", "", 56, 78, "")
22 | )))
23 | }
24 | }
--------------------------------------------------------------------------------
/src/main/activitytracker/Main.kt:
--------------------------------------------------------------------------------
1 | package activitytracker
2 |
3 | import activitytracker.tracking.ActivityTracker
4 | import activitytracker.tracking.CompilationTracker
5 | import activitytracker.tracking.PsiPathProvider
6 | import activitytracker.tracking.TaskNameProvider
7 | import com.intellij.ide.AppLifecycleListener
8 | import com.intellij.ide.util.PropertiesComponent
9 | import com.intellij.openapi.application.ApplicationManager
10 | import com.intellij.openapi.application.PathManager
11 |
12 | class Main: AppLifecycleListener {
13 | override fun appFrameCreated(commandLineArgs: List) {
14 | val application = ApplicationManager.getApplication()
15 | val trackerLog = TrackerLog("${PathManager.getPluginsPath()}/activity-tracker/ide-events.csv")
16 | .initWriter(parentDisposable = application, writeFrequencyMs = 10000L)
17 | PluginUI(
18 | Plugin(
19 | ActivityTracker(
20 | CompilationTracker.instance,
21 | PsiPathProvider.instance,
22 | TaskNameProvider.instance,
23 | trackerLog,
24 | parentDisposable = application,
25 | logTrackerCallDuration = false
26 | ),
27 | trackerLog,
28 | PropertiesComponent.getInstance()
29 | ).init(),
30 | trackerLog,
31 | EventAnalyzer(trackerLog),
32 | parentDisposable = application
33 | ).init()
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/resources/META-INF/plugin.xml:
--------------------------------------------------------------------------------
1 |
2 | Activity Tracker
3 | Activity Tracker
4 | 0.1.13 beta
5 | Dmitry Kandalov
6 |
7 |
11 |
12 | The main idea is to mine recorded data for interesting user or project-specific insights,
13 | e.g. time spent in each part of the project or editing/browsing ratio.
14 | If you happen to use the plugin and find an interesting way to analyze data, get in touch on
15 | Mastodon,
16 | Twitter or
17 | GitHub.
18 |
19 |
20 | For more details see the project page on GitHub.
21 | ]]>
22 |
23 |
24 |
25 | com.intellij.modules.platform
26 | com.intellij.modules.java
27 | com.intellij.tasks
28 |
29 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/src/main/activitytracker/tracking/CompilationTracker.kt:
--------------------------------------------------------------------------------
1 | package activitytracker.tracking
2 |
3 | import activitytracker.TrackerEvent
4 | import activitytracker.TrackerEvent.Type.CompilationFinished
5 | import activitytracker.liveplugin.newDisposable
6 | import activitytracker.liveplugin.registerProjectListener
7 | import com.intellij.ide.AppLifecycleListener
8 | import com.intellij.openapi.Disposable
9 | import com.intellij.openapi.compiler.CompilationStatusListener
10 | import com.intellij.openapi.compiler.CompileContext
11 | import com.intellij.openapi.compiler.CompilerTopics
12 |
13 | interface CompilationTracker {
14 | fun startActionListener(
15 | parentDisposable: Disposable,
16 | callback: (eventType: TrackerEvent.Type, originalEventData: String) -> Unit
17 | ) {}
18 |
19 | companion object {
20 | var instance = object: CompilationTracker {}
21 | }
22 | }
23 |
24 | /**
25 | * Also works for other JVM-based languages.
26 | */
27 | class InitJavaCompilationTracker: AppLifecycleListener {
28 | override fun appFrameCreated(commandLineArgs: List) {
29 | CompilationTracker.instance = object: CompilationTracker {
30 | override fun startActionListener(
31 | parentDisposable: Disposable,
32 | callback: (eventType: TrackerEvent.Type, originalEventData: String) -> Unit
33 | ) {
34 | registerCompilationListener(parentDisposable, object: CompilationStatusListener {
35 | override fun compilationFinished(aborted: Boolean, errors: Int, warnings: Int, compileContext: CompileContext) {
36 | callback(CompilationFinished, errors.toString())
37 | }
38 | })
39 | }
40 |
41 | private fun registerCompilationListener(disposable: Disposable, listener: CompilationStatusListener) {
42 | registerProjectListener(disposable) { project ->
43 | project.messageBus
44 | .connect(newDisposable(disposable, project))
45 | .subscribe(CompilerTopics.COMPILATION_STATUS, listener)
46 | }
47 | }
48 | }
49 | }
50 | }
--------------------------------------------------------------------------------
/src/main/activitytracker/liveplugin/Misc.kt:
--------------------------------------------------------------------------------
1 | package activitytracker.liveplugin
2 |
3 | import com.intellij.openapi.Disposable
4 | import com.intellij.openapi.util.Disposer
5 | import java.util.concurrent.atomic.AtomicBoolean
6 |
7 | fun Disposable.createChild() = newDisposable(this) {}
8 |
9 | fun Disposable.whenDisposed(callback: () -> Any) = newDisposable(this, callback = callback)
10 |
11 | fun newDisposable(vararg parents: Disposable, callback: () -> Any = {}): Disposable {
12 | val isDisposed = AtomicBoolean(false)
13 | val disposable = Disposable {
14 | if (!isDisposed.get()) {
15 | isDisposed.set(true)
16 | callback()
17 | }
18 | }
19 | parents.forEach { parent ->
20 | // can't use here "Disposer.register(parent, disposable)"
21 | // because Disposer only allows one parent to one child registration of Disposable objects
22 | Disposer.register(parent) {
23 | Disposer.dispose(disposable)
24 | }
25 | }
26 | return disposable
27 | }
28 |
29 | inline fun accessField(anObject: Any, possibleFieldNames: List, f: (T) -> Unit): Boolean {
30 | for (field in anObject.javaClass.declaredFields) {
31 | if (possibleFieldNames.contains(field.name) && T::class.java.isAssignableFrom(field.type)) {
32 | field.isAccessible = true
33 | try {
34 | f.invoke(field.get(anObject) as T)
35 | return true
36 | } catch (ignored: Exception) {
37 | }
38 | }
39 | }
40 | return false
41 | }
42 |
43 | @Suppress("UNCHECKED_CAST")
44 | fun accessField(o: Any, fieldName: String, fieldClass: Class<*>? = null): T {
45 | var aClass = o.javaClass as Class
46 | val allClasses = mutableListOf>()
47 | while (aClass != Object::class.java) {
48 | allClasses.add(aClass)
49 | aClass = aClass.superclass as Class
50 | }
51 | val allFields = allClasses.map { it.declaredFields.toList() }.flatten()
52 |
53 | for (field in allFields) {
54 | if (field.name == fieldName && (fieldClass == null || fieldClass.isAssignableFrom(field.type))) {
55 | field.isAccessible = true
56 | return field.get(o) as T
57 | }
58 | }
59 | val className = if (fieldClass == null) "" else " (with class ${fieldClass.canonicalName})"
60 | throw IllegalStateException("Didn't find field '$fieldName'$className in object $o")
61 | }
62 |
--------------------------------------------------------------------------------
/src/main/activitytracker/tracking/PsiPathProvider.kt:
--------------------------------------------------------------------------------
1 | package activitytracker.tracking
2 |
3 | import activitytracker.liveplugin.currentVirtualFile
4 | import com.intellij.ide.AppLifecycleListener
5 | import com.intellij.openapi.editor.Editor
6 | import com.intellij.openapi.project.Project
7 | import com.intellij.openapi.vfs.VirtualFile
8 | import com.intellij.psi.*
9 |
10 | interface PsiPathProvider {
11 | fun psiPath(project: Project, editor: Editor): String? = null
12 |
13 | companion object {
14 | var instance: PsiPathProvider = object: PsiPathProvider {}
15 | }
16 | }
17 |
18 | class InitJavaPsiPathProvider: AppLifecycleListener {
19 | override fun appFrameCreated(commandLineArgs: List) {
20 | PsiPathProvider.instance = JavaPsiPathProvider()
21 | }
22 | }
23 |
24 | private class JavaPsiPathProvider: PsiPathProvider {
25 | override fun psiPath(project: Project, editor: Editor): String {
26 | val elementAtOffset = project.currentPsiFile()?.findElementAt(editor.caretModel.offset)
27 | val psiMethod = findPsiParent(elementAtOffset) { it is PsiMethod }
28 | val psiFile = findPsiParent(elementAtOffset) { it is PsiFile }
29 | return psiPathOf(psiMethod ?: psiFile)
30 | }
31 |
32 | private fun psiPathOf(psiElement: PsiElement?): String =
33 | when (psiElement) {
34 | null, is PsiFile -> ""
35 | is PsiAnonymousClass -> {
36 | val parentName = psiPathOf(psiElement.parent)
37 | val name = "[${psiElement.baseClassType.className}]"
38 | if (parentName.isEmpty()) name else "$parentName::$name"
39 | }
40 | is PsiMethod, is PsiClass -> {
41 | val parentName = psiPathOf(psiElement.parent)
42 | val name = (psiElement as PsiNamedElement).name ?: ""
43 | if (parentName.isEmpty()) name else "$parentName::$name"
44 | }
45 | else -> psiPathOf(psiElement.parent)
46 | }
47 |
48 | @Suppress("UNCHECKED_CAST")
49 | private fun findPsiParent(element: PsiElement?, matches: (PsiElement) -> Boolean): T? =
50 | when {
51 | element == null -> null
52 | matches(element) -> element as T?
53 | else -> findPsiParent(element.parent, matches)
54 | }
55 |
56 | private fun Project.currentPsiFile(): PsiFile? =
57 | currentVirtualFile()?.toPsiFile(this)
58 |
59 | private fun VirtualFile.toPsiFile(project: Project): PsiFile? =
60 | PsiManager.getInstance(project).findFile(this)
61 | }
62 |
--------------------------------------------------------------------------------
/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 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if %ERRORLEVEL% equ 0 goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if %ERRORLEVEL% equ 0 goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | set EXIT_CODE=%ERRORLEVEL%
84 | if %EXIT_CODE% equ 0 set EXIT_CODE=1
85 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
86 | exit /b %EXIT_CODE%
87 |
88 | :mainEnd
89 | if "%OS%"=="Windows_NT" endlocal
90 |
91 | :omega
92 |
--------------------------------------------------------------------------------
/src/test/activitytracker/TrackerEventTests.kt:
--------------------------------------------------------------------------------
1 | package activitytracker
2 |
3 | import activitytracker.TrackerEvent.Companion.parseDateTime
4 | import activitytracker.TrackerEvent.Companion.printEvent
5 | import activitytracker.TrackerEvent.Companion.toTrackerEvent
6 | import activitytracker.TrackerEvent.Type.IdeState
7 | import org.apache.commons.csv.CSVFormat
8 | import org.apache.commons.csv.CSVParser
9 | import org.apache.commons.csv.CSVPrinter
10 | import org.hamcrest.CoreMatchers.equalTo
11 | import org.hamcrest.MatcherAssert.assertThat
12 | import org.junit.Test
13 | import java.io.StringReader
14 |
15 | class TrackerEventTests {
16 |
17 | @Test fun `convert event object into csv line`() {
18 | val dateTime = parseDateTime("2016-03-03T01:02:03.000")
19 | val event = TrackerEvent(dateTime, "user", IdeState, "Active", "banners", "Editor", "/path/to/file", "", 1938, 57, "Default")
20 | val expectedCsv = "2016-03-03T01:02:03.000Z,user,IdeState,Active,banners,Editor,/path/to/file,,1938,57,Default\r\n"
21 |
22 | assertThat(event.toCsvLine(), equalTo(expectedCsv))
23 | }
24 |
25 | @Test fun `convert csv line to event object`() {
26 | val parsedEvent = "2016-03-03T01:02:03.000Z,user,IdeState,Active,banners,Editor,/path/to/file,,1938,57,Default".parse()
27 | val dateTime = parseDateTime("2016-03-03T01:02:03.000")
28 | val event = TrackerEvent(dateTime, "user", IdeState, "Active", "banners", "Editor", "/path/to/file", "", 1938, 57, "Default")
29 |
30 | assertThat(parsedEvent, equalTo(event))
31 | }
32 |
33 | @Test fun `convert csv line with two-digit millis to event object (0-1-3 beta format)`() {
34 | val parsedEvent = "2016-03-03T01:02:03.12+00:00,user,IdeState,Active,banners,Editor,/path/to/file,,1938,57".parse()
35 | val dateTime = parseDateTime("2016-03-03T01:02:03.012+00:00")
36 | val event = TrackerEvent(dateTime, "user", IdeState, "Active", "banners", "Editor", "/path/to/file", "", 1938, 57, "")
37 |
38 | assertThat(parsedEvent, equalTo(event))
39 | }
40 |
41 | @Test fun `convert csv line without millis to event object (0-1-3 beta format)`() {
42 | val dateTime = parseDateTime("2016-03-03T01:02:03.000Z")
43 | val event = TrackerEvent(dateTime, "user", IdeState, "Active", "banners", "Editor", "/path/to/file", "", 1938, 57, "")
44 | val parsedEvent = "2016-03-03T01:02:03Z,user,IdeState,Active,banners,Editor,/path/to/file,,1938,57".parse()
45 |
46 | assertThat(parsedEvent, equalTo(event))
47 | }
48 |
49 | @Test fun `convert csv line with no timezone to event object (0-1-2 beta format)`() {
50 | val dateTime = parseDateTime("2016-03-03T01:02:03.123Z")
51 | val event = TrackerEvent(dateTime, "user", IdeState, "Active", "banners", "Editor", "/path/to/file", "", 1938, 57, "")
52 | val parsedEvent = "2016-03-03T01:02:03.123,user,IdeState,Active,banners,Editor,/path/to/file,,1938,57".parse()
53 |
54 | assertThat(parsedEvent, equalTo(event))
55 | }
56 |
57 | private fun TrackerEvent.toCsvLine() =
58 | StringBuilder().let {
59 | CSVPrinter(it, CSVFormat.RFC4180).printEvent(this)
60 | it.toString()
61 | }
62 |
63 | private fun String.parse(): TrackerEvent {
64 | val parser = CSVParser(StringReader(this), CSVFormat.RFC4180)
65 | return parser.records.first().toTrackerEvent()
66 | }
67 | }
68 |
69 |
--------------------------------------------------------------------------------
/src/main/activitytracker/TrackerLog.kt:
--------------------------------------------------------------------------------
1 | package activitytracker
2 |
3 | import activitytracker.TrackerEvent.Companion.printEvent
4 | import activitytracker.TrackerEvent.Companion.toTrackerEvent
5 | import activitytracker.liveplugin.whenDisposed
6 | import com.intellij.concurrency.JobScheduler
7 | import com.intellij.openapi.Disposable
8 | import com.intellij.openapi.diagnostic.Logger
9 | import com.intellij.openapi.util.io.FileUtil
10 | import org.apache.commons.csv.CSVFormat
11 | import org.apache.commons.csv.CSVParser
12 | import org.apache.commons.csv.CSVPrinter
13 | import java.io.File
14 | import java.io.FileOutputStream
15 | import java.text.SimpleDateFormat
16 | import java.util.*
17 | import java.util.concurrent.ConcurrentLinkedQueue
18 | import java.util.concurrent.TimeUnit.MILLISECONDS
19 | import kotlin.text.Charsets.UTF_8
20 |
21 | class TrackerLog(private val eventsFilePath: String) {
22 | private val log = Logger.getInstance(TrackerLog::class.java)
23 | private val eventQueue: Queue = ConcurrentLinkedQueue()
24 | private val eventsFile = File(eventsFilePath)
25 |
26 | fun initWriter(parentDisposable: Disposable, writeFrequencyMs: Long): TrackerLog {
27 | val runnable = {
28 | try {
29 | FileUtil.createIfDoesntExist(eventsFile)
30 | FileOutputStream(eventsFile, true).buffered().writer(UTF_8).use { writer ->
31 | val csvPrinter = CSVPrinter(writer, CSVFormat.RFC4180)
32 | var event: TrackerEvent? = eventQueue.poll()
33 | while (event != null) {
34 | csvPrinter.printEvent(event)
35 | event = eventQueue.poll()
36 | }
37 | csvPrinter.flush()
38 | csvPrinter.close()
39 | }
40 | } catch (e: Exception) {
41 | log.error(e)
42 | }
43 | }
44 |
45 | val future = JobScheduler.getScheduler().scheduleWithFixedDelay(runnable, writeFrequencyMs, writeFrequencyMs, MILLISECONDS)
46 | parentDisposable.whenDisposed {
47 | future.cancel(true)
48 | }
49 | return this
50 | }
51 |
52 | fun append(event: TrackerEvent?) {
53 | if (event == null) return
54 | eventQueue.add(event)
55 | }
56 |
57 | fun clearLog(): Boolean = FileUtil.delete(eventsFile)
58 |
59 | fun readEvents(onParseError: (String, Exception) -> Any): Sequence {
60 | if (!eventsFile.exists()) return emptySequence()
61 |
62 | val reader = eventsFile.bufferedReader(UTF_8)
63 | val parser = CSVParser(reader, CSVFormat.RFC4180)
64 | val sequence = parser.asSequence().map { csvRecord ->
65 | try {
66 | csvRecord.toTrackerEvent()
67 | } catch (e: Exception) {
68 | onParseError(csvRecord.toString(), e)
69 | null
70 | }
71 | }
72 |
73 | return sequence.filterNotNull().onClose {
74 | parser.close()
75 | reader.close()
76 | }
77 | }
78 |
79 | fun rollLog(now: Date = Date()): File {
80 | val postfix = SimpleDateFormat("_yyyy-MM-dd").format(now)
81 | var rolledStatsFile = File(eventsFile.path + postfix)
82 | var i = 1
83 | while (rolledStatsFile.exists()) {
84 | rolledStatsFile = File(eventsFile.path + postfix + "_" + i)
85 | i++
86 | }
87 |
88 | FileUtil.rename(eventsFile, rolledStatsFile)
89 | return rolledStatsFile
90 | }
91 |
92 | fun currentLogFile(): File = File(eventsFilePath)
93 |
94 | fun isTooLargeToProcess(): Boolean {
95 | val `2gb` = 2_000_000_000L
96 | return eventsFile.length() > `2gb`
97 | }
98 | }
99 |
100 |
101 | private fun Sequence.onClose(action: () -> Unit): Sequence {
102 | val iterator = this.iterator()
103 | return object : Sequence {
104 | override fun iterator() = object : Iterator {
105 | override fun hasNext(): Boolean {
106 | val result = iterator.hasNext()
107 | if (!result) action()
108 | return result
109 | }
110 | override fun next() = iterator.next()
111 | }
112 | }
113 | }
114 |
--------------------------------------------------------------------------------
/src/main/activitytracker/EventAnalyzer.kt:
--------------------------------------------------------------------------------
1 | package activitytracker
2 |
3 | import activitytracker.EventAnalyzer.Result.AlreadyRunning
4 | import activitytracker.EventAnalyzer.Result.DataIsTooLarge
5 | import activitytracker.EventAnalyzer.Result.Ok
6 | import activitytracker.TrackerEvent.Type.Action
7 | import activitytracker.TrackerEvent.Type.IdeState
8 | import java.io.File
9 | import java.util.concurrent.atomic.AtomicBoolean
10 |
11 | class EventAnalyzer(private val trackerLog: TrackerLog) {
12 | var runner: (() -> Unit) -> Unit = {}
13 | private val isRunning = AtomicBoolean()
14 |
15 | fun analyze(whenDone: (Result) -> Unit) {
16 | runner.invoke {
17 | when {
18 | trackerLog.isTooLargeToProcess() -> whenDone(DataIsTooLarge)
19 | isRunning.get() -> whenDone(AlreadyRunning)
20 | else -> {
21 | isRunning.set(true)
22 | try {
23 | val errors = ArrayList>()
24 | val events = trackerLog.readEvents(onParseError = { line: String, e: Exception ->
25 | errors.add(Pair(line, e))
26 | if (errors.size > 20) errors.removeAt(0)
27 | })
28 |
29 | val stats = analyze(events).copy(dataFile = trackerLog.currentLogFile().absolutePath)
30 |
31 | whenDone(Ok(stats, errors))
32 | } finally {
33 | isRunning.set(false)
34 | }
35 | }
36 | }
37 | }
38 | }
39 |
40 | sealed class Result {
41 | class Ok(val stats: Stats, val errors: List>): Result()
42 | data object AlreadyRunning: Result()
43 | data object DataIsTooLarge: Result()
44 | }
45 | }
46 |
47 | data class Stats(
48 | val secondsInEditorByFile: List>,
49 | val secondsByProject: List>,
50 | val secondsByTask: List>,
51 | val countByActionId: List>,
52 | val dataFile: String = ""
53 | )
54 |
55 | fun analyze(events: Sequence): Stats {
56 | val map1 = HashMap()
57 | val map2 = HashMap()
58 | val map3 = HashMap()
59 | val map4 = HashMap()
60 | events.forEach {
61 | secondsInEditorByFile(it, map1)
62 | secondsByProject(it, map2)
63 | secondsByTask(it, map3)
64 | countByActionId(it, map4)
65 | }
66 | return Stats(
67 | secondsInEditorByFile = map1.entries.map { Pair(it.key, it.value) }.sortedBy { -it.second }.withTotal(),
68 | secondsByProject = map2.entries.map { Pair(it.key, it.value) }.sortedBy { -it.second }.withTotal(),
69 | secondsByTask = map3.entries.map { Pair(it.key, it.value) }.sortedBy { -it.second }.withTotal(),
70 | countByActionId = map4.entries.map { Pair(it.key, it.value) }.sortedBy { -it.second }
71 | )
72 | }
73 |
74 | private fun secondsInEditorByFile(event: TrackerEvent, map: MutableMap) {
75 | if (event.type == IdeState && event.focusedComponent == "Editor" && event.file != "") {
76 | val key = event.file.fileName()
77 | map[key] = map.getOrDefault(key, 0) + 1
78 | }
79 | }
80 |
81 | private fun secondsByProject(event: TrackerEvent, map: MutableMap) {
82 | if (event.type == IdeState && event.data == "Active") {
83 | val key = event.projectName
84 | map[key] = map.getOrDefault(key, 0) + 1
85 | }
86 | }
87 |
88 | private fun secondsByTask(event: TrackerEvent, map: MutableMap) {
89 | if (event.type == IdeState && event.data == "Active") {
90 | val key = event.task
91 | map[key] = map.getOrDefault(key, 0) + 1
92 | }
93 | }
94 |
95 | private fun countByActionId(event: TrackerEvent, map: MutableMap) {
96 | if (event.type == Action) {
97 | val key = event.data
98 | map[key] = map.getOrDefault(key, 0) + 1
99 | }
100 | }
101 |
102 | private fun String.fileName(): String {
103 | val i = lastIndexOf(File.separator)
104 | return if (i == -1) this else substring(i + 1)
105 | }
106 |
107 | private fun List>.withTotal() = this + Pair("Total", sumOf { it.second })
108 |
--------------------------------------------------------------------------------
/src/test/activitytracker/shortcuts-to-sbv.kt:
--------------------------------------------------------------------------------
1 | package activitytracker
2 |
3 | import activitytracker.TrackerEvent.Type.KeyEvent
4 | import org.joda.time.DateTime
5 | import org.joda.time.DateTimeFieldType.hourOfDay
6 | import org.joda.time.DateTimeFieldType.millisOfSecond
7 | import org.joda.time.DateTimeFieldType.minuteOfHour
8 | import org.joda.time.DateTimeFieldType.secondOfMinute
9 | import org.joda.time.format.DateTimeFormatterBuilder
10 | import java.awt.event.InputEvent
11 | import java.lang.reflect.Modifier
12 |
13 | /**
14 | * Script to read IDE shortcuts from events log and
15 | * produce output compatible with .sbv file format.
16 | */
17 | fun main() {
18 | val eventsFilePath = "2019-01-11.csv"
19 | val shortcuts = TrackerLog(eventsFilePath)
20 | .readEvents(onParseError = printError)
21 | .toList()
22 | .filter { it.type == KeyEvent }
23 | .mapNotNull { event ->
24 | val (char, code, modifierFlags) = event.data.split(':').map(String::toInt)
25 | val keyName = code.toPrintableKeyName()
26 | val modifiers = modifierFlags.toPrintableModifiers()
27 |
28 | val shouldLogShortcut = char != 65535 && keyName != "Undefined" &&
29 | (modifiers.isNotEmpty() || keyName == "Tab" || keyName == "Escape") &&
30 | !(modifiers == listOf("Shift") && (keyName.length == 1 || keyName == "Colon")) // e.g Shift+A
31 |
32 | if (shouldLogShortcut) Shortcut(event.time, (modifiers + keyName).joinToString("+")) else null
33 | }
34 |
35 | val firstShortcut = shortcuts.first()
36 | shortcuts
37 | .map { it.copy(time = it.time.minusMillis(firstShortcut.time.millisOfDay)) }
38 | .chunkedBy { it.time.secondOfMinute().get() / 5 }
39 | .forEach { chunk ->
40 | val fromTime = chunk.first().time
41 | val toTime = chunk.last().time.let {
42 | if (it == fromTime) it.plusMillis(500) else it
43 | }
44 | println("${fromTime.toSbvTimeFormat()},${toTime.toSbvTimeFormat()}")
45 | println(chunk.map { it.text }.joinToString(" "))
46 | println()
47 | }
48 | }
49 |
50 | data class Shortcut(val time: DateTime, val text: String)
51 |
52 | private val sbvTimeFormatter = DateTimeFormatterBuilder()
53 | .appendFixedDecimal(hourOfDay(), 1).appendLiteral(':')
54 | .appendFixedDecimal(minuteOfHour(), 2).appendLiteral(':')
55 | .appendFixedDecimal(secondOfMinute(), 2).appendLiteral('.')
56 | .appendFixedDecimal(millisOfSecond(), 3)
57 | .toFormatter()!!
58 |
59 | private fun DateTime.toSbvTimeFormat(): String = sbvTimeFormatter.print(this)
60 |
61 | private val keyNameByCode = java.awt.event.KeyEvent::class.java.declaredFields
62 | .filter { it.name.startsWith("VK_") && Modifier.isStatic(it.modifiers) }
63 | .associate { Pair(it.get(null), it.name.vkToPrintableName()) }
64 |
65 | fun Int.toPrintableKeyName(): String = keyNameByCode[this] ?: error("")
66 |
67 | fun Int.toPrintableModifiers(): List =
68 | listOf(
69 | if (and(InputEvent.CTRL_MASK) != 0) "Ctrl" else "",
70 | if (and(InputEvent.ALT_MASK) != 0) "Alt" else "",
71 | if (and(InputEvent.META_MASK) != 0) "Cmd" else "",
72 | if (and(InputEvent.SHIFT_MASK) != 0) "Shift" else ""
73 | ).filter(String::isNotEmpty)
74 |
75 | private fun String.vkToPrintableName(): String {
76 | return when (val s = removePrefix("VK_").lowercase()) {
77 | "comma" -> ","
78 | "minus" -> "-"
79 | "period" -> "."
80 | "slash" -> "/"
81 | "semicolon" -> ";"
82 | "equals" -> "="
83 | "open_bracket" -> "["
84 | "back_slash" -> "\\"
85 | "close_bracket" -> "]"
86 | else -> s.replace("_", "").replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
87 | }
88 | }
89 |
90 | private fun List.chunkedBy(f: (T) -> R): List> = asSequence().chunkedBy(f).toList()
91 |
92 | private fun Sequence.chunkedBy(f: (T) -> R): Sequence> = sequence {
93 | var lastKey: R? = null
94 | var list = ArrayList()
95 | forEach { item ->
96 | val key = f(item)
97 | if (key != lastKey) {
98 | lastKey = key
99 | if (list.isNotEmpty()) yield(list)
100 | list = ArrayList()
101 | }
102 | list.add(item)
103 | }
104 | if (list.isNotEmpty()) yield(list)
105 | }
106 |
--------------------------------------------------------------------------------
/src/main/activitytracker/TrackerEvent.kt:
--------------------------------------------------------------------------------
1 | package activitytracker
2 |
3 | import org.apache.commons.csv.CSVPrinter
4 | import org.apache.commons.csv.CSVRecord
5 | import org.joda.time.DateTime
6 | import org.joda.time.DateTimeFieldType.*
7 | import org.joda.time.format.DateTimeFormatter
8 | import org.joda.time.format.DateTimeFormatterBuilder
9 |
10 | data class TrackerEvent(
11 | val time: DateTime,
12 | val userName: String,
13 | val type: Type,
14 | val data: String,
15 | val projectName: String,
16 | val focusedComponent: String,
17 | val file: String,
18 | val psiPath: String,
19 | val editorLine: Int,
20 | val editorColumn: Int,
21 | val task: String
22 | ) {
23 |
24 | enum class Type {
25 | IdeState,
26 | KeyEvent,
27 | MouseEvent,
28 | Action,
29 | VcsAction,
30 | CompilationFinished,
31 | Duration,
32 | Execution
33 | }
34 |
35 | companion object {
36 | private val dateTimeParseFormat: DateTimeFormatter = createDateTimeParseFormat()
37 | private val dateTimePrintFormat: DateTimeFormatter = createDateTimePrintFormat()
38 |
39 | fun ideNotInFocus(time: DateTime, userName: String, eventType: Type, eventData: String) =
40 | TrackerEvent(time, userName, eventType, eventData, "", "", "", "", -1, -1, "")
41 |
42 | fun CSVPrinter.printEvent(event: TrackerEvent) = event.apply {
43 | printRecord(
44 | dateTimePrintFormat.print(time),
45 | userName,
46 | type,
47 | data,
48 | projectName,
49 | focusedComponent,
50 | file,
51 | psiPath,
52 | editorLine,
53 | editorColumn,
54 | task
55 | )
56 | }
57 |
58 | fun CSVRecord.toTrackerEvent() = TrackerEvent(
59 | time = parseDateTime(this[0]),
60 | userName = this[1],
61 | type = Type.valueOf(this[2]),
62 | data = this[3],
63 | projectName = this[4],
64 | focusedComponent = this[5],
65 | file = this[6],
66 | psiPath = this[7],
67 | editorLine = this[8].toInt(),
68 | editorColumn = this[9].toInt(),
69 | task = if (size() < 11) "" else this[10] // backward compatibility with plugin data before 1.0.6 beta
70 | )
71 |
72 | fun parseDateTime(time: String): DateTime = DateTime.parse(time, dateTimeParseFormat.withZoneUTC())
73 |
74 | /**
75 | * Parser has to be separate from {@link #createDateTimePrintFormat()}
76 | * because builder with optional elements doesn't support printing.
77 | */
78 | private fun createDateTimeParseFormat(): DateTimeFormatter {
79 | // support for plugin version "0.1.3 beta" format where amount of milliseconds could vary (java8 DateTimeFormatter.ISO_OFFSET_DATE_TIME)
80 | val msParser = DateTimeFormatterBuilder()
81 | .appendLiteral('.').appendDecimal(millisOfSecond(), 1, 3)
82 | .toParser()
83 | // support for plugin version "0.1.2 beta" format which didn't have timezone
84 | val timeZoneParser = DateTimeFormatterBuilder()
85 | .appendTimeZoneOffset("Z", true, 2, 4)
86 | .toParser()
87 |
88 | return DateTimeFormatterBuilder()
89 | .appendFixedDecimal(year(), 4)
90 | .appendLiteral('-').appendFixedDecimal(monthOfYear(), 2)
91 | .appendLiteral('-').appendFixedDecimal(dayOfMonth(), 2)
92 | .appendLiteral('T').appendFixedDecimal(hourOfDay(), 2)
93 | .appendLiteral(':').appendFixedDecimal(minuteOfHour(), 2)
94 | .appendLiteral(':').appendFixedDecimal(secondOfMinute(), 2)
95 | .appendOptional(msParser)
96 | .appendOptional(timeZoneParser)
97 | .toFormatter()
98 | }
99 |
100 | /**
101 | * Similar to {@link org.joda.time.format.ISODateTimeFormat#basicDateTime()} except for "yyyy-MM-dd" part.
102 | * It's also similar java8 DateTimeFormatter.ISO_OFFSET_DATE_TIME except that this printer always prints all millisecond digits
103 | * which should be easier to read (or manually parse).
104 | */
105 | private fun createDateTimePrintFormat(): DateTimeFormatter {
106 | return DateTimeFormatterBuilder()
107 | .appendFixedDecimal(year(), 4)
108 | .appendLiteral('-').appendFixedDecimal(monthOfYear(), 2)
109 | .appendLiteral('-').appendFixedDecimal(dayOfMonth(), 2)
110 | .appendLiteral('T').appendFixedDecimal(hourOfDay(), 2)
111 | .appendLiteral(':').appendFixedDecimal(minuteOfHour(), 2)
112 | .appendLiteral(':').appendFixedDecimal(secondOfMinute(), 2)
113 | .appendLiteral('.').appendDecimal(millisOfSecond(), 3, 3)
114 | .appendTimeZoneOffset("Z", true, 2, 4)
115 | .toFormatter()
116 | }
117 | }
118 | }
--------------------------------------------------------------------------------
/src/main/activitytracker/liveplugin/VcsActions.kt:
--------------------------------------------------------------------------------
1 | package activitytracker.liveplugin
2 |
3 | import com.intellij.notification.Notification
4 | import com.intellij.notification.Notifications
5 | import com.intellij.openapi.Disposable
6 | import com.intellij.openapi.project.Project
7 | import com.intellij.openapi.vcs.CheckinProjectPanel
8 | import com.intellij.openapi.vcs.VcsException
9 | import com.intellij.openapi.vcs.VcsKey
10 | import com.intellij.openapi.vcs.changes.CommitContext
11 | import com.intellij.openapi.vcs.checkin.CheckinHandler
12 | import com.intellij.openapi.vcs.checkin.VcsCheckinHandlerFactory
13 | import com.intellij.openapi.vcs.impl.CheckinHandlersManager
14 | import com.intellij.openapi.vcs.impl.CheckinHandlersManagerImpl
15 | import com.intellij.openapi.vcs.update.UpdatedFilesListener
16 | import com.intellij.util.containers.MultiMap
17 | import com.intellij.util.messages.MessageBusConnection
18 |
19 | class VcsActions(private val project: Project, private val listener: Listener) {
20 | private val busConnection: MessageBusConnection = project.messageBus.connect()
21 |
22 | private val updatedListener: UpdatedFilesListener = UpdatedFilesListener { listener.onVcsUpdate() }
23 |
24 | // see git4idea.push.GitPushResultNotification#create
25 | // see org.zmlx.hg4idea.push.HgPusher#push
26 | private val pushListener: Notifications = object : Notifications {
27 | override fun notify(notification: Notification) {
28 | if (!isVcsNotification(notification)) return
29 |
30 | if (matchTitleOf(notification, "Push successful")) {
31 | listener.onVcsPush()
32 | } else if (matchTitleOf(notification, "Push failed", "Push partially failed", "Push rejected", "Push partially rejected")) {
33 | listener.onVcsPushFailed()
34 | }
35 | }
36 | }
37 |
38 | fun start(): VcsActions {
39 | // using bus to listen to vcs updates because normal listener calls it twice
40 | // (see also https://gist.github.com/dkandalov/8840509)
41 | busConnection.subscribe(UpdatedFilesListener.UPDATED_FILES, updatedListener)
42 | busConnection.subscribe(Notifications.TOPIC, pushListener)
43 | checkinHandlers { vcsFactories ->
44 | for (key in vcsFactories.keySet()) {
45 | vcsFactories.putValue(key, DelegatingCheckinHandlerFactory(project, key))
46 | }
47 | }
48 | return this
49 | }
50 |
51 | fun stop(): VcsActions {
52 | busConnection.disconnect()
53 | checkinHandlers { vcsFactories ->
54 | vcsFactories.entrySet().forEach { entry ->
55 | entry.value.removeIf { it is DelegatingCheckinHandlerFactory && it.project == project }
56 | }
57 | }
58 | return this
59 | }
60 |
61 | private fun checkinHandlers(f: (MultiMap) -> Unit) {
62 | val checkinHandlersManager = CheckinHandlersManager.getInstance() as CheckinHandlersManagerImpl
63 | accessField(checkinHandlersManager, listOf("a", "b", "myVcsMap", "vcsFactories")) { multiMap: MultiMap ->
64 | f(multiMap)
65 | }
66 | }
67 |
68 | private fun isVcsNotification(notification: Notification) =
69 | notification.groupId == "Vcs Messages" ||
70 | notification.groupId == "Vcs Important Messages" ||
71 | notification.groupId == "Vcs Minor Notifications" ||
72 | notification.groupId == "Vcs Silent Notifications"
73 |
74 | private fun matchTitleOf(notification: Notification, vararg expectedTitles: String): Boolean {
75 | return expectedTitles.any { notification.title.startsWith(it) }
76 | }
77 |
78 | /**
79 | * Listener callbacks can be called from any thread.
80 | */
81 | interface Listener {
82 | fun onVcsCommit() {}
83 | fun onVcsCommitFailed() {}
84 | fun onVcsUpdate() {}
85 | fun onVcsPush() {}
86 | fun onVcsPushFailed() {}
87 | }
88 |
89 | private inner class DelegatingCheckinHandlerFactory(val project: Project, key: VcsKey): VcsCheckinHandlerFactory(key) {
90 | override fun createVcsHandler(panel: CheckinProjectPanel, commitContext: CommitContext): CheckinHandler {
91 | return object : CheckinHandler() {
92 | override fun checkinSuccessful() {
93 | if (panel.project == project) listener.onVcsCommit()
94 | }
95 |
96 | override fun checkinFailed(exception: List) {
97 | if (panel.project == project) listener.onVcsCommitFailed()
98 | }
99 | }
100 | }
101 | }
102 |
103 | companion object {
104 | fun registerVcsListener(disposable: Disposable, listener: Listener) {
105 | registerProjectListener(disposable) { project ->
106 | registerVcsListener(newDisposable(project, disposable), project, listener)
107 | }
108 | }
109 |
110 | fun registerVcsListener(disposable: Disposable, project: Project, listener: Listener) {
111 | val vcsActions = VcsActions(project, listener)
112 | newDisposable(project, disposable) {
113 | vcsActions.stop()
114 | }
115 | vcsActions.start()
116 | }
117 | }
118 | }
--------------------------------------------------------------------------------
/src/main/activitytracker/Plugin.kt:
--------------------------------------------------------------------------------
1 | package activitytracker
2 |
3 | import activitytracker.liveplugin.openInEditor
4 | import activitytracker.tracking.ActivityTracker
5 | import com.intellij.ide.actions.RevealFileAction
6 | import com.intellij.ide.util.PropertiesComponent
7 | import com.intellij.openapi.project.Project
8 |
9 | class Plugin(
10 | private val tracker: ActivityTracker,
11 | private val trackerLog: TrackerLog,
12 | private val propertiesComponent: PropertiesComponent
13 | ) {
14 | private var state: State = State.defaultValue
15 | private var pluginUI: PluginUI? = null
16 |
17 | fun init(): Plugin {
18 | state = State.load(propertiesComponent, pluginId)
19 | onStateChange(state)
20 | return this
21 | }
22 |
23 | fun setUI(pluginUI: PluginUI) {
24 | this.pluginUI = pluginUI
25 | pluginUI.update(state)
26 | }
27 |
28 | fun toggleTracking() = updateState { it.copy(isTracking = !it.isTracking) }
29 |
30 | fun enablePollIdeState(value: Boolean) = updateState { it.copy(pollIdeState = value) }
31 |
32 | fun enableTrackIdeActions(value: Boolean) = updateState { it.copy(trackIdeActions = value) }
33 |
34 | fun enableTrackKeyboard(value: Boolean) = updateState { it.copy(trackKeyboard = value) }
35 |
36 | fun enableTrackKeyboardReleased(value: Boolean) = updateState { it.copy(trackKeyboardReleased = value) }
37 |
38 | fun enableTrackMouse(value: Boolean) = updateState { it.copy(trackMouse = value) }
39 |
40 | fun openTrackingLogFile(project: Project?) {
41 | if (project == null) return
42 | openInEditor(trackerLog.currentLogFile().absolutePath, project)
43 | }
44 |
45 | fun openTrackingLogFolder() {
46 | RevealFileAction.openFile(trackerLog.currentLogFile().parentFile)
47 | }
48 |
49 | private fun updateState(closure: (State) -> State) {
50 | val oldState = state
51 | state = closure(state)
52 | if (oldState != state) {
53 | onStateChange(state)
54 | }
55 | }
56 |
57 | private fun onStateChange(newState: State) {
58 | tracker.stopTracking()
59 | if (newState.isTracking) {
60 | tracker.startTracking(newState.toConfig())
61 | }
62 | pluginUI?.update(newState)
63 | newState.save(propertiesComponent, pluginId)
64 | }
65 |
66 | private fun State.toConfig() = ActivityTracker.Config(
67 | pollIdeState,
68 | pollIdeStateMs.toLong(),
69 | trackIdeActions,
70 | trackKeyboard,
71 | trackKeyboardReleased,
72 | trackMouse,
73 | mouseMoveEventsThresholdMs.toLong()
74 | )
75 |
76 |
77 | data class State(
78 | val isTracking: Boolean,
79 | val pollIdeState: Boolean,
80 | val pollIdeStateMs: Int,
81 | val trackIdeActions: Boolean,
82 | val trackKeyboard: Boolean,
83 | val trackKeyboardReleased: Boolean,
84 | val trackMouse: Boolean,
85 | val mouseMoveEventsThresholdMs: Int
86 | ) {
87 | fun save(propertiesComponent: PropertiesComponent, id: String) {
88 | propertiesComponent.run {
89 | setValue("$id-isTracking", isTracking, defaultValue.isTracking)
90 | setValue("$id-pollIdeState", pollIdeState, defaultValue.pollIdeState)
91 | setValue("$id-pollIdeStateMs", pollIdeStateMs, defaultValue.pollIdeStateMs)
92 | setValue("$id-trackIdeActions", trackIdeActions, defaultValue.trackIdeActions)
93 | setValue("$id-trackKeyboard", trackKeyboard, defaultValue.trackKeyboard)
94 | setValue("$id-trackKeyboardReleased", trackKeyboardReleased, defaultValue.trackKeyboard)
95 | setValue("$id-trackMouse", trackMouse, defaultValue.trackMouse)
96 | setValue("$id-mouseMoveEventsThresholdMs", mouseMoveEventsThresholdMs, defaultValue.mouseMoveEventsThresholdMs)
97 | }
98 | }
99 |
100 | companion object {
101 | val defaultValue = State(
102 | isTracking = true,
103 | pollIdeState = true,
104 | pollIdeStateMs = 1000,
105 | trackIdeActions = true,
106 | trackKeyboard = false,
107 | trackKeyboardReleased = false,
108 | trackMouse = false,
109 | mouseMoveEventsThresholdMs = 250
110 | )
111 |
112 | fun load(propertiesComponent: PropertiesComponent, id: String): State {
113 | return propertiesComponent.run {
114 | State(
115 | getBoolean("$id-isTracking", defaultValue.isTracking),
116 | getBoolean("$id-pollIdeState", defaultValue.pollIdeState),
117 | getInt("$id-pollIdeStateMs", defaultValue.pollIdeStateMs),
118 | getBoolean("$id-trackIdeActions", defaultValue.trackIdeActions),
119 | getBoolean("$id-trackKeyboard", defaultValue.trackKeyboard),
120 | getBoolean("$id-trackKeyboardReleased", defaultValue.trackKeyboard),
121 | getBoolean("$id-trackMouse", defaultValue.trackMouse),
122 | getInt("$id-mouseMoveEventsThresholdMs", defaultValue.mouseMoveEventsThresholdMs)
123 | )
124 | }
125 | }
126 | }
127 | }
128 |
129 | companion object {
130 | const val pluginId = "ActivityTracker"
131 | }
132 | }
--------------------------------------------------------------------------------
/src/test/activitytracker/analyze-events-script.kt:
--------------------------------------------------------------------------------
1 | package activitytracker
2 |
3 | import activitytracker.TrackerEvent.Type.*
4 | import org.joda.time.DateTime
5 | import org.joda.time.DateTimeZone
6 | import org.joda.time.Duration
7 | import java.util.*
8 |
9 | val userHome = System.getProperty("user.home")!!
10 | val printError = { line: String, _: Exception -> println("Failed to parse: $line") }
11 |
12 | fun main() {
13 | // val eventsFilePath = "$userHome/Library/Application Support/IntelliJIdea2017.2/activity-tracker/2017-09-01.csv"
14 | val eventsFilePath = "$userHome/Library/Application Support/IntelliJIdea2017.2/activity-tracker/ide-events.csv"
15 | val eventSequence = TrackerLog(eventsFilePath).readEvents { line: String, _: Exception ->
16 | println("Failed to parse: $line")
17 | }
18 |
19 | // amountOfKeyPresses(eventSequence)
20 | // createHistogramOfDurationEvents(eventSequence)
21 |
22 | val sessions = eventSequence.groupIntoSessions().filterAndMergeSessions().onEach { println(it) }
23 |
24 | Histogram(sessions.map{ it.duration.standardMinutes }.toList()).bucketed(20).printed()
25 | }
26 |
27 | private fun amountOfKeyPresses(eventSequence: Sequence) {
28 | val histogram = Histogram()
29 | eventSequence
30 | .filter { it.type == KeyEvent }
31 | .map { it.data.split(":") }
32 | .filter { it[0] != "65535" }
33 | .map { it[0].toInt().toChar().lowercaseChar() }
34 | .map {
35 | when (it) {
36 | '~' -> '`'
37 | '!' -> '1'
38 | '@' -> '2'
39 | '#' -> '3'
40 | '$' -> '4'
41 | '%' -> '5'
42 | '^' -> '6'
43 | '&' -> '7'
44 | '*' -> '8'
45 | '(' -> '9'
46 | ')' -> '0'
47 | '_' -> '-'
48 | '+' -> '='
49 | '<' -> ','
50 | '>' -> '.'
51 | '{' -> '['
52 | '}' -> ']'
53 | '|' -> '\\'
54 | '\"' -> '\''
55 | ':' -> ';'
56 | '?' -> '/'
57 | else -> it
58 | }
59 | }
60 | .forEachIndexed { _, c ->
61 | histogram.add(c)
62 | }
63 |
64 |
65 | histogram.frequencyByValue.entries
66 | .sortedBy { -it.value }
67 | .forEach { println(it) }
68 |
69 | // val normalized = histogram.normalizeTo(max = 100)
70 | // normalized
71 | // .frequencyByValue.entries
72 | // .sortedBy { -it.value }
73 | // .forEach { entry ->
74 | // 0.until(entry.value).forEach {
75 | // print(entry.key)
76 | // }
77 | // }
78 | }
79 |
80 | private data class Session(val events: List) {
81 | val duration: Duration get() = Duration(events.first().time, events.last().time)
82 |
83 | override fun toString() =
84 | "minutes: ${duration.standardMinutes}; " +
85 | "from: ${events.first().localTime}; " +
86 | "to: ${events.last().localTime};"
87 |
88 | private val TrackerEvent.localTime: DateTime get() = time.withZone(DateTimeZone.forOffsetHours(1))
89 | }
90 |
91 | fun TrackerEvent.ideIsActive() = type != IdeState || (type == IdeState && data != "Inactive")
92 |
93 | private fun Sequence.groupIntoSessions(): Sequence =
94 | sequence {
95 | var currentEvents = ArrayList()
96 | forEach { event ->
97 | val isEndOfCurrentSession =
98 | currentEvents.isNotEmpty() && currentEvents.last().ideIsActive() != event.ideIsActive()
99 |
100 | if (isEndOfCurrentSession) {
101 | yield(Session(currentEvents))
102 | currentEvents = ArrayList()
103 | }
104 | currentEvents.add(event)
105 | }
106 | }
107 |
108 | private fun Sequence.filterAndMergeSessions(): Sequence =
109 | sequence {
110 | var lastSession: Session? = null
111 | this@filterAndMergeSessions
112 | .filter { session ->
113 | session.events.first().ideIsActive()
114 | && session.duration > Duration.standardMinutes(5)
115 | && session.events.any { it.type == IdeState && it.focusedComponent == "Editor" }
116 | }
117 | .forEach { session ->
118 | lastSession = if (lastSession == null) session
119 | else {
120 | val timeBetweenSessions = Duration(lastSession!!.events.last().time, session.events.first().time)
121 | if (timeBetweenSessions < Duration.standardMinutes(5)) {
122 | Session(lastSession!!.events + session.events)
123 | } else {
124 | yield(lastSession!!)
125 | session
126 | }
127 | }
128 | }
129 | yield(lastSession!!)
130 | }
131 |
132 | /**
133 | * The intention is to check whether activity-tracker has significant impact on IDE performance.
134 | *
135 | * To do this `ActivityTracker.logTrackerCallDuration` was enabled, the plugin was used for some time
136 | * and then this function was used to analyse events.
137 | *
138 | * The conclusion was that there is no significant impact on performance
139 | * (no numbers are available at the time of writing though).
140 | */
141 | private fun createHistogramOfDurationEvents(eventSequence: Sequence) {
142 | val allDurations = mutableListOf()
143 | eventSequence
144 | .filter { it.type == Duration }
145 | .forEach { event ->
146 | allDurations.addAll(event.data.split(",").map(String::toInt))
147 | }
148 | val histogram = Histogram().addAll(allDurations)
149 | histogram.frequencyByValue.entries.forEach {
150 | println(it)
151 | }
152 | }
153 |
154 | private class Histogram(val frequencyByValue: HashMap = HashMap()) {
155 |
156 | constructor(values: Collection): this() {
157 | addAll(values)
158 | }
159 |
160 | fun add(value: T): Histogram {
161 | val frequency = frequencyByValue.getOrElse(value, { 0 })
162 | frequencyByValue[value] = frequency + 1
163 | return this
164 | }
165 |
166 | fun addAll(values: Collection): Histogram {
167 | values.forEach { add(it) }
168 | return this
169 | }
170 | }
171 |
172 | private fun > Histogram.printed() {
173 | frequencyByValue.entries
174 | .sortedBy { it.key }
175 | .forEach { entry ->
176 | println("${entry.key}\t${entry.value}")
177 | }
178 | }
179 |
180 | private fun Histogram.bucketed(bucketSize: Long): Histogram {
181 | val histogram = Histogram()
182 | frequencyByValue.entries.forEach {
183 | histogram.add((it.key / bucketSize) * bucketSize + bucketSize)
184 | }
185 | return histogram
186 | }
187 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://github.com/dkandalov/activity-tracker/actions)
2 |
3 | ## Activity Tracker
4 | This is a proof-of-concept plugin for IntelliJ IDEs to track and record user activity.
5 | Currently, the main feature is recording user activity into CSV files.
6 |
7 | To install the plugin use `IDE Settings -> Plugins -> Marketplace -> Search for "Activity Tracker"`.
8 |
9 | To use the plugin see the "Activity tracker" widget in the IDE status bar.
10 |
11 |
12 |
13 | ## Why?
14 | The main idea is to mine recorded data for interesting user or project-specific insights,
15 | e.g. time spent in each part of the project or editing/browsing ratio.
16 | If you use the plugin and find an interesting way to analyze the data, feel free to get in touch on
17 | [Mastodon](https://mastodon.social/@dkandalov),
18 | [Twitter](https://twitter.com/dmitrykandalov) or
19 | [GitHub](https://github.com/dkandalov/activity-tracker/issues).
20 |
21 | ## Help
22 | To open plugin popup menu:
23 | - click on the "Activity tracker" widget in the IDE status bar or
24 | - use the "Activity Tracker Popup" action (`ctrl+alt+shift+O` shortcut)
25 |
26 | ### Popup menu actions
27 | - **Start/Stop Tracking** - activate/deactivate recording of IDE events.
28 | Events are written to `ide-events.csv` file. This file is referred to as the "current log".
29 | - **Current Log**
30 | - **Show Stats** - analyse the current log and open the tool window which shows time spent editing each file.
31 | - **Open in IDE** - open the current log file in IDE editor.
32 | - **Open in File Manager** - open the current log in file manager
33 | (can be useful to navigate to log file location path).
34 | - **Roll Tracking Log** - rename the current log file by adding date postfix to it.
35 | The intention is to keep previous data and clear the current log.
36 | - **Clear Tracking Log** - remove all data from the current log.
37 | - **Settings**
38 | - **Track IDE Action** - enable capturing IDE actions.
39 | - **Poll IDE State** - enable polling IDE state (every 1 second) even if there is no activity.
40 | Enable this option to get more accurate data about time spent in/outside of IDE.
41 | - **Track Keyboard** - enable tracking keyboard events. __**Beware!**__
42 | If you enter sensitive information (like passwords), it might be captured and stored in the current log file.
43 | - **Track Mouse** - enable tracking mouse click events.
44 | Note that because of the high volume of mouse move and wheel events, they are logged at most every 250 milliseconds.
45 |
46 | ### Log file format
47 | The event log file is written as [CSV RFC4180](https://tools.ietf.org/html/rfc4180) in UTF-8 encoding.
48 |
49 | - **timestamp** - time of the event in `yyyy-MM-dd'T'HHmmss.SSSZ` format
50 | (see [createDateTimePrintFormat() method](https://github.com/dkandalov/activity-tracker/blob/6ca1342e8c71c96f5f7a1c52095c61317cc78650/src/main/activitytracker/TrackerEvent.groovy#L109-L109)).
51 | In plugin version 0.1.3 it was [ISO-8601 extended format](https://docs.oracle.com/javase/8/docs/api/java/time/format/DateTimeFormatter.html#ISO_OFFSET_DATE_TIME).
52 | In plugin version 0.1.2 it was `yyyy/MM/dd kk:mm:ss.SSS` format.
53 | - **user name** - current user name. The intention is to be able to merge logs from several users.
54 | - **event type**, **event data** - content depends on the type of captured event.
55 | - **IDE actions**: event type = `Action`, event data = `[action id]` (e.g. `Action,EditorUp`);
56 | or event type = `VcsAction`, event data = `[Push|Update|Commit]`;
57 | or event type = `CompilationFinished`, event data = `[amount of errors during compilation]`.
58 | - **Executions**: event type = `Execution`, event data = `[Run|Debug|Coverage]:[Run configuration name]:[full commandline instruction]`.
59 | - **IDE polling events**: event type = `IdeState`, event data = `[Active|Inactive|NoProject]`,
60 | where `Inactive` means IDE doesn't have focus, `NoProject` means all projects are closed.
61 | - **keyboard events**: event type = `KeyEvent`, event data = `[eventId]:[keyChar]:[keyCode]:[modifiers]`
62 | (see [AWT KeyEvent](https://docs.oracle.com/javase/7/docs/api/java/awt/event/KeyEvent.html)).
63 | - **mouse events**: event type = `MouseEvent`, event data can be
64 | - `click:[button]:[clickCount]:[modifiers]`
65 | - `move:[x]:[y]:[modifiers]`
66 | - `wheel:[wheelRotation]:[wheelModifiers]`
67 |
68 | (see java [MouseEvent](https://docs.oracle.com/javase/7/docs/api/java/awt/event/MouseEvent.html)
69 | and [MouseWheelEvent](https://docs.oracle.com/javase/7/docs/api/java/awt/event/MouseWheelEvent.html) for details).
70 | - **project name** - the name of the active project.
71 | - **focused component** - can be `Editor`, `Dialog`, `Popup`, or tool window id (e.g. `Version Control` or `Project`).
72 | - **current file** - absolute path to file open in the editor (even when the editor has no focus).
73 | - **PSI path** - [PSI](http://www.jetbrains.org/intellij/sdk/docs/basics/architectural_overview/psi_elements.html)
74 | path to currently selected element, format `[parent]::[child]`.
75 | Empty value if the file was not parsed by IDE (e.g. plain text file).
76 | - **editor line** - caret line number in Editor.
77 | - **editor column** - caret column number in Editor.
78 | - **task/change list name** - the name of the current task (`Main Menu -> Tools -> Tasks & Contexts`)
79 | or current VCS change list name if the "Task Management" plugin is not installed.
80 |
81 |
82 | ### Example of log file
83 | ```
84 | 2015-12-31T17:42:30.171Z,dima,IdeState,Active,activity-tracker,Editor,/path/to/jdk/src.zip!/java/awt/AWTEvent.java,AWTEvent::isConsumed,450,8,
85 | 2015-12-31T17:42:30.35Z,dima,Action,EditorLineEnd,activity-tracker,Editor,/path/to/jdk/src.zip!/java/awt/AWTEvent.java,AWTEvent::isConsumed,450,8,
86 | 2015-12-31T17:42:30.351Z,dima,KeyEvent,401:97:79:8,activity-tracker,Editor,/path/to/jdk/src.zip!/java/awt/AWTEvent.java,AWTEvent::isConsumed,450,24,
87 | 2015-12-31T17:42:30.566Z,dima,Action,EditorLineStart,activity-tracker,Editor,/path/to/jdk/src.zip!/java/awt/AWTEvent.java,AWTEvent::isConsumed,450,24,
88 | 2015-12-31T17:42:30.568Z,dima,KeyEvent,401:97:85:8,activity-tracker,Editor,/path/to/jdk/src.zip!/java/awt/AWTEvent.java,AWTEvent::isConsumed,450,8,
89 | 2015-12-31T17:42:30.998Z,dima,KeyEvent,401:65535:157:4,activity-tracker,Editor,/path/to/jdk/src.zip!/java/awt/AWTEvent.java,AWTEvent::isConsumed,450,8,
90 | 2015-12-31T17:42:31.17Z,dima,IdeState,Active,activity-tracker,Editor,/path/to/jdk/src.zip!/java/awt/AWTEvent.java,AWTEvent::isConsumed,450,8,
91 | 2015-12-31T17:42:32.169Z,dima,IdeState,Inactive,,,,,-1,-1,
92 | 2015-12-31T17:42:33.168Z,dima,IdeState,Inactive,,,,,-1,-1,
93 | ```
94 |
95 | ### How to use log file?
96 | This is up to you.
97 |
98 | ## Contributing
99 | The most interesting part is the analysis of the recorded data.
100 | All suggestions and code are welcome (even if it's not a JVM language, e.g. a Python snippet).
101 | If you have a question, feel free to create an issue.
102 |
103 | Working on the plugin:
104 | - to edit code, open project in IJ IDEA importing Gradle configuration
105 | - to build use `./gradlew buildPlugin` task which will create `build/distributions/activity-tracker-plugin.zip`
106 | - to run use `./gradlew runIde` task
107 | - alternatively, you can use [LivePlugin](https://github.com/dkandalov/live-plugin)
108 | as an [entry point](https://github.com/dkandalov/live-plugin/wiki/Liveplugin-as-an-entry-point-for-standard-plugins).
109 |
--------------------------------------------------------------------------------
/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/master/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 | APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
84 |
85 | APP_NAME="Gradle"
86 | APP_BASE_NAME=${0##*/}
87 |
88 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
89 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
90 |
91 | # Use the maximum available, or set MAX_FD != -1 to use that value.
92 | MAX_FD=maximum
93 |
94 | warn () {
95 | echo "$*"
96 | } >&2
97 |
98 | die () {
99 | echo
100 | echo "$*"
101 | echo
102 | exit 1
103 | } >&2
104 |
105 | # OS specific support (must be 'true' or 'false').
106 | cygwin=false
107 | msys=false
108 | darwin=false
109 | nonstop=false
110 | case "$( uname )" in #(
111 | CYGWIN* ) cygwin=true ;; #(
112 | Darwin* ) darwin=true ;; #(
113 | MSYS* | MINGW* ) msys=true ;; #(
114 | NONSTOP* ) nonstop=true ;;
115 | esac
116 |
117 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
118 |
119 |
120 | # Determine the Java command to use to start the JVM.
121 | if [ -n "$JAVA_HOME" ] ; then
122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
123 | # IBM's JDK on AIX uses strange locations for the executables
124 | JAVACMD=$JAVA_HOME/jre/sh/java
125 | else
126 | JAVACMD=$JAVA_HOME/bin/java
127 | fi
128 | if [ ! -x "$JAVACMD" ] ; then
129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
130 |
131 | Please set the JAVA_HOME variable in your environment to match the
132 | location of your Java installation."
133 | fi
134 | else
135 | JAVACMD=java
136 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
137 |
138 | Please set the JAVA_HOME variable in your environment to match the
139 | location of your Java installation."
140 | fi
141 |
142 | # Increase the maximum file descriptors if we can.
143 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
144 | case $MAX_FD in #(
145 | max*)
146 | MAX_FD=$( ulimit -H -n ) ||
147 | warn "Could not query maximum file descriptor limit"
148 | esac
149 | case $MAX_FD in #(
150 | '' | soft) :;; #(
151 | *)
152 | ulimit -n "$MAX_FD" ||
153 | warn "Could not set maximum file descriptor limit to $MAX_FD"
154 | esac
155 | fi
156 |
157 | # Collect all arguments for the java command, stacking in reverse order:
158 | # * args from the command line
159 | # * the main class name
160 | # * -classpath
161 | # * -D...appname settings
162 | # * --module-path (only if needed)
163 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
164 |
165 | # For Cygwin or MSYS, switch paths to Windows format before running java
166 | if "$cygwin" || "$msys" ; then
167 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
168 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
169 |
170 | JAVACMD=$( cygpath --unix "$JAVACMD" )
171 |
172 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
173 | for arg do
174 | if
175 | case $arg in #(
176 | -*) false ;; # don't mess with options #(
177 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
178 | [ -e "$t" ] ;; #(
179 | *) false ;;
180 | esac
181 | then
182 | arg=$( cygpath --path --ignore --mixed "$arg" )
183 | fi
184 | # Roll the args list around exactly as many times as the number of
185 | # args, so each arg winds up back in the position where it started, but
186 | # possibly modified.
187 | #
188 | # NB: a `for` loop captures its iteration list before it begins, so
189 | # changing the positional parameters here affects neither the number of
190 | # iterations, nor the values presented in `arg`.
191 | shift # remove old arg
192 | set -- "$@" "$arg" # push replacement arg
193 | done
194 | fi
195 |
196 | # Collect all arguments for the java command;
197 | # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
198 | # shell script including quotes and variable substitutions, so put them in
199 | # double quotes to make sure that they get re-expanded; and
200 | # * put everything else in single quotes, so that it's not re-expanded.
201 |
202 | set -- \
203 | "-Dorg.gradle.appname=$APP_BASE_NAME" \
204 | -classpath "$CLASSPATH" \
205 | org.gradle.wrapper.GradleWrapperMain \
206 | "$@"
207 |
208 | # Stop when "xargs" is not available.
209 | if ! command -v xargs >/dev/null 2>&1
210 | then
211 | die "xargs is not available"
212 | fi
213 |
214 | # Use "xargs" to parse quoted args.
215 | #
216 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed.
217 | #
218 | # In Bash we could simply go:
219 | #
220 | # readarray ARGS < <( xargs -n1 <<<"$var" ) &&
221 | # set -- "${ARGS[@]}" "$@"
222 | #
223 | # but POSIX shell has neither arrays nor command substitution, so instead we
224 | # post-process each arg (as a line of input to sed) to backslash-escape any
225 | # character that might be a shell metacharacter, then use eval to reverse
226 | # that process (while maintaining the separation between arguments), and wrap
227 | # the whole thing up as a single "set" statement.
228 | #
229 | # This will of course break if any of these variables contains a newline or
230 | # an unmatched quote.
231 | #
232 |
233 | eval "set -- $(
234 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
235 | xargs -n1 |
236 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
237 | tr '\n' ' '
238 | )" '"$@"'
239 |
240 | exec "$JAVACMD" "$@"
241 |
--------------------------------------------------------------------------------
/src/main/activitytracker/StatsToolWindow.kt:
--------------------------------------------------------------------------------
1 | package activitytracker
2 |
3 | import activitytracker.EventAnalyzer.Result.*
4 | import activitytracker.liveplugin.createChild
5 | import activitytracker.liveplugin.invokeLaterOnEDT
6 | import activitytracker.liveplugin.showNotification
7 | import activitytracker.liveplugin.whenDisposed
8 | import com.intellij.icons.AllIcons
9 | import com.intellij.icons.AllIcons.Actions.Cancel
10 | import com.intellij.icons.AllIcons.Actions.Refresh
11 | import com.intellij.ide.ClipboardSynchronizer
12 | import com.intellij.openapi.Disposable
13 | import com.intellij.openapi.actionSystem.*
14 | import com.intellij.openapi.keymap.KeymapUtil
15 | import com.intellij.openapi.project.Project
16 | import com.intellij.openapi.ui.SimpleToolWindowPanel
17 | import com.intellij.openapi.util.Disposer
18 | import com.intellij.openapi.wm.ToolWindow
19 | import com.intellij.openapi.wm.ToolWindowAnchor
20 | import com.intellij.openapi.wm.ToolWindowAnchor.RIGHT
21 | import com.intellij.openapi.wm.ToolWindowManager
22 | import com.intellij.ui.components.JBScrollPane
23 | import com.intellij.ui.content.ContentFactory
24 | import com.intellij.ui.table.JBTable
25 | import com.intellij.util.ui.GridBag
26 | import com.intellij.util.ui.StartupUiUtil
27 | import com.intellij.util.ui.UIUtil
28 | import java.awt.GridBagConstraints.*
29 | import java.awt.GridBagLayout
30 | import java.awt.datatransfer.StringSelection
31 | import java.awt.event.ActionEvent
32 | import java.util.function.Supplier
33 | import javax.swing.*
34 | import javax.swing.table.DefaultTableModel
35 |
36 | object StatsToolWindow {
37 | private const val toolWindowId = "Tracking Log Stats"
38 |
39 | fun showIn(project: Project, stats: Stats, eventAnalyzer: EventAnalyzer, parentDisposable: Disposable) {
40 | val toolWindowPanel = SimpleToolWindowPanel(true)
41 | var rootComponent = createRootComponent(stats)
42 | toolWindowPanel.setContent(rootComponent)
43 |
44 | val disposable = parentDisposable.createChild()
45 | val actionGroup = DefaultActionGroup().apply {
46 | add(object : AnAction(Supplier { "Rerun activity log analysis" }, Refresh) {
47 | override fun actionPerformed(e: AnActionEvent) =
48 | eventAnalyzer.analyze(whenDone = { result ->
49 | invokeLaterOnEDT {
50 | when (result) {
51 | is Ok -> {
52 | toolWindowPanel.remove(rootComponent)
53 | rootComponent = createRootComponent(result.stats)
54 | toolWindowPanel.setContent(rootComponent)
55 |
56 | if (result.errors.isNotEmpty()) {
57 | showNotification("There were ${result.errors.size} errors parsing log file, see IDE log for details")
58 | }
59 | }
60 | is AlreadyRunning -> showNotification("Analysis is already running")
61 | is DataIsTooLarge -> showNotification("Activity log is too large to process in IDE")
62 | }
63 | }
64 | })
65 | })
66 | add(object : AnAction(Supplier { "Close tool window" }, Cancel) {
67 | override fun actionPerformed(event: AnActionEvent) =
68 | Disposer.dispose(disposable)
69 | })
70 | }
71 |
72 | val actionToolbar = ActionManager.getInstance().createActionToolbar(ActionPlaces.EDITOR_TOOLBAR, actionGroup, true)
73 | actionToolbar.targetComponent = actionToolbar.component
74 | toolWindowPanel.toolbar = actionToolbar.component
75 |
76 | registerToolWindowIn(project, toolWindowId, disposable, toolWindowPanel, RIGHT).show()
77 | }
78 |
79 | private fun createRootComponent(stats: Stats): JComponent =
80 | JPanel().apply {
81 | layout = GridBagLayout()
82 | val bag = GridBag().setDefaultWeightX(1.0).setDefaultWeightY(1.0).setDefaultFill(BOTH)
83 |
84 | add(JTabbedPane().apply {
85 | val fillBoth = GridBag().setDefaultWeightX(1.0).setDefaultWeightY(1.0).setDefaultFill(BOTH).next()
86 |
87 | addTab("Time spent in editor", JPanel().apply {
88 | layout = GridBagLayout()
89 | val table = createTable(listOf("File name", "Time"), stats.secondsInEditorByFile.map { secondsToString(it) })
90 | add(JBScrollPane(table), fillBoth)
91 | })
92 | addTab("Time spent in project", JPanel().apply {
93 | layout = GridBagLayout()
94 | val table = createTable(listOf("Project", "Time"), stats.secondsByProject.map { secondsToString(it) })
95 | add(JBScrollPane(table), fillBoth)
96 | })
97 | addTab("Time spent on tasks", JPanel().apply {
98 | layout = GridBagLayout()
99 | val table = createTable(listOf("Task", "Time"), stats.secondsByTask.map { secondsToString(it) })
100 | add(JBScrollPane(table), fillBoth)
101 | })
102 | addTab("IDE action count", JPanel().apply {
103 | layout = GridBagLayout()
104 | val table = createTable(listOf("IDE Action", "Count"), stats.countByActionId.map { Pair(it.first, it.second.toString()) })
105 | add(JBScrollPane(table), fillBoth)
106 | })
107 | }, bag.nextLine().next().weighty(4.0).anchor(NORTH))
108 |
109 | add(JPanel().apply {
110 | layout = GridBagLayout()
111 | val message = "The stats are based on data from '${stats.dataFile}'.\n\n" +
112 | "To see the time spent in the editor/project or on tasks, enable Activity Tracker -> Settings -> Poll IDE State.\n\n" +
113 | "The time spent on a project includes the time in IDE tool windows and dialogs and, therefore, " +
114 | "it will be greater than the time spent in the IDE editor."
115 | val panelBackground = background
116 | add(JTextArea(message).apply {
117 | isEditable = false
118 | lineWrap = true
119 | wrapStyleWord = true
120 | background = panelBackground
121 | font = StartupUiUtil.labelFont
122 | UIUtil.applyStyle(UIUtil.ComponentStyle.REGULAR, this)
123 | }, GridBag().setDefaultWeightX(1.0).setDefaultWeightY(1.0).nextLine().next().fillCellHorizontally().anchor(NORTH))
124 | }, bag.nextLine().next().weighty(0.5).anchor(SOUTH))
125 | }
126 |
127 | private fun createTable(header: Collection, data: List>): JBTable {
128 | val tableModel = object : DefaultTableModel() {
129 | override fun isCellEditable(row: Int, column: Int) = false
130 | }
131 | header.forEach {
132 | tableModel.addColumn(it)
133 | }
134 | data.forEach {
135 | tableModel.addRow(arrayListOf(it.first, it.second).toArray())
136 | }
137 | val table = JBTable(tableModel).apply {
138 | isStriped = true
139 | setShowGrid(false)
140 | }
141 | registerCopyToClipboardShortCut(table, tableModel)
142 | return table
143 | }
144 |
145 | private fun registerCopyToClipboardShortCut(table: JTable, tableModel: DefaultTableModel) {
146 | val copyKeyStroke = KeymapUtil.getKeyStroke(ActionManager.getInstance().getAction(IdeActions.ACTION_COPY).shortcutSet)
147 | table.registerKeyboardAction(object : AbstractAction() {
148 | override fun actionPerformed(event: ActionEvent) {
149 | val selectedCells = table.selectedRows.map { row ->
150 | 0.until(tableModel.columnCount).map { column ->
151 | tableModel.getValueAt(row, column).toString()
152 | }
153 | }
154 | val content = StringSelection(selectedCells.map { it.joinToString(",") }.joinToString("\n"))
155 | ClipboardSynchronizer.getInstance().setContent(content, content)
156 | }
157 | }, "Copy", copyKeyStroke, JComponent.WHEN_FOCUSED)
158 | }
159 |
160 | private fun registerToolWindowIn(
161 | project: Project,
162 | toolWindowId: String,
163 | disposable: Disposable,
164 | component: JComponent,
165 | location: ToolWindowAnchor = RIGHT
166 | ): ToolWindow {
167 | disposable.whenDisposed {
168 | ToolWindowManager.getInstance(project).unregisterToolWindow(toolWindowId)
169 | }
170 |
171 | val manager = ToolWindowManager.getInstance(project)
172 | if (manager.getToolWindow(toolWindowId) != null) {
173 | manager.unregisterToolWindow(toolWindowId)
174 | }
175 |
176 | val toolWindow = manager.registerToolWindow(toolWindowId, false, location)
177 | toolWindow.contentManager.addContent(ContentFactory.getInstance().createContent(component, "", false))
178 | toolWindow.setIcon(AllIcons.Vcs.History)
179 | return toolWindow
180 | }
181 |
182 | private fun secondsToString(pair: Pair): Pair {
183 | val seconds = pair.second
184 | val formatterSeconds = (seconds / 60).toString() + ":" + String.format("%02d", seconds % 60)
185 | return Pair(pair.first, formatterSeconds)
186 | }
187 | }
--------------------------------------------------------------------------------
/src/main/activitytracker/liveplugin/PluginUtil.kt:
--------------------------------------------------------------------------------
1 | package activitytracker.liveplugin
2 |
3 | import activitytracker.Plugin
4 | import com.intellij.notification.Notification
5 | import com.intellij.notification.NotificationAction
6 | import com.intellij.notification.NotificationType
7 | import com.intellij.notification.NotificationType.*
8 | import com.intellij.notification.Notifications
9 | import com.intellij.openapi.Disposable
10 | import com.intellij.openapi.actionSystem.*
11 | import com.intellij.openapi.application.ApplicationManager
12 | import com.intellij.openapi.application.ModalityState
13 | import com.intellij.openapi.diagnostic.Logger
14 | import com.intellij.openapi.fileEditor.FileEditorManager
15 | import com.intellij.openapi.fileEditor.ex.FileEditorManagerEx
16 | import com.intellij.openapi.keymap.KeymapManager
17 | import com.intellij.openapi.progress.PerformInBackgroundOption
18 | import com.intellij.openapi.progress.PerformInBackgroundOption.ALWAYS_BACKGROUND
19 | import com.intellij.openapi.progress.ProgressIndicator
20 | import com.intellij.openapi.progress.Task
21 | import com.intellij.openapi.project.Project
22 | import com.intellij.openapi.project.ProjectManager
23 | import com.intellij.openapi.project.ProjectManagerListener
24 | import com.intellij.openapi.util.SystemInfo
25 | import com.intellij.openapi.vfs.VirtualFile
26 | import com.intellij.openapi.vfs.VirtualFileManager
27 | import com.intellij.openapi.wm.WindowManager
28 | import com.intellij.util.text.CharArrayUtil
29 | import org.jetbrains.annotations.NonNls
30 | import java.io.PrintWriter
31 | import java.io.StringWriter
32 | import java.util.concurrent.atomic.AtomicReference
33 | import javax.swing.KeyStroke
34 |
35 | fun invokeOnEDT(callback: () -> T): T? {
36 | var result: T? = null
37 | ApplicationManager.getApplication()
38 | .invokeAndWait({ result = callback() }, ModalityState.any())
39 | return result
40 | }
41 |
42 | fun invokeLaterOnEDT(callback: () -> Unit) {
43 | ApplicationManager.getApplication().invokeLater { callback() }
44 | }
45 |
46 | fun showNotification(message: Any?, onLinkClick: () -> Unit = {}) {
47 | invokeLaterOnEDT {
48 | val messageString = asString(message)
49 | val title = ""
50 | val notificationType = INFORMATION
51 | val groupDisplayId = Plugin.pluginId
52 | val notification = Notification(groupDisplayId, title, messageString, notificationType)
53 | .addAction(NotificationAction.create { onLinkClick() })
54 | ApplicationManager.getApplication().messageBus.syncPublisher(Notifications.TOPIC).notify(notification)
55 | }
56 | }
57 |
58 | fun registerAction(
59 | actionId: String,
60 | keyStroke: String = "",
61 | actionGroupId: String? = null,
62 | displayText: String = actionId,
63 | disposable: Disposable? = null,
64 | callback: (AnActionEvent) -> Unit
65 | ): AnAction {
66 | return registerAction(actionId, keyStroke, actionGroupId, displayText, disposable, object : AnAction() {
67 | override fun actionPerformed(event: AnActionEvent) {
68 | callback.invoke(event)
69 | }
70 | })
71 | }
72 |
73 | fun registerAction(
74 | actionId: String,
75 | keyStroke: String = "",
76 | actionGroupId: String? = null,
77 | displayText: String = actionId,
78 | disposable: Disposable? = null,
79 | action: AnAction
80 | ): AnAction {
81 |
82 | val actionManager = ActionManager.getInstance()
83 | val actionGroup = findActionGroup(actionGroupId)
84 |
85 | val alreadyRegistered = (actionManager.getAction(actionId) != null)
86 | if (alreadyRegistered) {
87 | actionGroup?.remove(actionManager.getAction(actionId))
88 | actionManager.unregisterAction(actionId)
89 | }
90 |
91 | assignKeyStroke(actionId, keyStroke)
92 | actionManager.registerAction(actionId, action)
93 | actionGroup?.add(action)
94 | action.templatePresentation.setText(displayText, true)
95 |
96 | if (disposable != null) {
97 | newDisposable(disposable) { unregisterAction(actionId) }
98 | }
99 |
100 | return action
101 | }
102 |
103 | fun unregisterAction(actionId: String, actionGroupId: String? = null) {
104 | val actionManager = ActionManager.getInstance()
105 | val actionGroup = findActionGroup(actionGroupId)
106 |
107 | val alreadyRegistered = (actionManager.getAction(actionId) != null)
108 | if (alreadyRegistered) {
109 | actionGroup?.remove(actionManager.getAction(actionId))
110 | actionManager.unregisterAction(actionId)
111 | }
112 | }
113 |
114 | private fun findActionGroup(actionGroupId: String?): DefaultActionGroup? {
115 | actionGroupId ?: return null
116 | val action = ActionManager.getInstance().getAction(actionGroupId)
117 | return action as? DefaultActionGroup
118 | }
119 |
120 | private fun assignKeyStroke(actionId: String, keyStroke: String, macKeyStroke: String = keyStroke) {
121 | val keymap = KeymapManager.getInstance().activeKeymap
122 | if (!SystemInfo.isMac) {
123 | val shortcut = asKeyboardShortcut(keyStroke) ?: return
124 | keymap.removeAllActionShortcuts(actionId)
125 | keymap.addShortcut(actionId, shortcut)
126 | } else {
127 | val shortcut = asKeyboardShortcut(macKeyStroke) ?: return
128 | keymap.removeAllActionShortcuts(actionId)
129 | keymap.addShortcut(actionId, shortcut)
130 | }
131 | }
132 |
133 | private fun asKeyboardShortcut(keyStroke: String): KeyboardShortcut? {
134 | if (keyStroke.trim().isEmpty()) return null
135 |
136 | val firstKeystroke: KeyStroke?
137 | var secondKeystroke: KeyStroke? = null
138 | if (keyStroke.contains(",")) {
139 | firstKeystroke = KeyStroke.getKeyStroke(keyStroke.substring(0, keyStroke.indexOf(",")).trim())
140 | secondKeystroke = KeyStroke.getKeyStroke(keyStroke.substring((keyStroke.indexOf(",") + 1)).trim())
141 | } else {
142 | firstKeystroke = KeyStroke.getKeyStroke(keyStroke)
143 | }
144 | if (firstKeystroke == null) throw IllegalStateException("Invalid keystroke '$keyStroke'")
145 | return KeyboardShortcut(firstKeystroke, secondKeystroke)
146 | }
147 |
148 |
149 | fun runInBackground(
150 | taskDescription: String = "A task",
151 | canBeCancelledByUser: Boolean = true,
152 | backgroundOption: PerformInBackgroundOption = ALWAYS_BACKGROUND,
153 | task: (ProgressIndicator) -> T,
154 | whenCancelled: () -> Unit = {},
155 | whenDone: (T) -> Unit = {}
156 | ) {
157 | invokeOnEDT {
158 | val result = AtomicReference(null)
159 | object : Task.Backgroundable(null, taskDescription, canBeCancelledByUser, backgroundOption) {
160 | override fun run(indicator: ProgressIndicator) {
161 | result.set(task.invoke(indicator))
162 | }
163 |
164 | override fun onSuccess() {
165 | whenDone.invoke(result.get())
166 | }
167 |
168 | override fun onCancel() {
169 | whenCancelled.invoke()
170 | }
171 | }.queue()
172 | }
173 | }
174 |
175 | fun openInEditor(filePath: String, project: Project) {
176 | openUrlInEditor("file://$filePath", project)
177 | }
178 |
179 | fun openUrlInEditor(fileUrl: String, project: Project): VirtualFile? {
180 | // note that it has to be refreshAndFindFileByUrl (not just findFileByUrl) otherwise VirtualFile might be null
181 | val virtualFile = VirtualFileManager.getInstance().refreshAndFindFileByUrl(fileUrl) ?: return null
182 | FileEditorManager.getInstance(project).openFile(virtualFile, true, true)
183 | return virtualFile
184 | }
185 |
186 | fun registerProjectListener(disposable: Disposable, onEachProject: (Project) -> Unit) {
187 | registerProjectListener(disposable, object : ProjectManagerListener {
188 | override fun projectOpened(project: Project) {
189 | onEachProject(project)
190 | }
191 | })
192 | ProjectManager.getInstance().openProjects.forEach { project ->
193 | onEachProject(project)
194 | }
195 | }
196 |
197 | fun registerProjectListener(disposable: Disposable, listener: ProjectManagerListener) {
198 | val connection = ApplicationManager.getApplication().messageBus.connect(disposable)
199 | connection.subscribe(ProjectManager.TOPIC, listener)
200 | }
201 |
202 | fun Project.currentVirtualFile(): VirtualFile? =
203 | FileEditorManagerEx.getInstanceEx(this).currentFile
204 |
205 | val logger = Logger.getInstance("LivePlugin")
206 |
207 | fun log(message: Any?, notificationType: NotificationType = INFORMATION) {
208 | val s = (message as? Throwable)?.toString() ?: asString(message)
209 | when (notificationType) {
210 | INFORMATION, IDE_UPDATE -> logger.info(s)
211 | WARNING -> logger.warn(s)
212 | ERROR -> logger.error(s)
213 | }
214 | }
215 |
216 | fun asString(message: Any?): String = when {
217 | message?.javaClass?.isArray == true -> (message as Array<*>).contentToString()
218 | message is Throwable -> unscrambleThrowable(message)
219 | else -> message.toString()
220 | }
221 |
222 | fun unscrambleThrowable(throwable: Throwable): String {
223 | val writer = StringWriter()
224 | throwable.printStackTrace(PrintWriter(writer))
225 | return Unscramble.normalizeText(writer.buffer.toString())
226 | }
227 |
228 | private object Unscramble {
229 | fun normalizeText(@NonNls text: String): String {
230 | val lines = text
231 | .replace("(\\S[ \\t\\x0B\\f\\r]+)(at\\s+)".toRegex(), "$1\n$2")
232 | .split("\n".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
233 |
234 | var first = true
235 | var inAuxInfo = false
236 | val builder = StringBuilder(text.length)
237 | for (line in lines) {
238 | if (!inAuxInfo && (line.startsWith("JNI global references") || line.trim { it <= ' ' } == "Heap")) {
239 | builder.append("\n")
240 | inAuxInfo = true
241 | }
242 | if (inAuxInfo) {
243 | builder.append(trimSuffix(line)).append("\n")
244 | continue
245 | }
246 | if (!first && mustHaveNewLineBefore(line)) {
247 | builder.append("\n")
248 | if (line.startsWith("\"")) builder.append("\n") // Additional line break for thread names
249 | }
250 | first = false
251 | val i = builder.lastIndexOf("\n")
252 | val lastLine = if (i == -1) builder else builder.subSequence(i + 1, builder.length)
253 | if (lastLine.toString().matches("\\s*at".toRegex()) && !line.matches("\\s+.*".toRegex())) builder.append(" ") // separate 'at' from file name
254 | builder.append(trimSuffix(line))
255 | }
256 | return builder.toString()
257 | }
258 |
259 | private fun mustHaveNewLineBefore(line: String): Boolean {
260 | var s = line
261 | val nonWs = CharArrayUtil.shiftForward(s, 0, " \t")
262 | if (nonWs < s.length) {
263 | s = s.substring(nonWs)
264 | }
265 | if (s.startsWith("at")) return true // Start of the new stack frame entry
266 | if (s.startsWith("Caused")) return true // Caused by message
267 | if (s.startsWith("- locked")) return true // "Locked a monitor" logging
268 | if (s.startsWith("- waiting")) return true // "Waiting for monitor" logging
269 | if (s.startsWith("- parking to wait")) return true
270 | if (s.startsWith("java.lang.Thread.State")) return true
271 | return s.startsWith("\"") // Start of the new thread (thread name)
272 |
273 | }
274 |
275 | private fun trimSuffix(line: String): String {
276 | var len = line.length
277 | while (0 < len && line[len - 1] <= ' ') len--
278 | return if (len < line.length) line.substring(0, len) else line
279 | }
280 | }
281 |
282 | fun updateWidget(widgetId: String) {
283 | WindowManager.getInstance().allProjectFrames.forEach {
284 | it.statusBar?.updateWidget(widgetId)
285 | }
286 | }
287 |
--------------------------------------------------------------------------------
/src/main/activitytracker/PluginUI.kt:
--------------------------------------------------------------------------------
1 | package activitytracker
2 |
3 | import activitytracker.EventAnalyzer.Result.*
4 | import activitytracker.Plugin.Companion.pluginId
5 | import activitytracker.liveplugin.*
6 | import com.intellij.CommonBundle
7 | import com.intellij.ide.BrowserUtil
8 | import com.intellij.ide.actions.RevealFileAction
9 | import com.intellij.ide.ui.IdeUiService
10 | import com.intellij.openapi.Disposable
11 | import com.intellij.openapi.actionSystem.ActionUpdateThread.EDT
12 | import com.intellij.openapi.actionSystem.AnAction
13 | import com.intellij.openapi.actionSystem.AnActionEvent
14 | import com.intellij.openapi.actionSystem.DataContext
15 | import com.intellij.openapi.actionSystem.DefaultActionGroup
16 | import com.intellij.openapi.actionSystem.ex.CheckboxAction
17 | import com.intellij.openapi.diagnostic.Logger
18 | import com.intellij.openapi.extensions.Extensions
19 | import com.intellij.openapi.extensions.LoadingOrder
20 | import com.intellij.openapi.project.DumbAware
21 | import com.intellij.openapi.project.Project
22 | import com.intellij.openapi.ui.Messages
23 | import com.intellij.openapi.ui.Messages.showOkCancelDialog
24 | import com.intellij.openapi.ui.popup.JBPopupFactory
25 | import com.intellij.openapi.ui.popup.JBPopupFactory.ActionSelectionAid.SPEEDSEARCH
26 | import com.intellij.openapi.wm.StatusBar
27 | import com.intellij.openapi.wm.StatusBarWidget
28 | import com.intellij.openapi.wm.StatusBarWidgetFactory
29 | import com.intellij.ui.awt.RelativePoint
30 | import com.intellij.util.Consumer
31 | import java.awt.Component
32 | import java.awt.Point
33 | import java.awt.event.MouseEvent
34 |
35 | class PluginUI(
36 | private val plugin: Plugin,
37 | private val trackerLog: TrackerLog,
38 | private val eventAnalyzer: EventAnalyzer,
39 | private val parentDisposable: Disposable
40 | ) {
41 | private val log = Logger.getInstance(PluginUI::class.java)
42 | private var state = Plugin.State.defaultValue
43 | private val actionGroup by lazy { createActionGroup() }
44 | private val widgetId = "Activity Tracker Widget"
45 |
46 | fun init(): PluginUI {
47 | plugin.setUI(this)
48 | registerWidget(parentDisposable)
49 | registerPopup(parentDisposable)
50 | eventAnalyzer.runner = { task ->
51 | runInBackground("Analyzing activity log", task = { task() })
52 | }
53 | return this
54 | }
55 |
56 | fun update(state: Plugin.State) {
57 | this.state = state
58 | updateWidget(widgetId)
59 | }
60 |
61 | private fun registerPopup(parentDisposable: Disposable) {
62 | registerAction("$pluginId-Popup", "ctrl shift alt O", "", "Activity Tracker Popup", parentDisposable) {
63 | val project = it.project
64 | if (project != null) {
65 | createListPopup(it.dataContext).showCenteredInCurrentWindow(project)
66 | }
67 | }
68 | }
69 |
70 | private fun registerWidget(parentDisposable: Disposable) {
71 | val presentation = object : StatusBarWidget.TextPresentation {
72 | override fun getText() = "Activity tracker: " + (if (state.isTracking) "on" else "off")
73 | override fun getAlignment() = Component.CENTER_ALIGNMENT
74 | override fun getTooltipText() = "Click to open menu"
75 | override fun getClickConsumer() = Consumer { mouseEvent: MouseEvent ->
76 | @Suppress("UnstableApiUsage")
77 | val popup = createListPopup(IdeUiService.getInstance().createUiDataContext(mouseEvent.component))
78 | val point = Point(0, -popup.content.preferredSize.height)
79 | popup.show(RelativePoint(mouseEvent.component, point))
80 | }
81 | }
82 |
83 | val extensionPoint = StatusBarWidgetFactory.EP_NAME.getPoint { Extensions.getRootArea() }
84 | val factory = object : StatusBarWidgetFactory {
85 | override fun getId() = widgetId
86 | override fun getDisplayName() = widgetId
87 | override fun isAvailable(project: Project) = true
88 | override fun canBeEnabledOn(statusBar: StatusBar) = true
89 | override fun createWidget(project: Project) = object : StatusBarWidget, StatusBarWidget.Multiframe {
90 | override fun ID() = widgetId
91 | override fun getPresentation() = presentation
92 | override fun install(statusBar: StatusBar) = statusBar.updateWidget(ID())
93 | override fun copy() = this
94 | override fun dispose() {}
95 | }
96 |
97 | override fun disposeWidget(widget: StatusBarWidget) {}
98 | }
99 | @Suppress("UnstableApiUsage")
100 | extensionPoint.registerExtension(factory, LoadingOrder.before("positionWidget"), parentDisposable)
101 | }
102 |
103 | private fun createListPopup(dataContext: DataContext) =
104 | JBPopupFactory.getInstance()
105 | .createActionGroupPopup("Activity Tracker", actionGroup, dataContext, SPEEDSEARCH, true)
106 |
107 | private fun createActionGroup(): DefaultActionGroup {
108 | val toggleTracking = object : AnAction(), DumbAware {
109 | override fun actionPerformed(event: AnActionEvent) = plugin.toggleTracking()
110 | override fun getActionUpdateThread() = EDT
111 | override fun update(event: AnActionEvent) {
112 | event.presentation.text = if (state.isTracking) "Stop Tracking" else "Start Tracking"
113 | }
114 | }
115 | val togglePollIdeState = object : CheckboxAction("Poll IDE state") {
116 | override fun isSelected(event: AnActionEvent) = state.pollIdeState
117 | override fun setSelected(event: AnActionEvent, value: Boolean) = plugin.enablePollIdeState(value)
118 | override fun getActionUpdateThread() = EDT
119 | }
120 | val toggleTrackActions = object : CheckboxAction("Track IDE actions") {
121 | override fun isSelected(event: AnActionEvent) = state.trackIdeActions
122 | override fun setSelected(event: AnActionEvent, value: Boolean) = plugin.enableTrackIdeActions(value)
123 | override fun getActionUpdateThread() = EDT
124 | }
125 | val toggleTrackKeyboard = object : CheckboxAction("Track keyboard") {
126 | override fun isSelected(event: AnActionEvent) = state.trackKeyboard
127 | override fun setSelected(event: AnActionEvent, value: Boolean) = plugin.enableTrackKeyboard(value)
128 | override fun getActionUpdateThread() = EDT
129 | }
130 | val trackKeyboardReleased = object : CheckboxAction("Track keyboard (key released)") {
131 | override fun isSelected(event: AnActionEvent) = state.trackKeyboardReleased
132 | override fun setSelected(event: AnActionEvent, value: Boolean) = plugin.enableTrackKeyboardReleased(value)
133 | override fun getActionUpdateThread() = EDT
134 | }
135 | val toggleTrackMouse = object : CheckboxAction("Track mouse") {
136 | override fun isSelected(event: AnActionEvent) = state.trackMouse
137 | override fun setSelected(event: AnActionEvent, value: Boolean) = plugin.enableTrackMouse(value)
138 | override fun getActionUpdateThread() = EDT
139 | }
140 | val openLogInIde = object : AnAction("Open in IDE"), DumbAware {
141 | override fun actionPerformed(event: AnActionEvent) = plugin.openTrackingLogFile(event.project)
142 | }
143 | val openLogFolder = object : AnAction("Open in File Manager"), DumbAware {
144 | override fun actionPerformed(event: AnActionEvent) = plugin.openTrackingLogFolder()
145 | }
146 | val showStatistics = object : AnAction("Show Stats"), DumbAware {
147 | override fun actionPerformed(event: AnActionEvent) {
148 | val project = event.project ?: return
149 | if (trackerLog.isTooLargeToProcess()) {
150 | return showNotification("Current activity log is too large to process in IDE")
151 | }
152 | eventAnalyzer.analyze(whenDone = { result ->
153 | invokeLaterOnEDT {
154 | when (result) {
155 | is Ok -> {
156 | StatsToolWindow.showIn(project, result.stats, eventAnalyzer, parentDisposable)
157 | if (result.errors.isNotEmpty()) {
158 | showNotification("There were ${result.errors.size} errors parsing log file. See IDE log for details")
159 | result.errors.forEach { log.warn(it.first, it.second) }
160 | }
161 | }
162 | is AlreadyRunning -> showNotification("Analysis is already running")
163 | is DataIsTooLarge -> showNotification("Activity log is too large to process in IDE")
164 | }
165 | }
166 | })
167 | }
168 | }
169 | val rollCurrentLog = object : AnAction("Roll Tracking Log"), DumbAware {
170 | override fun actionPerformed(event: AnActionEvent) {
171 | val userAnswer = showOkCancelDialog(
172 | event.project,
173 | "Roll tracking log file?\nCurrent log will be moved into new file.",
174 | "Activity Tracker",
175 | CommonBundle.getOkButtonText(),
176 | CommonBundle.getCancelButtonText(),
177 | Messages.getQuestionIcon()
178 | )
179 | if (userAnswer != Messages.OK) return
180 |
181 | val rolledFile = trackerLog.rollLog()
182 | showNotification("Rolled tracking log into ${rolledFile.name}") {
183 | RevealFileAction.openFile(rolledFile)
184 | }
185 | }
186 | }
187 | val clearCurrentLog = object : AnAction("Clear Tracking Log"), DumbAware {
188 | override fun actionPerformed(event: AnActionEvent) {
189 | val userAnswer = showOkCancelDialog(
190 | event.project,
191 | "Clear current tracking log file?\n(This operation cannot be undone.)",
192 | "Activity Tracker",
193 | CommonBundle.getOkButtonText(),
194 | CommonBundle.getCancelButtonText(),
195 | Messages.getQuestionIcon()
196 | )
197 | if (userAnswer != Messages.OK) return
198 |
199 | val wasCleared = trackerLog.clearLog()
200 | if (wasCleared) showNotification("Tracking log was cleared")
201 | }
202 | }
203 | val openHelp = object : AnAction("Help"), DumbAware {
204 | override fun actionPerformed(event: AnActionEvent) = BrowserUtil.open("https://github.com/dkandalov/activity-tracker#help")
205 | }
206 |
207 | registerAction("Start/Stop Activity Tracking", action = toggleTracking)
208 | registerAction("Roll Tracking Log", action = rollCurrentLog)
209 | registerAction("Clear Tracking Log", action = clearCurrentLog)
210 | // TODO register other actions
211 |
212 | return DefaultActionGroup().apply {
213 | add(toggleTracking)
214 | add(DefaultActionGroup("Current Log", true).apply {
215 | add(showStatistics)
216 | add(openLogInIde)
217 | add(openLogFolder)
218 | addSeparator()
219 | add(rollCurrentLog)
220 | add(clearCurrentLog)
221 | })
222 | addSeparator()
223 | add(DefaultActionGroup("Settings", true).apply {
224 | add(toggleTrackActions)
225 | add(togglePollIdeState)
226 | add(toggleTrackKeyboard)
227 | add(trackKeyboardReleased)
228 | add(toggleTrackMouse)
229 | })
230 | add(openHelp)
231 | }
232 | }
233 | }
--------------------------------------------------------------------------------
/src/main/activitytracker/tracking/ActivityTracker.kt:
--------------------------------------------------------------------------------
1 | package activitytracker.tracking
2 |
3 | import activitytracker.TrackerEvent
4 | import activitytracker.TrackerEvent.Type.Action
5 | import activitytracker.TrackerEvent.Type.Duration
6 | import activitytracker.TrackerEvent.Type.Execution
7 | import activitytracker.TrackerEvent.Type.IdeState
8 | import activitytracker.TrackerEvent.Type.VcsAction
9 | import activitytracker.TrackerLog
10 | import activitytracker.liveplugin.VcsActions
11 | import activitytracker.liveplugin.VcsActions.Companion.registerVcsListener
12 | import activitytracker.liveplugin.currentVirtualFile
13 | import activitytracker.liveplugin.invokeOnEDT
14 | import activitytracker.liveplugin.log
15 | import activitytracker.liveplugin.newDisposable
16 | import activitytracker.liveplugin.whenDisposed
17 | import com.intellij.concurrency.JobScheduler
18 | import com.intellij.execution.ExecutionListener
19 | import com.intellij.execution.ExecutionManager
20 | import com.intellij.execution.process.BaseProcessHandler
21 | import com.intellij.execution.process.ProcessHandler
22 | import com.intellij.execution.runners.ExecutionEnvironment
23 | import com.intellij.ide.IdeEventQueue
24 | import com.intellij.notification.NotificationType
25 | import com.intellij.openapi.Disposable
26 | import com.intellij.openapi.actionSystem.ActionManager
27 | import com.intellij.openapi.actionSystem.AnAction
28 | import com.intellij.openapi.actionSystem.AnActionEvent
29 | import com.intellij.openapi.actionSystem.ex.AnActionListener
30 | import com.intellij.openapi.application.ApplicationManager
31 | import com.intellij.openapi.editor.Editor
32 | import com.intellij.openapi.editor.impl.EditorComponentImpl
33 | import com.intellij.openapi.fileEditor.ex.FileEditorManagerEx
34 | import com.intellij.openapi.project.Project
35 | import com.intellij.openapi.util.Disposer
36 | import com.intellij.openapi.wm.IdeFocusManager
37 | import com.intellij.openapi.wm.ToolWindowManager
38 | import com.intellij.openapi.wm.ex.WindowManagerEx
39 | import com.intellij.openapi.wm.impl.IdeFrameImpl
40 | import com.intellij.util.SystemProperties
41 | import org.joda.time.DateTime
42 | import java.awt.AWTEvent
43 | import java.awt.Component
44 | import java.awt.event.KeyEvent
45 | import java.awt.event.MouseEvent
46 | import java.awt.event.MouseWheelEvent
47 | import java.lang.System.currentTimeMillis
48 | import java.util.concurrent.TimeUnit.MILLISECONDS
49 | import javax.swing.JDialog
50 |
51 | class ActivityTracker(
52 | private val compilationTracker: CompilationTracker,
53 | private val psiPathProvider: PsiPathProvider,
54 | private val taskNameProvider: TaskNameProvider,
55 | private val trackerLog: TrackerLog,
56 | private val parentDisposable: Disposable,
57 | private val logTrackerCallDuration: Boolean = false
58 | ) {
59 | private var trackingDisposable: Disposable? = null
60 | private val trackerCallDurations: MutableList = mutableListOf()
61 |
62 | fun startTracking(config: Config) {
63 | if (trackingDisposable != null) return
64 | trackingDisposable = newDisposable(parentDisposable)
65 |
66 | if (config.pollIdeState) {
67 | startPollingIdeState(trackerLog, trackingDisposable!!, config.pollIdeStateMs)
68 | }
69 | if (config.trackIdeActions) {
70 | startActionListener(trackerLog, trackingDisposable!!)
71 | startExecutionListener(trackerLog, trackingDisposable!!)
72 | compilationTracker.startActionListener(trackingDisposable!!) { eventType, originalEventData ->
73 | invokeOnEDT { trackerLog.append(captureIdeState(eventType, originalEventData)) }
74 | }
75 | }
76 | if (config.trackKeyboard || config.trackMouse) {
77 | startAWTEventListener(trackerLog, trackingDisposable!!, config.trackKeyboard, config.trackKeyboardReleased, config.trackMouse, config.mouseMoveEventsThresholdMs)
78 | }
79 | }
80 |
81 | fun stopTracking() {
82 | if (trackingDisposable != null) {
83 | Disposer.dispose(trackingDisposable!!)
84 | trackingDisposable = null
85 | }
86 | }
87 |
88 | private fun startPollingIdeState(trackerLog: TrackerLog, trackingDisposable: Disposable, frequencyMs: Long) {
89 | val runnable = Runnable {
90 | // It has to be invokeOnEDT() method so that it's still triggered when IDE dialog window is opened (e.g. override or project settings).
91 | invokeOnEDT {
92 | trackerLog.append(captureIdeState(IdeState, ""))
93 | trackerLog.append(trackerCallDurationsEvent())
94 | }
95 | }
96 |
97 | val nextSecondStartMs = 1000 - (currentTimeMillis() % 1000)
98 | val future = JobScheduler.getScheduler().scheduleWithFixedDelay(runnable, nextSecondStartMs, frequencyMs, MILLISECONDS)
99 | trackingDisposable.whenDisposed {
100 | future.cancel(true)
101 | }
102 | }
103 |
104 | private fun trackerCallDurationsEvent(): TrackerEvent? {
105 | if (!logTrackerCallDuration || trackerCallDurations.size < 10) return null
106 |
107 | val time = DateTime.now()
108 | val userName = SystemProperties.getUserName()
109 | val durations = trackerCallDurations.joinToString(",")
110 | trackerCallDurations.clear()
111 | return TrackerEvent(time, userName, Duration, durations, "", "", "", "", -1, -1, "")
112 | }
113 |
114 | private fun startAWTEventListener(
115 | trackerLog: TrackerLog, parentDisposable: Disposable, trackKeyboard: Boolean, trackKeyboardReleased: Boolean,
116 | trackMouse: Boolean, mouseMoveEventsThresholdMs: Long
117 | ) {
118 | var lastMouseMoveTimestamp = 0L
119 | IdeEventQueue.getInstance().addPostprocessor({ awtEvent: AWTEvent ->
120 | if (trackMouse && awtEvent is MouseEvent && awtEvent.id == MouseEvent.MOUSE_CLICKED) {
121 | trackerLog.append(captureIdeState(
122 | TrackerEvent.Type.MouseEvent,
123 | "click:${awtEvent.button}:${awtEvent.clickCount}:${awtEvent.modifiersEx}"
124 | ))
125 | }
126 | if (trackMouse && awtEvent is MouseEvent && awtEvent.id == MouseEvent.MOUSE_MOVED) {
127 | val now = currentTimeMillis()
128 | if (now - lastMouseMoveTimestamp > mouseMoveEventsThresholdMs) {
129 | trackerLog.append(captureIdeState(
130 | TrackerEvent.Type.MouseEvent,
131 | "move:${awtEvent.x}:${awtEvent.y}:${awtEvent.modifiersEx}"
132 | ))
133 | lastMouseMoveTimestamp = now
134 | }
135 | }
136 | if (trackMouse && awtEvent is MouseWheelEvent && awtEvent.id == MouseEvent.MOUSE_WHEEL) {
137 | val now = currentTimeMillis()
138 | if (now - lastMouseMoveTimestamp > mouseMoveEventsThresholdMs) {
139 | trackerLog.append(captureIdeState(
140 | TrackerEvent.Type.MouseEvent,
141 | "wheel:${awtEvent.wheelRotation}:${awtEvent.modifiersEx}"
142 | ))
143 | lastMouseMoveTimestamp = now
144 | }
145 | }
146 | if (trackKeyboard && awtEvent is KeyEvent && awtEvent.id == KeyEvent.KEY_PRESSED) {
147 | trackerLog.append(captureIdeState(
148 | TrackerEvent.Type.KeyEvent,
149 | "${awtEvent.id}:${awtEvent.keyChar.code}:${awtEvent.keyCode}:${awtEvent.modifiersEx}"
150 | ))
151 | }
152 | if (trackKeyboard && trackKeyboardReleased && awtEvent is KeyEvent && awtEvent.id == KeyEvent.KEY_RELEASED) {
153 | trackerLog.append(captureIdeState(
154 | TrackerEvent.Type.KeyEvent,
155 | "${awtEvent.id}:${awtEvent.keyChar.code}:${awtEvent.keyCode}:${awtEvent.modifiersEx}"
156 | ))
157 | }
158 | false
159 | }, parentDisposable)
160 | }
161 |
162 | private fun startActionListener(trackerLog: TrackerLog, parentDisposable: Disposable) {
163 | val actionListener = object: AnActionListener {
164 | override fun beforeActionPerformed(anAction: AnAction, event: AnActionEvent) {
165 | // Track action in "before" callback because otherwise timestamp of the action can be wrong
166 | // (e.g. commit action shows dialog and finishes only after the dialog is closed).
167 | // Action id can be null e.g. on ctrl+o action (class com.intellij.openapi.ui.impl.DialogWrapperPeerImpl$AnCancelAction).
168 | val actionId = ActionManager.getInstance().getId(anAction) ?: return
169 | trackerLog.append(captureIdeState(Action, actionId))
170 | }
171 | }
172 | ApplicationManager.getApplication()
173 | .messageBus.connect(parentDisposable)
174 | .subscribe(AnActionListener.TOPIC, actionListener)
175 |
176 | // Use custom listener for VCS because listening to normal IDE actions
177 | // doesn't notify about actual commits but only about opening commit dialog (see VcsActions source code for details).
178 | registerVcsListener(parentDisposable, object: VcsActions.Listener {
179 | override fun onVcsCommit() {
180 | invokeOnEDT { trackerLog.append(captureIdeState(VcsAction, "Commit")) }
181 | }
182 |
183 | override fun onVcsUpdate() {
184 | invokeOnEDT { trackerLog.append(captureIdeState(VcsAction, "Update")) }
185 | }
186 |
187 | override fun onVcsPush() {
188 | invokeOnEDT { trackerLog.append(captureIdeState(VcsAction, "Push")) }
189 | }
190 | })
191 | }
192 |
193 | private fun startExecutionListener(trackerLog: TrackerLog, parentDisposable: Disposable) {
194 | val executionListener = object : ExecutionListener {
195 | override fun processStarting(executorId: String, env: ExecutionEnvironment, handler: ProcessHandler) {
196 | invokeOnEDT {
197 | val commandLine = (handler as? BaseProcessHandler<*>)?.commandLine ?: ""
198 | trackerLog.append(captureIdeState(Execution, "$executorId:'${env.runProfile}':$commandLine"))
199 | }
200 | }
201 | }
202 | ApplicationManager.getApplication()
203 | .messageBus.connect(parentDisposable)
204 | .subscribe(ExecutionManager.EXECUTION_TOPIC, executionListener)
205 | }
206 |
207 | private fun captureIdeState(eventType: TrackerEvent.Type, originalEventData: String): TrackerEvent? {
208 | val start = currentTimeMillis()
209 | return try {
210 | var eventData = originalEventData
211 | if (eventType == IdeState) {
212 | eventData = "Inactive"
213 | }
214 | val time = DateTime.now()
215 | val userName = SystemProperties.getUserName()
216 |
217 | val ideFocusManager = IdeFocusManager.getGlobalInstance()
218 | val focusOwner = ideFocusManager.focusOwner
219 |
220 | // this might also work: ApplicationManager.application.isActive(), ApplicationActivationListener
221 | val window = WindowManagerEx.getInstanceEx().mostRecentFocusedWindow
222 | ?: return TrackerEvent.ideNotInFocus(time, userName, eventType, eventData)
223 |
224 | var ideHasFocus = window.isActive
225 | if (!ideHasFocus) {
226 | @Suppress("UnstableApiUsage")
227 | val ideFrame = findParentComponent(focusOwner) { it is IdeFrameImpl }
228 | ideHasFocus = ideFrame != null && ideFrame.isActive
229 | }
230 | if (!ideHasFocus) return TrackerEvent.ideNotInFocus(time, userName, eventType, eventData)
231 |
232 | // use "lastFocusedFrame" to be able to obtain project in cases when some dialog is open (e.g. "override" or "project settings")
233 | val project = ideFocusManager.lastFocusedFrame?.project
234 | if (eventType == IdeState && project?.isDefault != false) {
235 | eventData = "NoProject"
236 | }
237 | if (project == null || project.isDefault) return TrackerEvent.ideNotInFocus(time, userName, eventType, eventData)
238 |
239 | if (eventType == IdeState) {
240 | eventData = "Active"
241 | }
242 |
243 | // Check for JDialog before EditorComponentImpl because dialog can belong to editor.
244 | val focusOwnerId = when {
245 | findParentComponent(focusOwner) { it is JDialog } != null -> "Dialog"
246 | findParentComponent(focusOwner) { it is EditorComponentImpl } != null -> "Editor"
247 | else -> ToolWindowManager.getInstance(project).activeToolWindowId ?: "Popup"
248 | }
249 |
250 | var filePath = ""
251 | var psiPath = ""
252 | var line = -1
253 | var column = -1
254 | val editor = currentEditorIn(project)
255 | if (editor != null) {
256 | // Keep full file name because projects and libraries might have files with the same names/partial paths.
257 | val file = project.currentVirtualFile()
258 | filePath = file?.path ?: ""
259 | line = editor.caretModel.logicalPosition.line
260 | column = editor.caretModel.logicalPosition.column
261 | psiPath = psiPathProvider.psiPath(project, editor) ?: ""
262 | }
263 | val task = taskNameProvider.taskName(project)
264 |
265 | TrackerEvent(time, userName, eventType, eventData, project.name, focusOwnerId, filePath, psiPath, line, column, task)
266 |
267 | } catch (e: Exception) {
268 | log(e, NotificationType.ERROR)
269 | null
270 | } finally {
271 | if (logTrackerCallDuration) {
272 | trackerCallDurations.add(currentTimeMillis() - start)
273 | }
274 | }
275 | }
276 |
277 | @Suppress("UNCHECKED_CAST")
278 | private fun findParentComponent(component: Component?, matches: (Component) -> Boolean): T? =
279 | when {
280 | component == null -> null
281 | matches(component) -> component as T?
282 | else -> findParentComponent(component.parent, matches)
283 | }
284 |
285 | private fun currentEditorIn(project: Project): Editor? =
286 | FileEditorManagerEx.getInstanceEx(project).selectedTextEditor
287 |
288 | data class Config(
289 | val pollIdeState: Boolean,
290 | val pollIdeStateMs: Long,
291 | val trackIdeActions: Boolean,
292 | val trackKeyboard: Boolean,
293 | val trackKeyboardReleased: Boolean,
294 | val trackMouse: Boolean,
295 | val mouseMoveEventsThresholdMs: Long
296 | )
297 | }
298 |
--------------------------------------------------------------------------------