diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ffbb12..b5d6832 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Project name is now correctly displayed + for projects in directories with different names. + +### Changed +- Coding time no longer increases while inactive. +- Hostname now works regardless of DNS settings.\* + +*Works on Windows, macOS, and any OS with `gethostname()`. + ## [0.2.0] - 2022-08-04 ### Added diff --git a/build.gradle.kts b/build.gradle.kts index e19f8d7..8c92ac7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,11 +7,11 @@ plugins { // Java support id("java") // Kotlin support - id("org.jetbrains.kotlin.jvm") version "1.7.10" + id("org.jetbrains.kotlin.jvm") version "1.8.10" // Gradle IntelliJ Plugin - id("org.jetbrains.intellij") version "1.7.0" + id("org.jetbrains.intellij") version "1.13.0" // Gradle Changelog Plugin - id("org.jetbrains.changelog") version "1.3.1" + id("org.jetbrains.changelog") version "2.0.0" // Gradle Qodana Plugin id("org.jetbrains.qodana") version "0.1.13" } @@ -20,7 +20,8 @@ group = properties("pluginGroup") version = properties("pluginVersion") dependencies { - implementation("com.google.code.gson:gson:2.9.0") + implementation("com.google.code.gson:gson:2.10.1") + implementation("com.kstruct:gethostname4j:1.0.0") } // Configure project's dependencies @@ -71,7 +72,7 @@ tasks { patchPluginXml { version.set(properties("pluginVersion")) sinceBuild.set(properties("pluginSinceBuild")) - untilBuild.set(properties("pluginUntilBuild")) + // untilBuild.set(properties("pluginUntilBuild")) // Extract the section from README.md and provide for the plugin's manifest pluginDescription.set( diff --git a/gradle.properties b/gradle.properties index 4c156df..8dd3b88 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,12 +4,11 @@ pluginGroup = fi.testaustime.plugin_intellij pluginName = testaustime-intellij # SemVer format -> https://semver.org -pluginVersion = 0.2.0 +pluginVersion = 0.3.0 # See https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html # for insight into build numbers and IntelliJ Platform versions. pluginSinceBuild = 212.4746.92 -pluginUntilBuild = 282.* # IntelliJ Platform Properties -> https://github.com/JetBrains/gradle-intellij-plugin#intellij-platform-properties @@ -24,7 +23,7 @@ platformPlugins = org.jetbrains.kotlin javaVersion = 17 # Gradle Releases -> https://github.com/gradle/gradle/releases -gradleVersion = 7.5 +gradleVersion = 8.0.1 # Opt-out flag for bundling Kotlin standard library. # See https://plugins.jetbrains.com/docs/intellij/kotlin.html#kotlin-standard-library for details. diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 41dfb87..f72df95 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/src/main/kotlin/fi/testaustime/plugin_intellij/network/models/ActivityPostPayload.kt b/src/main/kotlin/fi/testaustime/plugin_intellij/network/models/ActivityPostPayload.kt index 58ae509..8ab83ef 100644 --- a/src/main/kotlin/fi/testaustime/plugin_intellij/network/models/ActivityPostPayload.kt +++ b/src/main/kotlin/fi/testaustime/plugin_intellij/network/models/ActivityPostPayload.kt @@ -1,10 +1,24 @@ package fi.testaustime.plugin_intellij.network.models import com.google.gson.annotations.SerializedName +import com.intellij.openapi.project.Project +import fi.testaustime.plugin_intellij.utils.ContextInformation +import fi.testaustime.plugin_intellij.utils.ContextInformation.getFriendlyName -class ActivityPostPayload( +data class ActivityPostPayload( + @SerializedName("hostname") val hostname: String?, @SerializedName("language") val programmingLanguage: String?, - @SerializedName("hostname") val host: String, - @SerializedName("editor_name") val IDEName: String, @SerializedName("project_name") val projectName: String, -) \ No newline at end of file + @SerializedName("editor_name") val application: String, +) { + companion object { + fun fromProject(project: Project): ActivityPostPayload { + return ActivityPostPayload( + hostname = ContextInformation.getHostname(), + programmingLanguage = ContextInformation.getProgrammingLanguage(), + application = ContextInformation.getApplicationName(), + projectName = project.getFriendlyName() + ) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/fi/testaustime/plugin_intellij/services/CaretMoveListener.kt b/src/main/kotlin/fi/testaustime/plugin_intellij/services/CaretMoveListener.kt new file mode 100755 index 0000000..f83a25c --- /dev/null +++ b/src/main/kotlin/fi/testaustime/plugin_intellij/services/CaretMoveListener.kt @@ -0,0 +1,34 @@ +package fi.testaustime.plugin_intellij.services + +import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.editor.event.CaretEvent +import com.intellij.openapi.editor.event.EditorFactoryEvent +import com.intellij.openapi.editor.event.EditorFactoryListener +import java.time.Duration +import java.time.Duration.ofSeconds +import java.time.Instant +import java.time.Instant.now + +class CaretMoveListener(service: TestaustimeProjectService) : com.intellij.openapi.editor.event.CaretListener { + companion object { + val TIMEOUT: Duration = ofSeconds(30) + } + + init { + EditorFactory.getInstance().addEditorFactoryListener(object : EditorFactoryListener { + override fun editorCreated(event: EditorFactoryEvent) { + event.editor.caretModel.addCaretListener(this@CaretMoveListener) + } + }, service) + } + + private var lastActive: Instant = now() + + override fun caretPositionChanged(event: CaretEvent) { + lastActive = now() + } + + fun isActive(): Boolean { + return Duration.between(lastActive, now()) <= TIMEOUT + } +} \ No newline at end of file diff --git a/src/main/kotlin/fi/testaustime/plugin_intellij/services/TestaustimeApplicationService.kt b/src/main/kotlin/fi/testaustime/plugin_intellij/services/TestaustimeApplicationService.kt index 0b62b30..7b4dce0 100644 --- a/src/main/kotlin/fi/testaustime/plugin_intellij/services/TestaustimeApplicationService.kt +++ b/src/main/kotlin/fi/testaustime/plugin_intellij/services/TestaustimeApplicationService.kt @@ -1,56 +1,54 @@ package fi.testaustime.plugin_intellij.services -import com.intellij.ide.DataManager import com.intellij.notification.NotificationType.ERROR +import com.intellij.openapi.actionSystem.CommonDataKeys import com.intellij.openapi.actionSystem.PlatformDataKeys -import com.intellij.openapi.application.ApplicationInfo import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ModalityState import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.editor.Editor import com.intellij.openapi.project.Project -import com.intellij.psi.util.PsiUtilBase import com.intellij.util.concurrency.AppExecutorUtil -import fi.testaustime.plugin_intellij.TestaustimeBundle import fi.testaustime.plugin_intellij.TestaustimeBundle.message import fi.testaustime.plugin_intellij.configuration.SettingsState import fi.testaustime.plugin_intellij.network.TestaustimeAPIClient import fi.testaustime.plugin_intellij.network.models.ActivityPostPayload +import fi.testaustime.plugin_intellij.utils.ContextInformation import fi.testaustime.plugin_intellij.utils.TestaustimeNotifier -import java.net.InetAddress -import java.net.http.HttpResponse -import java.util.concurrent.CompletableFuture +import java.time.Duration +import java.time.Duration.ofSeconds import java.util.concurrent.ScheduledFuture -import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeUnit.MILLISECONDS class TestaustimeApplicationService { + companion object { + private val HEARTBEAT_PERIOD: Duration = ofSeconds(30); + } init { Logger.getInstance("#Testaustime").debug(message("applicationService.started")) - startScheduledPinger(); + startScheduledPinger() } - private var scheduledPingTask: ScheduledFuture<*>? = null; + private var scheduledPingTask: ScheduledFuture<*>? = null // Track last project to know when to flush sessions - private var lastProject: Project? = null; + private var lastProject: Project? = null - // API token invalidation state, used for notification tracking - private var wasInvalid: Boolean = true; - private var isInvalid: Boolean = false; // Past connection exception state, used for de-duping connection exception notifications - private var didFail: Boolean = false; + private var didFail: Boolean = false fun terminateService() { - flushActivity(); + flushActivity() scheduledPingTask?.cancel(true) Logger.getInstance("#Testaustime").debug(message("applicationService.terminated")) } private fun flushActivity() { - val settings = SettingsState.instance; - val client = TestaustimeAPIClient(settings.apiBaseUrl, settings.authToken); - client.flushActivity().join(); + val settings = SettingsState.instance + val client = TestaustimeAPIClient(settings.apiBaseUrl, settings.authToken) + client.flushActivity().join() } private fun startScheduledPinger() { @@ -58,80 +56,69 @@ class TestaustimeApplicationService { if (scheduledPingTask != null) return scheduledPingTask = AppExecutorUtil.getAppScheduledExecutorService().scheduleWithFixedDelay({ pingNow() - }, 0, 30, TimeUnit.SECONDS) + }, 0, HEARTBEAT_PERIOD.toMillis(), MILLISECONDS) Logger.getInstance("#Testaustime").debug( message("applicationService.pinger.registered") ) } fun pingNow() { - val settings = SettingsState.instance; + val settings = SettingsState.instance try { - val client = TestaustimeAPIClient(settings.apiBaseUrl, settings.authToken); - - val hostname = InetAddress.getLocalHost().hostName - val appName = ApplicationInfo.getInstance().fullApplicationName - val dataContext = DataManager.getInstance().dataContextFromFocusAsync - dataContext.onSuccess { ctx -> - if (isInvalid) { - wasInvalid = true; - return@onSuccess; + val client = TestaustimeAPIClient(settings.apiBaseUrl, settings.authToken) + + ApplicationManager.getApplication().invokeLater({ + val ctx = ContextInformation.getDataContext() ?: return@invokeLater; + val project = ctx.getData(CommonDataKeys.PROJECT) + project ?: return@invokeLater + + // Consider switching projects to be a new session + if (lastProject?.equals(project) == false) { + flushActivity() + lastProject = project } - ctx.getData(PlatformDataKeys.PROJECT)?.let { project -> - // Consider switching projects to be a new session - if (lastProject?.equals(project) == false) { - flushActivity() - lastProject = project + val editor: Editor? = ctx.getData(PlatformDataKeys.EDITOR) + val service = project.getService(TestaustimeProjectService::class.java) + val future = if (fun(): Boolean { + if (!project.isOpen) return false + if (settings.authToken.isBlank()) return false + if (!service.isActive()) return false; + editor ?: return false + + return true + }.invoke()) { + client.activityLog(ActivityPostPayload.fromProject(project)) + } else client.me() + + future.thenAccept { resp -> + if (resp.statusCode() == 401) { + TestaustimeProjectService.broadcast(false) + return@thenAccept + } else if (didFail) { + TestaustimeProjectService.broadcast(true) + didFail = false } - - val editor: Editor? = ctx.getData(PlatformDataKeys.EDITOR) - val type: String? - - val future: CompletableFuture> - if (project.isOpen && editor != null && settings.authToken.isNotBlank()) { - type = PsiUtilBase.getPsiFileInEditor(editor, project)?.fileType?.displayName - future = client.activityLog( - ActivityPostPayload( - programmingLanguage = type, - projectName = project.name, - IDEName = appName, - host = hostname - ) - ); - } else { - future = client.me(); + }.exceptionally { + if (!didFail) { + TestaustimeNotifier.notification( + ERROR, null, + message("applicationService.heartbeat.failed"), it.localizedMessage + ) + + didFail = true } - future.thenAccept { resp -> - if (resp.statusCode() == 401) { - TestaustimeProjectService.broadcast(false) - isInvalid = true - wasInvalid = true - return@thenAccept - } else if (wasInvalid || didFail) { - TestaustimeProjectService.broadcast(true) - wasInvalid = false - didFail = false; - } - - }.exceptionally { - if (!didFail) { - TestaustimeNotifier.notification( - ERROR, null, - message("applicationService.heartbeat.failed"), it.localizedMessage - ) - didFail = true; - } - return@exceptionally null; - } + return@exceptionally null } - } - dataContext.onError { err -> - Logger.getInstance("#Testaustime").error(err) - } - } catch (e: Exception) { + }, ModalityState.any()) + } catch (e: Throwable) { + TestaustimeNotifier.notification( + ERROR, null, + message("applicationService.heartbeat.failed"), e.localizedMessage + ) + Logger.getInstance("#Testaustime").error(e) } } diff --git a/src/main/kotlin/fi/testaustime/plugin_intellij/services/TestaustimeProjectService.kt b/src/main/kotlin/fi/testaustime/plugin_intellij/services/TestaustimeProjectService.kt index d919e59..73f3eff 100644 --- a/src/main/kotlin/fi/testaustime/plugin_intellij/services/TestaustimeProjectService.kt +++ b/src/main/kotlin/fi/testaustime/plugin_intellij/services/TestaustimeProjectService.kt @@ -1,27 +1,36 @@ package fi.testaustime.plugin_intellij.services import com.intellij.notification.NotificationType +import com.intellij.openapi.Disposable import com.intellij.openapi.project.Project import fi.testaustime.plugin_intellij.TestaustimeBundle.message +import fi.testaustime.plugin_intellij.utils.ContextInformation.getFriendlyName import fi.testaustime.plugin_intellij.utils.TestaustimeNotifier -class TestaustimeProjectService(private val project: Project) { +class TestaustimeProjectService(private val project: Project) : Disposable { + private val caretListener: CaretMoveListener + init { projects.add(project); + caretListener = CaretMoveListener(this); } fun terminate() { projects.remove(project); } + fun isActive(): Boolean { + return caretListener.isActive() + } + companion object { var projects: MutableList = ArrayList(); fun broadcast(tokenValid: Boolean) { for (project in projects) { if (tokenValid) { TestaustimeNotifier.notifyInfo(project, message("projectService.active.title"), - message("projectService.active.message", project.name)); + message("projectService.active.message", project.getFriendlyName())); } else { TestaustimeNotifier.notification(NotificationType.ERROR, project, message("projectService.invalid.title"), message("projectService.invalid.message", project.name)) @@ -29,4 +38,8 @@ class TestaustimeProjectService(private val project: Project) { } } } + + override fun dispose() { + terminate() + } } diff --git a/src/main/kotlin/fi/testaustime/plugin_intellij/utils/ContextInformation.kt b/src/main/kotlin/fi/testaustime/plugin_intellij/utils/ContextInformation.kt new file mode 100755 index 0000000..c1c11ac --- /dev/null +++ b/src/main/kotlin/fi/testaustime/plugin_intellij/utils/ContextInformation.kt @@ -0,0 +1,64 @@ +package fi.testaustime.plugin_intellij.utils + +import com.intellij.ide.DataManager +import com.intellij.openapi.actionSystem.CommonDataKeys.EDITOR +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.application.ApplicationInfo +import com.intellij.openapi.application.ReadAction +import com.intellij.openapi.module.ModuleManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.project.guessProjectDir +import com.intellij.openapi.roots.ModuleRootManager +import com.intellij.psi.util.PsiUtilBase +import com.kstruct.gethostname4j.Hostname +import kotlinx.coroutines.runBlocking +import org.jetbrains.concurrency.await + +object ContextInformation { + fun Project.getFriendlyName(): String { + val moduleManager = ModuleManager.getInstance(this) + val modules = moduleManager.modules + for (module in modules) { + if (!module.isLoaded) continue + val moduleRootManager = ModuleRootManager.getInstance(module) + val contentRoots = moduleRootManager.contentRoots + if (contentRoots.contains(this.guessProjectDir())) { + return module.name + } + } + + return this.name + } + + fun getProgrammingLanguage(): String? { + return ReadAction.compute { + val context = getDataContext() + val editor = context?.getData(EDITOR) + val project = editor?.project + + project?.let { + val psiFile = PsiUtilBase.getPsiFileInEditor(editor, it) + val language = psiFile?.language + language?.displayName + } + } + } + + fun getDataContext(): DataContext? { + return runBlocking { + DataManager.getInstance().dataContextFromFocusAsync.await() + } + } + + fun getApplicationName(): String { + return ReadAction.compute { + ApplicationInfo.getInstance().fullApplicationName + } + } + + fun getHostname(): String? = try { + Hostname.getHostname() + } catch (e: RuntimeException) { + null + } +} \ No newline at end of file