diff --git a/CLAUDE.md b/CLAUDE.md index cffa3c3..474de30 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,13 +53,20 @@ IDE Events (file open/edit/save) → FileEditorListener / FileSaveListener (plugin.xml listeners) → ShellTimeProjectService → HeartbeatCollector (debounces events, max 1 per file per 30s) - → HeartbeatSender (periodic flush every 2 min) + → HeartbeatSender (periodic flush every 2 min, at most 500 per message) → SocketClient (Unix socket to daemon) ``` +The daemon answers `{"status":"ok"}`, or `{"status":"disabled"}` when code tracking is off in its config. +A connection closed without a reply counts as delivered only if the daemon still takes connections: daemons 0.1.42 to +0.1.47 never replied, and a stopping daemon closes its listener before it drops a message. Otherwise `disabled`, no reply and +no connection keep the heartbeats queued; on project close what is left goes to the daemon's resend file +(`OfflineStore`, `~/.shelltime/coding-heartbeat.data.log`). + ### Configuration Sources -1. **File config**: `~/.shelltime/config.toml` or `~/.shelltime/config.yaml` (loaded by ConfigLoader) +1. **File config**: the files the CLI reads, the first of `~/.shelltime/config.yaml`, `config.yml` and `config.toml` with + the first `config.local.*` merged over it (loaded by ConfigLoader when the IDE starts) 2. **IDE settings**: Settings → Tools → ShellTime (ShellTimeConfigurable) ### Key Extension Points (plugin.xml) @@ -71,8 +78,23 @@ IDE Events (file open/edit/save) - `applicationListeners`: FileSaveListener - `projectListeners`: FileEditorListener +### Kotlin version + +The IDE provides the Kotlin standard library (`kotlin.stdlib.default.dependency=false`), so `build.gradle.kts` sets +`apiVersion` to 1.9, the library of 2024.1 (`pluginSinceBuild=241`). Raise it only together with `pluginSinceBuild`: a +newer API version also writes coroutine debug metadata an older library can't read, and with `-ea` (as in tests) a +coroutine whose metadata kotlinx.coroutines read died with "Debug metadata version mismatch" and never resumed, so the +tests hung. + ### Testing - JUnit 5 + MockK for mocking +- Every test has a 60 s timeout (`build.gradle.kts`). `@ExtendWith(ThreadDumpOnTimeout::class)` (the sender and socket + tests) attaches every thread's stack and the coroutine dump to one that times out +- The IntelliJ test framework's JUnit 5 extensions run on every test, also plain ones: its thread leak tracker fails a + test that leaves a thread running, so stop the threads a test starts (see `SocketClientTest`'s fake daemon) +- Don't mock `URL`'s constructors (`mockkConstructor(URL::class)`): every URL in the JVM goes through the mock, the class + loader's resource reads included, so a library that initializes during the test (kotlin-reflect, for MockK) reads the + mock's stream and hangs. `VersionCheckerTest` runs against a local `HttpServer` - Kover for coverage - IntelliJ test framework for integration tests diff --git a/README.md b/README.md index 92d96fd..a982997 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,9 @@ Or if using `~/.shelltime/config.toml`: enabled = true ``` -The plugin reads the same file when the IDE starts: `codeTracking.enabled` decides whether it tracks, and `socketPath` (if set) tells it where to find the daemon. +Recent daemons pick the change up right away; older ones need a restart (`shelltime daemon reinstall`). + +The plugin reads the same files as the CLI: the first of `~/.shelltime/config.yaml`, `config.yml` and `config.toml`, with the first `config.local.yaml`, `config.local.yml` or `config.local.toml` merged over it. `codeTracking.enabled` decides whether it tracks, and `socketPath` (if set) tells it where to find the daemon. ### Verify Installation @@ -124,14 +126,14 @@ Access commands from **Tools** → **ShellTime**: The plugin monitors your IDE activity and sends heartbeats to a local daemon: -1. **Event Monitoring** - Tracks file opens and tab switches, edits, cursor movements and saves, including in tabs restored when a project opens. Each event is attributed to the project the file is open in. +1. **Event Monitoring** - Tracks switching to a file, typing, cursor movements and saves of open files. Each event is attributed to the project the file is open in. Only what you do counts: tabs a project restores behind the selected one, tab switches and edits while the IDE is in the background, files reloaded from disk (after `git checkout`, or a formatter or agent in a terminal) and files a refactoring saves without opening them are ignored. 2. **Debouncing** - Batches events to reduce overhead (max 1 heartbeat per file per 30 seconds). Saves always count; repeated cursor or tab events at the same position are skipped. -3. **Periodic Flush** - Sends collected heartbeats to the daemon every 2 minutes (configurable) -4. **Offline Support** - Keeps heartbeats in memory when the daemon is unavailable and retries on the next flush. Closing a project or the IDE flushes whatever is still pending (waiting at most 2 seconds). +3. **Periodic Flush** - Sends collected heartbeats to the daemon every 2 minutes (configurable), at most 500 per message +4. **Offline Support** - Keeps heartbeats in memory (up to 5,000, oldest dropped first) while the daemon is unavailable or has code tracking turned off, and retries on the next flush. Closing a project or the IDE flushes whatever is still pending (waiting at most 2 seconds); what the daemon can't take then goes to `~/.shelltime/coding-heartbeat.data.log`, which the daemon resends once it runs. -Each heartbeat records the file, project, Git branch (through the bundled Git plugin), language, line count and cursor position, IDE and plugin versions, OS, and machine hostname. The hostname is the same one the ShellTime CLI reports, so IDE, terminal and AI activity on one computer are grouped together. Heartbeats sent while a debugger session is running are categorized as `debugging` instead of `coding`. +Each heartbeat records the file, project, Git branch (through the bundled Git plugin), language (as the VS Code language id, e.g. `typescriptreact` or `shellscript`, so the same language from different editors adds up), line count and cursor position, IDE and plugin versions, OS, and machine hostname. The hostname is the same one the ShellTime CLI reports, so IDE, terminal and AI activity on one computer are grouped together. Heartbeats sent while a debugger session is running are categorized as `debugging` instead of `coding`. -Files outside the local file system are not tracked, and neither are paths inside the project under `.git`, `.idea`, `build`, `out`, `target`, `node_modules`, `.gradle`, `vendor` or `__pycache__`. These are matched relative to the project root, so a project that itself lives under e.g. `~/build/` is still tracked. +Files outside the local file system are not tracked, and neither are folders the project excludes (build output such as `build`, `out` or `target`) or paths inside the project under `.git`, `.idea`, `node_modules`, `.gradle` or `__pycache__`. These are matched relative to the project root, so a project that itself lives under e.g. `~/build/` is still tracked. A file outside the project's base directory counts toward the Git repository it is in. ## CLI Update Check @@ -142,9 +144,10 @@ When a project opens, the plugin asks the daemon for its version and checks it a The plugin shows its status in the IDE status bar: - **ShellTime** - Connected and tracking +- **ShellTime (tracking off)** - The daemon runs but has code tracking turned off in its config (heartbeats queued) - **ShellTime (offline)** - Daemon not reachable (heartbeats queued) -The state updates on each flush, so it shows offline until the first heartbeats have been sent. Hover over it for the number of pending heartbeats, or click it to view daemon status. +The state is checked when a project opens and updates on each flush. Hover over it for the number of pending heartbeats, or click it to view daemon status. ## Supported IDEs diff --git a/build.gradle.kts b/build.gradle.kts index 51b1600..a84644d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -17,6 +17,13 @@ version = providers.gradleProperty("pluginVersion").get() kotlin { jvmToolchain(17) + compilerOptions { + // The IDE provides the Kotlin standard library (kotlin.stdlib.default.dependency=false), and 2024.1, the oldest + // build pluginSinceBuild allows, ships 1.9. A newer API version also writes coroutine debug metadata that the + // IDE's library can't read: in the coroutines' debug mode (on with -ea, as in tests) reading it threw "Debug + // metadata version mismatch" and the coroutine was never resumed. + apiVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9) + } } repositories { @@ -128,6 +135,13 @@ kover { tasks { test { useJUnitPlatform() + // A test that hangs fails with its stack trace instead of stalling the job until it times out + systemProperty("junit.jupiter.execution.timeout.default", "60s") + systemProperty("junit.jupiter.execution.timeout.thread.mode.default", "SEPARATE_THREAD") + testLogging { + events("passed", "skipped", "failed") + exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + } } wrapper { diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/actions/FlushAction.kt b/src/main/kotlin/xyz/shelltime/jetbrains/actions/FlushAction.kt index a49ddb4..bd451e9 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/actions/FlushAction.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/actions/FlushAction.kt @@ -3,11 +3,13 @@ package xyz.shelltime.jetbrains.actions import com.intellij.notification.NotificationGroupManager import com.intellij.notification.NotificationType import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.components.service import com.intellij.openapi.project.Project import xyz.shelltime.jetbrains.ShellTimeBundle import xyz.shelltime.jetbrains.config.Constants +import xyz.shelltime.jetbrains.heartbeat.DaemonState import xyz.shelltime.jetbrains.services.ShellTimeProjectService /** @@ -19,26 +21,36 @@ class FlushAction : AnAction() { val project = e.project ?: return val projectService = project.service() - val flushedCount = projectService.forceFlush() - val (message, type) = when { - flushedCount > 0 -> { - ShellTimeBundle.message("action.flush.success", flushedCount) to NotificationType.INFORMATION - } - flushedCount == 0 && projectService.getPendingCount() == 0 -> { - ShellTimeBundle.message("action.flush.empty") to NotificationType.INFORMATION + // The socket round trip must not block the UI thread + ApplicationManager.getApplication().executeOnPooledThread { + val result = projectService.forceFlush() + val pending = projectService.getPendingCount() + + val (message, type) = when { + result.state == DaemonState.DISABLED -> { + ShellTimeBundle.message("action.flush.disabled", pending) to NotificationType.WARNING + } + result.sent > 0 && pending == 0 -> { + ShellTimeBundle.message("action.flush.success", result.sent) to NotificationType.INFORMATION + } + result.sent == 0 && pending == 0 -> { + ShellTimeBundle.message("action.flush.empty") to NotificationType.INFORMATION + } + else -> { + ShellTimeBundle.message("action.flush.failed") to NotificationType.WARNING + } } - else -> { - ShellTimeBundle.message("action.flush.failed") to NotificationType.WARNING + + if (!project.isDisposed) { + showNotification( + project, + ShellTimeBundle.message("action.flush.title"), + message, + type + ) } } - - showNotification( - project, - ShellTimeBundle.message("action.flush.title"), - message, - type - ) } private fun showNotification( diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/actions/ShowStatusAction.kt b/src/main/kotlin/xyz/shelltime/jetbrains/actions/ShowStatusAction.kt index 4ce3a88..24e6092 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/actions/ShowStatusAction.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/actions/ShowStatusAction.kt @@ -4,6 +4,7 @@ import com.intellij.notification.NotificationGroupManager import com.intellij.notification.NotificationType import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.components.service import com.intellij.openapi.project.Project import kotlinx.coroutines.runBlocking @@ -22,11 +23,12 @@ class ShowStatusAction : AnAction() { val service = ShellTimeService.getInstance() val socketClient = service.getSocketClient() - runBlocking { - val status = socketClient.getStatus() + // The socket round trip must not block the UI thread + ApplicationManager.getApplication().executeOnPooledThread { + val status = runBlocking { socketClient.getStatus() } val message = if (status != null && socketClient.isConnected()) { - val uptime = formatUptime(status.uptime ?: 0) + val uptime = status.uptime ?: "Unknown" ShellTimeBundle.message( "action.showStatus.connected", status.version ?: "Unknown", @@ -37,31 +39,17 @@ class ShowStatusAction : AnAction() { ShellTimeBundle.message("action.showStatus.disconnected") } - showNotification( - project, - ShellTimeBundle.message("action.showStatus.title"), - message, - if (socketClient.isConnected()) NotificationType.INFORMATION else NotificationType.WARNING - ) + if (!project.isDisposed) { + showNotification( + project, + ShellTimeBundle.message("action.showStatus.title"), + message, + if (socketClient.isConnected()) NotificationType.INFORMATION else NotificationType.WARNING + ) + } } } - private fun formatUptime(seconds: Long): String { - if (seconds <= 0) return "Unknown" - - val days = seconds / 86400 - val hours = (seconds % 86400) / 3600 - val minutes = (seconds % 3600) / 60 - val secs = seconds % 60 - - return buildString { - if (days > 0) append("${days}d ") - if (hours > 0) append("${hours}h ") - if (minutes > 0) append("${minutes}m ") - append("${secs}s") - }.trim() - } - private fun showNotification( project: Project, title: String, diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/config/ConfigLoader.kt b/src/main/kotlin/xyz/shelltime/jetbrains/config/ConfigLoader.kt index 2610bb5..00cd259 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/config/ConfigLoader.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/config/ConfigLoader.kt @@ -7,47 +7,40 @@ import java.nio.file.Files import java.nio.file.attribute.BasicFileAttributes /** - * Loads and caches configuration from ~/.shelltime/config.toml or YAML variants + * Loads and caches configuration from the files the ShellTime CLI reads: the first of + * ~/.shelltime/config.yaml, config.yml and config.toml, with the first config.local.* + * merged over it (cli/model/config.go) + * + * @param configDir Directory holding the config files, ~/.shelltime by default */ -class ConfigLoader { +class ConfigLoader( + private val configDir: String = expandPath(Constants.CONFIG_DIR) +) { private var cachedConfig: ShellTimeConfig? = null - private var cachedMtime: Long = 0 - private var cachedPath: String? = null - - private val configPaths = listOf( - "~/.shelltime/config.toml", - "~/.shelltime/config.yaml", - "~/.shelltime/config.yml" - ) + private var cachedSignature: List? = null /** * Get the configuration, loading from file if needed */ fun getConfig(): ShellTimeConfig { - val configFile = findConfigFile() - - if (configFile != null) { - val mtime = getFileMtime(configFile) - val path = configFile.absolutePath - - // Return cached config if file hasn't changed - if (cachedConfig != null && cachedPath == path && cachedMtime == mtime) { - return cachedConfig!! - } + val baseFile = findFile(Constants.BASE_CONFIG_FILES) + val localFile = findFile(Constants.LOCAL_CONFIG_FILES) + val signature = listOf( + baseFile?.absolutePath ?: "", getFileMtime(baseFile), + localFile?.absolutePath ?: "", getFileMtime(localFile) + ) - // Load and cache new config - val fileConfig = loadConfigFile(configFile) - cachedConfig = mergeConfig(fileConfig) - cachedMtime = mtime - cachedPath = path - return cachedConfig!! - } + // Return cached config if no file has changed + cachedConfig?.let { if (cachedSignature == signature) return it } - // Return default config if no file found - if (cachedConfig == null) { - cachedConfig = ShellTimeConfig() + val config = if (baseFile != null) { + mergeConfig(mergeLocalConfig(loadConfigFile(baseFile), localFile?.let { loadConfigFile(it) })) + } else { + ShellTimeConfig() } - return cachedConfig!! + cachedConfig = config + cachedSignature = signature + return config } /** @@ -55,31 +48,16 @@ class ConfigLoader { */ fun reloadConfig(): ShellTimeConfig { cachedConfig = null - cachedMtime = 0 - cachedPath = null + cachedSignature = null return getConfig() } - private fun findConfigFile(): File? { - for (path in configPaths) { - val expandedPath = expandPath(path) - val file = File(expandedPath) - if (file.exists() && file.isFile) { - return file - } - } - return null + private fun findFile(names: List): File? { + return names.map { File(configDir, it) }.firstOrNull { it.exists() && it.isFile } } - private fun expandPath(path: String): String { - return if (path.startsWith("~")) { - System.getProperty("user.home") + path.substring(1) - } else { - path - } - } - - private fun getFileMtime(file: File): Long { + private fun getFileMtime(file: File?): Long { + if (file == null) return 0L return try { val attrs = Files.readAttributes(file.toPath(), BasicFileAttributes::class.java) attrs.lastModifiedTime().toMillis() @@ -106,18 +84,19 @@ class ConfigLoader { val codeTracking = toml.getTable("codeTracking")?.let { ct -> CodeTrackingConfig( - enabled = ct.getBoolean("enabled") ?: true, + enabled = ct.getBoolean("enabled"), apiEndpoint = ct.getString("apiEndpoint"), token = ct.getString("token") ) } + // The CLI writes the endpoints as APIEndpoint and WebEndpoint in TOML @Suppress("UNCHECKED_CAST") return ShellTimeFileConfig( socketPath = toml.getString("socketPath"), - token = toml.getString("token"), - apiEndpoint = toml.getString("apiEndpoint"), - webEndpoint = toml.getString("webEndpoint"), + token = toml.getString("Token") ?: toml.getString("token"), + apiEndpoint = toml.getString("APIEndpoint") ?: toml.getString("apiEndpoint"), + webEndpoint = toml.getString("WebEndpoint") ?: toml.getString("webEndpoint"), codeTracking = codeTracking, flushCount = toml.getLong("flushCount")?.toInt(), gcTime = toml.getLong("gcTime")?.toInt(), @@ -131,11 +110,11 @@ class ConfigLoader { @Suppress("UNCHECKED_CAST") private fun loadYamlConfig(file: File): ShellTimeFileConfig { val yaml = Yaml() - val data: Map = yaml.load(file.inputStream()) ?: return ShellTimeFileConfig() + val data: Map = file.inputStream().use { yaml.load(it) } ?: return ShellTimeFileConfig() val codeTracking = (data["codeTracking"] as? Map)?.let { ct -> CodeTrackingConfig( - enabled = ct["enabled"] as? Boolean ?: true, + enabled = ct["enabled"] as? Boolean, apiEndpoint = ct["apiEndpoint"] as? String, token = ct["token"] as? String ) @@ -156,13 +135,41 @@ class ConfigLoader { ) } + /** + * Local settings override base settings when set, as in the CLI's mergeConfig + */ + private fun mergeLocalConfig(base: ShellTimeFileConfig, local: ShellTimeFileConfig?): ShellTimeFileConfig { + if (local == null) return base + return base.copy( + socketPath = local.socketPath?.takeIf { it.isNotEmpty() } ?: base.socketPath, + token = local.token?.takeIf { it.isNotEmpty() } ?: base.token, + apiEndpoint = local.apiEndpoint?.takeIf { it.isNotEmpty() } ?: base.apiEndpoint, + webEndpoint = local.webEndpoint?.takeIf { it.isNotEmpty() } ?: base.webEndpoint, + codeTracking = mergeCodeTracking(base.codeTracking, local.codeTracking), + exclude = local.exclude?.takeIf { it.isNotEmpty() } ?: base.exclude + ) + } + + /** + * Field by field: a local file that only sets the code tracking token keeps tracking on + */ + private fun mergeCodeTracking(base: CodeTrackingConfig?, local: CodeTrackingConfig?): CodeTrackingConfig? { + if (local == null) return base + val merged = base ?: CodeTrackingConfig() + return merged.copy( + enabled = local.enabled ?: merged.enabled, + apiEndpoint = local.apiEndpoint?.takeIf { it.isNotEmpty() } ?: merged.apiEndpoint, + token = local.token?.takeIf { it.isNotEmpty() } ?: merged.token + ) + } + private fun mergeConfig(fileConfig: ShellTimeFileConfig): ShellTimeConfig { // Determine if enabled from codeTracking section or default val enabled = fileConfig.codeTracking?.enabled ?: true return ShellTimeConfig( enabled = enabled, - socketPath = fileConfig.socketPath ?: Constants.DEFAULT_SOCKET_PATH, + socketPath = fileConfig.socketPath?.takeIf { it.isNotEmpty() } ?: Constants.DEFAULT_SOCKET_PATH, heartbeatInterval = Constants.DEFAULT_FLUSH_INTERVAL_MS, debug = false, exclude = fileConfig.exclude ?: emptyList(), @@ -175,7 +182,20 @@ class ConfigLoader { * Get raw file config for accessing apiEndpoint/webEndpoint */ fun getFileConfig(): ShellTimeFileConfig? { - val configFile = findConfigFile() ?: return null - return loadConfigFile(configFile) + val baseFile = findFile(Constants.BASE_CONFIG_FILES) ?: return null + return mergeLocalConfig( + loadConfigFile(baseFile), + findFile(Constants.LOCAL_CONFIG_FILES)?.let { loadConfigFile(it) } + ) + } + + companion object { + private fun expandPath(path: String): String { + return if (path.startsWith("~")) { + System.getProperty("user.home") + path.substring(1) + } else { + path + } + } } } diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/config/Constants.kt b/src/main/kotlin/xyz/shelltime/jetbrains/config/Constants.kt index 41a0ee9..fc78d88 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/config/Constants.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/config/Constants.kt @@ -13,11 +13,26 @@ object Constants { /** Default heartbeat flush interval - 2 minutes */ const val DEFAULT_FLUSH_INTERVAL_MS = 120_000L + /** Shortest flush interval accepted, so a 0 from the settings can't spin the timer */ + const val MIN_FLUSH_INTERVAL_MS = 10_000L + + /** Heartbeats per socket message, so a backlog never goes out as one huge request */ + const val MAX_HEARTBEATS_PER_MESSAGE = 500 + + /** Upper bound for heartbeats kept while the daemon is unreachable, oldest dropped first */ + const val MAX_PENDING_HEARTBEATS = 5_000 + /** Socket connection timeout - 5 seconds */ const val CONNECTION_TIMEOUT_MS = 5_000L - /** Default config file path */ - const val DEFAULT_CONFIG_PATH = "~/.shelltime/config.toml" + /** Directory of the ShellTime CLI's config */ + const val CONFIG_DIR = "~/.shelltime" + + /** Config files in the CLI's priority order (cli/model/config.go): the first that exists */ + val BASE_CONFIG_FILES = listOf("config.yaml", "config.yml", "config.toml") + + /** Local overrides merged over the base config: the first that exists */ + val LOCAL_CONFIG_FILES = listOf("config.local.yaml", "config.local.yml", "config.local.toml") /** Plugin identifier */ const val PLUGIN_ID = "shelltime" diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/config/ShellTimeConfig.kt b/src/main/kotlin/xyz/shelltime/jetbrains/config/ShellTimeConfig.kt index 86c64c5..aede48f 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/config/ShellTimeConfig.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/config/ShellTimeConfig.kt @@ -4,7 +4,8 @@ package xyz.shelltime.jetbrains.config * Code tracking specific configuration */ data class CodeTrackingConfig( - val enabled: Boolean = true, + /** Null when the file doesn't say; tracking then defaults to on */ + val enabled: Boolean? = null, val apiEndpoint: String? = null, val token: String? = null ) diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatCollector.kt b/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatCollector.kt index 91484be..b450570 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatCollector.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatCollector.kt @@ -2,6 +2,7 @@ package xyz.shelltime.jetbrains.heartbeat import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationInfo +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.editor.Document import com.intellij.openapi.editor.Editor import com.intellij.openapi.editor.EditorFactory @@ -12,6 +13,7 @@ import com.intellij.openapi.editor.event.DocumentListener import com.intellij.openapi.fileEditor.FileDocumentManager import com.intellij.openapi.fileEditor.FileEditorManager import com.intellij.openapi.project.Project +import com.intellij.openapi.roots.ProjectFileIndex import com.intellij.openapi.util.Disposer import com.intellij.openapi.vfs.VirtualFile import com.intellij.xdebugger.XDebuggerManager @@ -36,7 +38,7 @@ class HeartbeatCollector( private val project: Project, private val pluginVersion: String, debug: Boolean = false -) : Disposable { +) : Disposable, HeartbeatSource { private val logger = Logger("Collector", debug) private val lastHeartbeat = ConcurrentHashMap() @Volatile @@ -181,9 +183,19 @@ class HeartbeatCollector( if (file == null) return false if (!file.isInLocalFileSystem) return false if (ProjectUtils.shouldExclude(file.path, project.basePath)) return false + // Build output and other folders the project itself excludes + if (isExcludedFromProject(file)) return false return true } + private fun isExcludedFromProject(file: VirtualFile): Boolean { + return try { + ProjectFileIndex.getInstance(project).isExcluded(file) + } catch (e: Exception) { + false + } + } + /** * Find an editor of this project that shows the document, preferring the selected one */ @@ -204,9 +216,13 @@ class HeartbeatCollector( if (!isValidFile(file)) return - // The multicaster reports changes to every document in the IDE; only count - // documents that are open in an editor of this project. - val editor = findEditor(document) ?: return + // The multicaster reports changes to every document in the IDE. Only typing counts: + // the IDE is active and an editor of this project showing the document has focus. + // Reloads from disk (git checkout, formatters, an agent in a terminal) and the same + // file open in another project's window don't. + if (!ApplicationManager.getApplication().isActive) return + val editor = EditorFactory.getInstance().getEditors(document, project) + .firstOrNull { it.contentComponent.hasFocus() } ?: return val lineNumber = editor.caretModel.logicalPosition.line + 1 val cursorPosition = editor.caretModel.logicalPosition.column @@ -224,6 +240,8 @@ class HeartbeatCollector( private fun handleCaretChange(event: CaretEvent) { val editor = event.editor if (editor.project != project) return + // Programmatic caret moves while the IDE is in the background aren't the user's + if (!ApplicationManager.getApplication().isActive) return val document = editor.document val file = FileDocumentManager.getInstance().getFile(document) ?: return @@ -245,6 +263,9 @@ class HeartbeatCollector( * Handle file switch events */ fun handleFileSwitch(file: VirtualFile?) { + // The tab a project restores while the IDE is in the background, and tabs a debugger + // or another tool selects there, aren't the user's + if (!ApplicationManager.getApplication().isActive) return if (!isValidFile(file)) return val document = FileDocumentManager.getInstance().getDocument(file!!) ?: return @@ -267,9 +288,10 @@ class HeartbeatCollector( if (!isValidFile(file)) return val document = FileDocumentManager.getInstance().getDocument(file!!) ?: return - val editor = findEditor(document) - val lineNumber = editor?.let { it.caretModel.logicalPosition.line + 1 } - val cursorPosition = editor?.caretModel?.logicalPosition?.column + // A refactoring saves files that aren't open; only saves of files the user has open count + val editor = findEditor(document) ?: return + val lineNumber = editor.caretModel.logicalPosition.line + 1 + val cursorPosition = editor.caretModel.logicalPosition.column // Saves always bypass debouncing val heartbeat = createHeartbeat(file, document, true, lineNumber, cursorPosition) @@ -285,7 +307,7 @@ class HeartbeatCollector( * * @return List of pending heartbeats, clearing the queue */ - fun flush(): List { + override fun flush(): List { val heartbeats = mutableListOf() while (true) { val heartbeat = pendingHeartbeats.poll() ?: break @@ -298,7 +320,7 @@ class HeartbeatCollector( /** * Get the number of pending heartbeats */ - fun getPendingCount(): Int = pendingHeartbeats.size + override fun getPendingCount(): Int = pendingHeartbeats.size /** * Set debug logging diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatData.kt b/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatData.kt index addc08c..f3da04e 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatData.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatData.kt @@ -105,16 +105,26 @@ data class SocketMessage( ) /** - * Response from daemon status request + * Response from daemon status request. The daemon formats uptime itself, e.g. "2h 5m 3s". */ @Serializable data class StatusResponse( val version: String? = null, - val uptime: Long? = null, + val startedAt: String? = null, + val uptime: String? = null, val platform: String? = null, val goVersion: String? = null ) +/** + * The daemon's reply to a heartbeat message: "ok", or "disabled" when it drops editor + * heartbeats because code tracking is off in its config + */ +@Serializable +data class HeartbeatAck( + val status: String? = null +) + /** * Response from version check API */ diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatSender.kt b/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatSender.kt index 7d6a8bc..aa81189 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatSender.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatSender.kt @@ -3,37 +3,77 @@ package xyz.shelltime.jetbrains.heartbeat import com.intellij.openapi.Disposable import kotlinx.coroutines.* import xyz.shelltime.jetbrains.config.Constants -import xyz.shelltime.jetbrains.socket.SocketClient +import xyz.shelltime.jetbrains.socket.HeartbeatDelivery +import xyz.shelltime.jetbrains.socket.HeartbeatTransport import xyz.shelltime.jetbrains.utils.Logger -import java.util.concurrent.ConcurrentLinkedQueue +import java.io.File +import java.util.concurrent.ConcurrentLinkedDeque + +/** + * State of the daemon as the last flush found it + */ +enum class DaemonState { + CONNECTED, + DISCONNECTED, + + /** The daemon runs but drops editor heartbeats: code tracking was off when it started */ + DISABLED +} + +/** + * Outcome of one flush + */ +data class FlushResult(val sent: Int, val state: DaemonState) + +/** + * Where the sender takes newly collected heartbeats from + */ +interface HeartbeatSource { + /** Take every collected heartbeat, oldest first, leaving none queued */ + fun flush(): List + + fun getPendingCount(): Int +} /** * Callback interface for status updates */ interface HeartbeatSenderCallback { - fun onStatusChanged(connected: Boolean, pendingCount: Int) + fun onStatusChanged(state: DaemonState, pendingCount: Int) } /** * Periodically sends collected heartbeats to the daemon */ class HeartbeatSender( - private val collector: HeartbeatCollector, - private val socketClient: SocketClient, - private var flushInterval: Long = Constants.DEFAULT_FLUSH_INTERVAL_MS, - debug: Boolean = false + private val collector: HeartbeatSource, + private val socketClient: HeartbeatTransport, + flushInterval: Long = Constants.DEFAULT_FLUSH_INTERVAL_MS, + debug: Boolean = false, + private val offlineFile: File = OfflineStore.DEFAULT_FILE ) : Disposable { private val logger = Logger("Sender", debug) private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) private var flushJob: Job? = null private var callback: HeartbeatSenderCallback? = null + private var flushInterval = flushInterval.coerceAtLeast(Constants.MIN_FLUSH_INTERVAL_MS) - // Queue for heartbeats that failed to send (offline support) - private val failedHeartbeats = ConcurrentLinkedQueue() + // Guards requeue against drainPending, so nothing is put back after the queue was drained + private val queueLock = Any() + + @Volatile + private var drained = false + + // Heartbeats that failed to send (offline support), oldest first + private val failedHeartbeats = ConcurrentLinkedDeque() @Volatile private var isRunning = false + @Volatile + var state = DaemonState.DISCONNECTED + private set + /** * Set callback for status updates */ @@ -71,59 +111,114 @@ class HeartbeatSender( } /** - * Flush pending heartbeats to the daemon - * - * @return Number of heartbeats successfully sent + * Stop the periodic flush timer and wait until a flush it was running has queued what it + * did not deliver again, so nothing reads the queue before that */ - suspend fun flush(): Int { - // Collect heartbeats from collector - val newHeartbeats = collector.flush() + suspend fun stopAndJoin() { + val job = flushJob + stop() + job?.cancelAndJoin() + } - // Combine with any previously failed heartbeats + /** + * Flush pending heartbeats to the daemon, at most MAX_HEARTBEATS_PER_MESSAGE per message + * + * @return How many heartbeats the daemon took, and the state it was found in + */ + suspend fun flush(): FlushResult { + // Previously failed heartbeats first (older), then the collector's val allHeartbeats = mutableListOf() - - // Add failed heartbeats first (older) while (true) { - val hb = failedHeartbeats.poll() ?: break + val hb = failedHeartbeats.pollFirst() ?: break allHeartbeats.add(hb) } - - // Add new heartbeats - allHeartbeats.addAll(newHeartbeats) + allHeartbeats.addAll(collector.flush()) if (allHeartbeats.isEmpty()) { logger.log("No heartbeats to flush") updateStatus() - return 0 + return FlushResult(0, state) } logger.log("Flushing ${allHeartbeats.size} heartbeats") - val payload = HeartbeatPayload(heartbeats = allHeartbeats) - val success = socketClient.sendHeartbeats(payload) + var sent = 0 + var result = DaemonState.CONNECTED + try { + for (chunk in allHeartbeats.chunked(Constants.MAX_HEARTBEATS_PER_MESSAGE)) { + when (socketClient.sendHeartbeats(HeartbeatPayload(heartbeats = chunk))) { + HeartbeatDelivery.OK -> sent += chunk.size + HeartbeatDelivery.DISABLED -> { + result = DaemonState.DISABLED + break + } + HeartbeatDelivery.FAILED -> { + result = DaemonState.DISCONNECTED + break + } + } + } + } finally { + // Keep what was not delivered, also when the flush was cancelled mid-send + requeue(allHeartbeats.subList(sent, allHeartbeats.size)) + } - if (success) { - logger.log("Successfully sent ${allHeartbeats.size} heartbeats") - updateStatus() - return allHeartbeats.size + state = result + if (sent == allHeartbeats.size) { + logger.log("Successfully sent $sent heartbeats") } else { - // Re-queue failed heartbeats for retry - allHeartbeats.forEach { failedHeartbeats.add(it) } - logger.log("Failed to send heartbeats, queued ${allHeartbeats.size} for retry") - updateStatus() - return 0 + logger.log("Sent $sent heartbeats, queued ${allHeartbeats.size - sent} for retry ($result)") + } + updateStatus() + return FlushResult(sent, result) + } + + /** + * Put heartbeats back ahead of newer ones, dropping the oldest beyond MAX_PENDING_HEARTBEATS + */ + private fun requeue(heartbeats: List) { + synchronized(queueLock) { + if (drained) { + // The project closed while this flush was sending (a Flush action, or tracking + // turned off): its queue is already gone, so the daemon sends these later + OfflineStore.save(heartbeats, offlineFile) + return + } + for (hb in heartbeats.asReversed()) { + failedHeartbeats.addFirst(hb) + } + while (failedHeartbeats.size > Constants.MAX_PENDING_HEARTBEATS) { + failedHeartbeats.pollFirst() + } } } /** * Force an immediate flush (used by manual flush action) */ - fun forceFlush(): Int { + fun forceFlush(): FlushResult { return runBlocking { flush() } } + /** + * Take every heartbeat not yet delivered, oldest first, leaving none queued + */ + fun drainPending(): List { + synchronized(queueLock) { + // From now on a flush still sending leaves what it doesn't deliver in the daemon's file + drained = true + val pending = mutableListOf() + while (true) { + val hb = failedHeartbeats.pollFirst() ?: break + pending.add(hb) + } + pending.addAll(collector.flush()) + return pending + } + } + /** * Get total pending count (collector + failed) */ @@ -136,16 +231,33 @@ class HeartbeatSender( */ private fun updateStatus() { callback?.onStatusChanged( - connected = socketClient.isConnected(), + state = state, pendingCount = getPendingCount() ) } + /** + * Probe the daemon without sending heartbeats, so the status bar is right before the + * first flush and while nothing is pending. A running daemon keeps the disabled state + * until it accepts heartbeats again. + */ + suspend fun refreshState() { + val connected = socketClient.getStatus() != null || socketClient.isConnected() + state = when { + !connected -> DaemonState.DISCONNECTED + state == DaemonState.DISABLED -> DaemonState.DISABLED + else -> DaemonState.CONNECTED + } + updateStatus() + } + /** * Update flush interval */ fun setFlushInterval(interval: Long) { - flushInterval = interval + val coerced = interval.coerceAtLeast(Constants.MIN_FLUSH_INTERVAL_MS) + if (coerced == flushInterval) return + flushInterval = coerced if (isRunning) { stop() start() diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/OfflineStore.kt b/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/OfflineStore.kt new file mode 100644 index 0000000..1332755 --- /dev/null +++ b/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/OfflineStore.kt @@ -0,0 +1,40 @@ +package xyz.shelltime.jetbrains.heartbeat + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import xyz.shelltime.jetbrains.config.Constants +import java.io.File + +/** + * The daemon's file for heartbeats it could not send (cli/model/db.go HEARTBEAT_LOG_FILE): + * one {"heartbeats": [...]} payload per line, resent when the daemon starts and every + * 30 minutes. Heartbeat ids make a resend of one that did arrive harmless. + */ +object OfflineStore { + val DEFAULT_FILE = File(System.getProperty("user.home"), ".shelltime/coding-heartbeat.data.log") + + // Don't grow the file without bound when no daemon ever reads it + private const val MAX_FILE_BYTES = 50L * 1024 * 1024 + + private val json = Json { encodeDefaults = true } + + /** + * Leave heartbeats that could not be delivered before the IDE closes for the daemon + * to send once it runs again + * + * @return Whether they were written + */ + fun save(heartbeats: List, file: File = DEFAULT_FILE): Boolean { + if (heartbeats.isEmpty()) return true + return try { + if (file.exists() && file.length() > MAX_FILE_BYTES) return false + file.parentFile?.mkdirs() + val lines = heartbeats.chunked(Constants.MAX_HEARTBEATS_PER_MESSAGE) + .joinToString("") { json.encodeToString(HeartbeatPayload(heartbeats = it)) + "\n" } + file.appendText(lines) + true + } catch (e: Exception) { + false + } + } +} diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/listeners/FileEditorListener.kt b/src/main/kotlin/xyz/shelltime/jetbrains/listeners/FileEditorListener.kt index 1c39c9b..87695b3 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/listeners/FileEditorListener.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/listeners/FileEditorListener.kt @@ -14,14 +14,14 @@ import com.intellij.openapi.vfs.VirtualFile import xyz.shelltime.jetbrains.services.ShellTimeProjectService /** - * Listener for file editor events (open, close, switch) + * Listener for file editor events (switching tabs) + * + * fileOpened isn't used: it fires for every tab restored with the project and for files + * opened in the background, which the user never looked at. A file the user opens + * becomes the selected one, which selectionChanged reports. */ class FileEditorListener(private val project: Project) : FileEditorManagerListener { - override fun fileOpened(source: FileEditorManager, file: VirtualFile) { - project.service().onFileSwitch(file) - } - override fun selectionChanged(event: FileEditorManagerEvent) { event.newFile?.let { file -> project.service().onFileSwitch(file) diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/services/ShellTimeProjectService.kt b/src/main/kotlin/xyz/shelltime/jetbrains/services/ShellTimeProjectService.kt index 429bf83..b5eddca 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/services/ShellTimeProjectService.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/services/ShellTimeProjectService.kt @@ -1,15 +1,23 @@ package xyz.shelltime.jetbrains.services +import com.intellij.notification.NotificationGroupManager +import com.intellij.notification.NotificationType import com.intellij.openapi.Disposable import com.intellij.openapi.components.Service import com.intellij.openapi.project.Project import com.intellij.openapi.vfs.VirtualFile +import xyz.shelltime.jetbrains.ShellTimeBundle +import xyz.shelltime.jetbrains.config.Constants +import xyz.shelltime.jetbrains.heartbeat.DaemonState +import xyz.shelltime.jetbrains.heartbeat.FlushResult import xyz.shelltime.jetbrains.heartbeat.HeartbeatCollector import xyz.shelltime.jetbrains.heartbeat.HeartbeatSender import xyz.shelltime.jetbrains.heartbeat.HeartbeatSenderCallback +import xyz.shelltime.jetbrains.heartbeat.OfflineStore import xyz.shelltime.jetbrains.utils.SystemUtils import xyz.shelltime.jetbrains.version.VersionChecker import kotlinx.coroutines.* +import java.util.concurrent.atomic.AtomicBoolean /** * Project-level service for ShellTime @@ -30,6 +38,31 @@ class ShellTimeProjectService(private val project: Project) : Disposable { companion object { /** How long closing a project may wait for the last heartbeats to be sent */ private const val FINAL_FLUSH_TIMEOUT_MS = 2_000L + + /** The tracking-disabled notice is shown once per IDE session, not per project */ + private val disabledNoticeShown = AtomicBoolean(false) + } + + /** Forwards sender updates to the status bar widget and explains a disabled daemon */ + private val senderCallback = object : HeartbeatSenderCallback { + override fun onStatusChanged(state: DaemonState, pendingCount: Int) { + if (state == DaemonState.DISABLED) { + notifyTrackingDisabled() + } + statusCallback?.onStatusChanged(state, pendingCount) + } + } + + private fun notifyTrackingDisabled() { + if (project.isDisposed || !disabledNoticeShown.compareAndSet(false, true)) return + NotificationGroupManager.getInstance() + .getNotificationGroup(Constants.NOTIFICATION_GROUP_ID) + .createNotification( + ShellTimeBundle.message("action.showStatus.title"), + ShellTimeBundle.message("notification.trackingDisabled"), + NotificationType.WARNING + ) + .notify(project) } init { @@ -57,8 +90,7 @@ class ShellTimeProjectService(private val project: Project) : Disposable { debug = debug ) - // Set status callback if already registered - statusCallback?.let { sender.setCallback(it) } + sender.setCallback(senderCallback) // Resolve the machine name once, off the EDT scope.launch { SystemUtils.getMachineName() } @@ -66,8 +98,9 @@ class ShellTimeProjectService(private val project: Project) : Disposable { // Register for editor events collector.start(this) - // Start the sender + // Start the sender, and show the daemon's state before the first flush sender.start() + scope.launch { sender.refreshState() } // Check CLI version in background (non-blocking) checkCliVersion() @@ -107,7 +140,7 @@ class ShellTimeProjectService(private val project: Project) : Disposable { fun setStatusCallback(callback: HeartbeatSenderCallback) { statusCallback = callback if (::sender.isInitialized) { - sender.setCallback(callback) + callback.onStatusChanged(sender.state, sender.getPendingCount()) } } @@ -130,10 +163,10 @@ class ShellTimeProjectService(private val project: Project) : Disposable { /** * Force flush heartbeats * - * @return Number of heartbeats flushed + * @return How many heartbeats the daemon took, and its state */ - fun forceFlush(): Int { - if (!::sender.isInitialized) return 0 + fun forceFlush(): FlushResult { + if (!::sender.isInitialized) return FlushResult(0, DaemonState.DISCONNECTED) return sender.forceFlush() } @@ -180,15 +213,18 @@ class ShellTimeProjectService(private val project: Project) : Disposable { collector.stop() } if (::sender.isInitialized) { - sender.stop() // Send what is still queued; this runs when the project closes and on IDE exit try { runBlocking(Dispatchers.IO) { + // A periodic flush cancelled mid-send puts its heartbeats back first + sender.stopAndJoin() withTimeoutOrNull(FINAL_FLUSH_TIMEOUT_MS) { sender.flush() } } } catch (e: Exception) { // Best effort only } + // Not delivered: the daemon sends them once it runs again + OfflineStore.save(sender.drainPending()) sender.dispose() } if (::collector.isInitialized) { diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/services/ShellTimeService.kt b/src/main/kotlin/xyz/shelltime/jetbrains/services/ShellTimeService.kt index f972585..0fbe980 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/services/ShellTimeService.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/services/ShellTimeService.kt @@ -71,12 +71,12 @@ class ShellTimeService { socketPath: String, heartbeatInterval: Long ) { - currentSettings = ShellTimeConfig( + // Keep the file's endpoints, which the settings page doesn't show + currentSettings = currentSettings.copy( enabled = enabled, socketPath = socketPath, heartbeatInterval = heartbeatInterval, - debug = debug, - exclude = currentSettings.exclude + debug = debug ) // Update socket client @@ -88,14 +88,7 @@ class ShellTimeService { * Reload configuration from file */ fun reloadConfig() { - val fileConfig = configLoader.reloadConfig() - currentSettings = ShellTimeConfig( - enabled = fileConfig.enabled, - socketPath = fileConfig.socketPath, - heartbeatInterval = fileConfig.heartbeatInterval, - debug = fileConfig.debug, - exclude = fileConfig.exclude - ) + currentSettings = configLoader.reloadConfig() socketClient.setSocketPath(currentSettings.socketPath) socketClient.setDebug(currentSettings.debug) diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/socket/SocketClient.kt b/src/main/kotlin/xyz/shelltime/jetbrains/socket/SocketClient.kt index 793b41f..29b9818 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/socket/SocketClient.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/socket/SocketClient.kt @@ -1,11 +1,13 @@ package xyz.shelltime.jetbrains.socket import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import xyz.shelltime.jetbrains.config.Constants +import xyz.shelltime.jetbrains.heartbeat.HeartbeatAck import xyz.shelltime.jetbrains.heartbeat.HeartbeatPayload import xyz.shelltime.jetbrains.heartbeat.SocketMessage import xyz.shelltime.jetbrains.heartbeat.StatusResponse @@ -16,13 +18,38 @@ import java.nio.ByteBuffer import java.nio.channels.SocketChannel import java.nio.charset.StandardCharsets +/** + * What became of a heartbeat message + */ +enum class HeartbeatDelivery { + /** The daemon took the heartbeats */ + OK, + + /** The daemon dropped them: code tracking is off in its config */ + DISABLED, + + /** No connection, a timeout, or no reply from a daemon that is stopping */ + FAILED +} + +/** + * What the heartbeat sender needs from the daemon + */ +interface HeartbeatTransport { + suspend fun sendHeartbeats(payload: HeartbeatPayload): HeartbeatDelivery + + suspend fun getStatus(): StatusResponse? + + fun isConnected(): Boolean +} + /** * Unix domain socket client for communicating with the ShellTime daemon */ class SocketClient( private var socketPath: String = Constants.DEFAULT_SOCKET_PATH, debug: Boolean = false -) { +) : HeartbeatTransport { private val logger = Logger("SocketClient", debug) private val json = Json { ignoreUnknownKeys = true @@ -35,7 +62,7 @@ class SocketClient( /** * Check if we successfully connected on the last attempt */ - fun isConnected(): Boolean = lastConnectionSuccess + override fun isConnected(): Boolean = lastConnectionSuccess /** * Update the socket path @@ -48,16 +75,29 @@ class SocketClient( * Send heartbeats to the daemon * * @param payload The heartbeat payload to send - * @return True if successfully sent, false otherwise + * @return Whether the daemon took the heartbeats */ - suspend fun sendHeartbeats(payload: HeartbeatPayload): Boolean { + override suspend fun sendHeartbeats(payload: HeartbeatPayload): HeartbeatDelivery { val message = SocketMessage( type = "heartbeat", payload = payload ) - val response = send(json.encodeToString(message)) - return response != null + val response = send(json.encodeToString(message)) ?: return HeartbeatDelivery.FAILED + if (response.isBlank()) { + // Daemons 0.1.42 to 0.1.47 took heartbeats without ever replying (0.1.42 doesn't + // answer status either), so retrying would resend them forever. A stopping daemon + // also closes without a reply when it drops a message, but it closes its listener + // first: one that still takes connections took them. + return if (testConnection()) HeartbeatDelivery.OK else HeartbeatDelivery.FAILED + } + val ack = try { + json.decodeFromString(response) + } catch (e: Exception) { + // Any other reply means the daemon read the message + null + } + return if (ack?.status == "disabled") HeartbeatDelivery.DISABLED else HeartbeatDelivery.OK } /** @@ -65,7 +105,7 @@ class SocketClient( * * @return StatusResponse if successful, null otherwise */ - suspend fun getStatus(): StatusResponse? { + override suspend fun getStatus(): StatusResponse? { val message = SocketMessage(type = "status") val response = send(json.encodeToString(message)) @@ -87,41 +127,51 @@ class SocketClient( * @return The response string, or null if failed */ private suspend fun send(message: String): String? = withContext(Dispatchers.IO) { - withTimeoutOrNull(Constants.CONNECTION_TIMEOUT_MS) { - try { - val socketAddress = UnixDomainSocketAddress.of(socketPath) - SocketChannel.open(StandardProtocolFamily.UNIX).use { channel -> - channel.connect(socketAddress) - channel.configureBlocking(true) - - // Send message with newline delimiter - val messageWithNewline = message + "\n" - val sendBuffer = ByteBuffer.wrap(messageWithNewline.toByteArray(StandardCharsets.UTF_8)) - while (sendBuffer.hasRemaining()) { - channel.write(sendBuffer) - } - - // Read response - val receiveBuffer = ByteBuffer.allocate(8192) - val bytesRead = channel.read(receiveBuffer) - - if (bytesRead > 0) { - receiveBuffer.flip() - val response = StandardCharsets.UTF_8.decode(receiveBuffer).toString().trim() - lastConnectionSuccess = true - logger.log("Sent message, received: $response") - response - } else { - lastConnectionSuccess = true - logger.log("Sent message, no response received") - "" - } + // Blocking socket I/O ignores coroutine cancellation unless it is interruptible, so + // without it the timeout could not stop a daemon that accepts and never answers + val response = withTimeoutOrNull(Constants.CONNECTION_TIMEOUT_MS) { + runInterruptible { exchange(message) } + } + if (response == null) { + lastConnectionSuccess = false + } + response + } + + private fun exchange(message: String): String? { + return try { + val socketAddress = UnixDomainSocketAddress.of(socketPath) + SocketChannel.open(StandardProtocolFamily.UNIX).use { channel -> + channel.connect(socketAddress) + channel.configureBlocking(true) + + // Send message with newline delimiter + val messageWithNewline = message + "\n" + val sendBuffer = ByteBuffer.wrap(messageWithNewline.toByteArray(StandardCharsets.UTF_8)) + while (sendBuffer.hasRemaining()) { + channel.write(sendBuffer) + } + + // Read response + val receiveBuffer = ByteBuffer.allocate(8192) + val bytesRead = channel.read(receiveBuffer) + + if (bytesRead > 0) { + receiveBuffer.flip() + val response = StandardCharsets.UTF_8.decode(receiveBuffer).toString().trim() + lastConnectionSuccess = true + logger.log("Sent message, received: $response") + response + } else { + lastConnectionSuccess = true + logger.log("Sent message, no response received") + "" } - } catch (e: Exception) { - lastConnectionSuccess = false - logger.log("Failed to connect to daemon: ${e.message}") - null } + } catch (e: Exception) { + lastConnectionSuccess = false + logger.log("Failed to connect to daemon: ${e.message}") + null } } diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/ui/StatusBarWidget.kt b/src/main/kotlin/xyz/shelltime/jetbrains/ui/StatusBarWidget.kt index 5c0eff6..d303507 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/ui/StatusBarWidget.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/ui/StatusBarWidget.kt @@ -11,6 +11,7 @@ import com.intellij.openapi.wm.StatusBarWidget import com.intellij.openapi.wm.impl.status.EditorBasedWidget import com.intellij.util.Consumer import xyz.shelltime.jetbrains.ShellTimeBundle +import xyz.shelltime.jetbrains.heartbeat.DaemonState import xyz.shelltime.jetbrains.heartbeat.HeartbeatSenderCallback import xyz.shelltime.jetbrains.services.ShellTimeProjectService import java.awt.event.MouseEvent @@ -26,7 +27,7 @@ class ShellTimeStatusBarWidget(project: Project) : EditorBasedWidget(project), S } @Volatile - private var connected = false + private var state = DaemonState.DISCONNECTED @Volatile private var pendingCount = 0 @@ -36,18 +37,18 @@ class ShellTimeStatusBarWidget(project: Project) : EditorBasedWidget(project), S override fun getPresentation(): StatusBarWidget.WidgetPresentation = this override fun getText(): String { - return if (connected) { - ShellTimeBundle.message("statusbar.connected") - } else { - ShellTimeBundle.message("statusbar.disconnected") + return when (state) { + DaemonState.CONNECTED -> ShellTimeBundle.message("statusbar.connected") + DaemonState.DISABLED -> ShellTimeBundle.message("statusbar.disabled") + DaemonState.DISCONNECTED -> ShellTimeBundle.message("statusbar.disconnected") } } override fun getTooltipText(): String { - return if (connected) { - ShellTimeBundle.message("statusbar.tooltip.connected", pendingCount) - } else { - ShellTimeBundle.message("statusbar.tooltip.disconnected", pendingCount) + return when (state) { + DaemonState.CONNECTED -> ShellTimeBundle.message("statusbar.tooltip.connected", pendingCount) + DaemonState.DISABLED -> ShellTimeBundle.message("statusbar.tooltip.disabled", pendingCount) + DaemonState.DISCONNECTED -> ShellTimeBundle.message("statusbar.tooltip.disconnected", pendingCount) } } @@ -75,8 +76,8 @@ class ShellTimeStatusBarWidget(project: Project) : EditorBasedWidget(project), S } } - override fun onStatusChanged(connected: Boolean, pendingCount: Int) { - this.connected = connected + override fun onStatusChanged(state: DaemonState, pendingCount: Int) { + this.state = state this.pendingCount = pendingCount // Update the status bar on EDT diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/utils/GitUtils.kt b/src/main/kotlin/xyz/shelltime/jetbrains/utils/GitUtils.kt index d0cd324..a923d71 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/utils/GitUtils.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/utils/GitUtils.kt @@ -22,15 +22,38 @@ object GitUtils { } return try { + // The quick lookup only reads the repository cache. getRepositoryForFile may update + // it synchronously, which the platform reports as an error on the EDT. val repositoryManager = GitRepositoryManager.getInstance(project) - val repository = repositoryManager.getRepositoryForFile(file) + val repository = repositoryManager.getRepositoryForFileQuick(file) repository?.currentBranch?.name ?: "" } catch (e: Exception) { + "" + } catch (e: NoClassDefFoundError) { // Git4Idea plugin may not be available "" } } + /** + * Get the root of the Git repository that contains a file + * + * @return The repository root path, or null when the file isn't in a known repository + */ + fun getRepositoryRoot(project: Project?, file: VirtualFile?): String? { + if (project == null || file == null) { + return null + } + + return try { + GitRepositoryManager.getInstance(project).getRepositoryForFileQuick(file)?.root?.path + } catch (e: Exception) { + null + } catch (e: NoClassDefFoundError) { + null + } + } + /** * Get the current Git branch for a file path * diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/utils/LanguageIds.kt b/src/main/kotlin/xyz/shelltime/jetbrains/utils/LanguageIds.kt new file mode 100644 index 0000000..f725d54 --- /dev/null +++ b/src/main/kotlin/xyz/shelltime/jetbrains/utils/LanguageIds.kt @@ -0,0 +1,132 @@ +package xyz.shelltime.jetbrains.utils + +/** + * Maps IDE file types to VS Code language identifiers, the ids the other ShellTime editor + * plugins report and the web merges on, so one language isn't split across editors + */ +object LanguageIds { + /** Lowercased IntelliJ FileType names */ + private val fileTypeIds = mapOf( + "kotlin" to "kotlin", + "java" to "java", + "javascript" to "javascript", + "jsx harmony" to "javascriptreact", + "typescript" to "typescript", + "typescript jsx" to "typescriptreact", + "python" to "python", + "go" to "go", + "rust" to "rust", + "ruby" to "ruby", + "php" to "php", + "swift" to "swift", + "scala" to "scala", + "groovy" to "groovy", + "dart" to "dart", + "lua" to "lua", + "c#" to "csharp", + "html" to "html", + "css" to "css", + "scss" to "scss", + "less" to "less", + "xml" to "xml", + "json" to "json", + "json5" to "json", + "yaml" to "yaml", + "toml" to "toml", + "markdown" to "markdown", + "sql" to "sql", + "graphql" to "graphql", + "vue.js" to "vue", + "svelte" to "svelte", + "shell script" to "shellscript", + "dockerfile" to "dockerfile", + "makefile" to "makefile", + "properties" to "properties", + "protobuf" to "proto", + "plain_text" to "plaintext" + ) + + /** Lowercased file extensions, for file types above that don't say (TextMate, C/C++) */ + private val extensionIds = mapOf( + "ts" to "typescript", + "mts" to "typescript", + "cts" to "typescript", + "tsx" to "typescriptreact", + "js" to "javascript", + "mjs" to "javascript", + "cjs" to "javascript", + "jsx" to "javascriptreact", + "py" to "python", + "pyi" to "python", + "rs" to "rust", + "rb" to "ruby", + "go" to "go", + "kt" to "kotlin", + "kts" to "kotlin", + "java" to "java", + "swift" to "swift", + "scala" to "scala", + "cs" to "csharp", + "c" to "c", + "h" to "c", + "cpp" to "cpp", + "cc" to "cpp", + "cxx" to "cpp", + "hpp" to "cpp", + "hh" to "cpp", + "hxx" to "cpp", + "m" to "objective-c", + "mm" to "objective-cpp", + "sh" to "shellscript", + "bash" to "shellscript", + "zsh" to "shellscript", + "md" to "markdown", + "mdx" to "mdx", + "yml" to "yaml", + "yaml" to "yaml", + "json" to "json", + "jsonc" to "jsonc", + "toml" to "toml", + "xml" to "xml", + "html" to "html", + "htm" to "html", + "css" to "css", + "scss" to "scss", + "less" to "less", + "sql" to "sql", + "graphql" to "graphql", + "gql" to "graphql", + "vue" to "vue", + "svelte" to "svelte", + "dart" to "dart", + "lua" to "lua", + "php" to "php", + "gradle" to "groovy", + "groovy" to "groovy", + "tf" to "terraform", + "proto" to "proto", + "txt" to "plaintext" + ) + + /** Lowercased file names without an extension */ + private val fileNameIds = mapOf( + "dockerfile" to "dockerfile", + "makefile" to "makefile" + ) + + /** + * @param fileTypeName The IDE's FileType name, e.g. "TypeScript JSX" + * @param extension The file extension without the dot, if any + * @param fileName The file name + */ + fun languageId(fileTypeName: String, extension: String?, fileName: String): String { + val typeName = fileTypeName.lowercase() + fileTypeIds[typeName]?.let { return it } + + val ext = extension?.lowercase() + if (!ext.isNullOrEmpty()) { + return extensionIds[ext] ?: ext + } + return fileNameIds[fileName.lowercase()] ?: typeName + } +} diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/utils/ProjectUtils.kt b/src/main/kotlin/xyz/shelltime/jetbrains/utils/ProjectUtils.kt index 402852d..2cab18d 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/utils/ProjectUtils.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/utils/ProjectUtils.kt @@ -9,42 +9,47 @@ import java.io.File */ object ProjectUtils { /** - * Get the project name (last 2 path components) + * Get the project name (last 2 path components of the project root) * * @param project The current project * @param file The current file * @return The project name */ fun getProjectName(project: Project?, file: VirtualFile?): String { - // Try to get from project first - project?.basePath?.let { basePath -> - return extractLastPathComponents(basePath, 2) + val root = getProjectRootPath(project, file) + if (root.isEmpty()) { + return file?.name ?: "Unknown" } - - // Fall back to file's parent directories - file?.let { f -> - val parent = f.parent?.path ?: return f.name - return extractLastPathComponents(parent, 2) - } - - return "Unknown" + return extractLastPathComponents(root, 2) } /** - * Get the project root path + * Get the project root path: the project's base path for its own files; for a file + * outside it (another attached root, a file opened from elsewhere) the Git repository + * it is in, else its directory, as the other editors report it * * @param project The current project * @param file The current file (fallback) * @return The project root path */ fun getProjectRootPath(project: Project?, file: VirtualFile?): String { - // Try project base path first - project?.basePath?.let { return it } + val basePath = project?.basePath + if (basePath != null && (file == null || isUnder(file.path, basePath))) { + return basePath + } + + GitUtils.getRepositoryRoot(project, file)?.let { return it } // Fall back to file's parent directory file?.parent?.path?.let { return it } - return "" + return basePath ?: "" + } + + private fun isUnder(path: String, root: String): Boolean { + val normalizedPath = path.replace('\\', '/') + val normalizedRoot = root.replace('\\', '/').trimEnd('/') + return normalizedPath == normalizedRoot || normalizedPath.startsWith("$normalizedRoot/") } /** @@ -55,40 +60,7 @@ object ProjectUtils { */ fun getLanguage(file: VirtualFile?): String { if (file == null) return "unknown" - - // Try to get from file type - val fileType = file.fileType - val typeName = fileType.name.lowercase() - - // Map common file types to language IDs - return when (typeName) { - "kotlin" -> "kotlin" - "java" -> "java" - "javascript" -> "javascript" - "typescript" -> "typescript" - "python" -> "python" - "go" -> "go" - "rust" -> "rust" - "c" -> "c" - "c++" -> "cpp" - "ruby" -> "ruby" - "php" -> "php" - "swift" -> "swift" - "scala" -> "scala" - "html" -> "html" - "css" -> "css" - "xml" -> "xml" - "json" -> "json" - "yaml" -> "yaml" - "markdown" -> "markdown" - "sql" -> "sql" - "shell script" -> "shellscript" - "plain_text" -> "plaintext" - else -> { - // Try to infer from extension - file.extension?.lowercase() ?: typeName - } - } + return LanguageIds.languageId(file.fileType.name, file.extension, file.name) } /** @@ -130,15 +102,13 @@ object ProjectUtils { return true } - // Exclude common non-code paths + // Exclude paths that never hold the user's sources. Build output (build/, out/, + // target/) is left to the project's own excluded folders: a package can be named + // build too. val excludePatterns = listOf( "/.idea/", - "/build/", - "/out/", - "/target/", "/node_modules/", "/.gradle/", - "/vendor/", "/__pycache__/" ) diff --git a/src/main/resources/messages/ShellTimeBundle.properties b/src/main/resources/messages/ShellTimeBundle.properties index 1467169..c3e1faf 100644 --- a/src/main/resources/messages/ShellTimeBundle.properties +++ b/src/main/resources/messages/ShellTimeBundle.properties @@ -3,8 +3,10 @@ # Status Bar statusbar.connected=ShellTime statusbar.disconnected=ShellTime (offline) +statusbar.disabled=ShellTime (tracking off) statusbar.tooltip.connected=ShellTime: Connected ({0} pending) statusbar.tooltip.disconnected=ShellTime: Disconnected ({0} pending) +statusbar.tooltip.disabled=ShellTime: The daemon has code tracking turned off ({0} pending). Set codeTracking.enabled to true in your ShellTime config (an older daemon also needs a restart). # Actions action.showStatus.title=ShellTime Status @@ -14,6 +16,7 @@ action.flush.title=Flush Heartbeats action.flush.success=Flushed {0} heartbeats to daemon action.flush.empty=No pending heartbeats to flush action.flush.failed=Failed to flush heartbeats. Daemon may not be running. +action.flush.disabled=The daemon has code tracking turned off, {0} heartbeats kept. Set codeTracking.enabled to true in ~/.shelltime/config.yaml (an older daemon also needs shelltime daemon reinstall). # Settings settings.title=ShellTime Settings @@ -25,3 +28,4 @@ settings.heartbeatInterval=Heartbeat flush interval (ms) # Notifications notification.daemonConnected=Connected to ShellTime daemon notification.daemonDisconnected=Disconnected from ShellTime daemon. Heartbeats will be queued. +notification.trackingDisabled=The ShellTime daemon is not recording editor activity because code tracking is off. Set codeTracking.enabled to true in ~/.shelltime/config.yaml (an older daemon also needs shelltime daemon reinstall). Your activity is kept until then. diff --git a/src/test/kotlin/xyz/shelltime/jetbrains/ThreadDumpOnTimeout.kt b/src/test/kotlin/xyz/shelltime/jetbrains/ThreadDumpOnTimeout.kt new file mode 100644 index 0000000..b691a6d --- /dev/null +++ b/src/test/kotlin/xyz/shelltime/jetbrains/ThreadDumpOnTimeout.kt @@ -0,0 +1,74 @@ +package xyz.shelltime.jetbrains + +import org.junit.jupiter.api.extension.ExtensionContext +import org.junit.jupiter.api.extension.TestExecutionExceptionHandler +import java.io.ByteArrayOutputStream +import java.io.PrintStream +import java.lang.management.ManagementFactory +import java.util.concurrent.TimeoutException +import java.util.concurrent.atomic.AtomicInteger + +/** + * Adds every thread's stack, and the coroutines when the coroutine debug agent runs, to a test + * that timed out: the timeout's own trace shows only the test thread, which is usually parked + * waiting for another one. Add it to a test class with @ExtendWith. + */ +class ThreadDumpOnTimeout : TestExecutionExceptionHandler { + override fun handleTestExecutionException(context: ExtensionContext, throwable: Throwable) { + if (throwable !is TimeoutException) throw throwable + // Later timeouts usually wait on what the first ones left behind + val details = if (dumps.incrementAndGet() <= MAX_DUMPS) { + dump() + } else { + "(threads are dumped for the first $MAX_DUMPS timeouts)" + } + throw AssertionError("${throwable.message}\n$details", throwable) + } + + private fun dump(): String = buildString { + appendLine("JVM arguments: ${ManagementFactory.getRuntimeMXBean().inputArguments}") + for (name in System.getProperties().stringPropertyNames().filter { it.startsWith("kotlinx.coroutines") }.sorted()) { + appendLine("$name=${System.getProperty(name)}") + } + appendLine("kotlin-stdlib ${KotlinVersion.CURRENT}: ${codeSource("kotlin.KotlinVersion")}") + appendLine("kotlinx-coroutines: ${codeSource("kotlinx.coroutines.Job")}") + appendLine("kotlin-reflect: ${codeSource("kotlin.reflect.jvm.internal.KClassImpl")}") + appendLine() + for (info in ManagementFactory.getThreadMXBean().dumpAllThreads(true, true)) { + append("\"${info.threadName}\" #${info.threadId} ${info.threadState}") + info.lockName?.let { append(" on $it") } + info.lockOwnerName?.let { append(" owned by \"$it\"") } + appendLine() + info.stackTrace.forEachIndexed { depth, frame -> + appendLine(" at $frame") + info.lockedMonitors.filter { it.lockedStackDepth == depth }.forEach { appendLine(" - locked $it") } + } + info.lockedSynchronizers.forEach { appendLine(" - holds $it") } + appendLine() + } + append(coroutineDump()) + } + + // The IDE's class loader leaves codeSource empty, so ask for the class file itself + private fun codeSource(className: String): String = try { + val type = Class.forName(className) + type.getResource("${type.simpleName}.class")?.toString() ?: "unknown" + } catch (e: Throwable) { + "not loaded ($e)" + } + + private fun coroutineDump(): String = try { + val probes = Class.forName("kotlinx.coroutines.debug.internal.DebugProbesImpl") + val out = ByteArrayOutputStream() + probes.getMethod("dumpCoroutines", PrintStream::class.java) + .invoke(probes.getField("INSTANCE").get(null), PrintStream(out, true)) + out.toString() + } catch (e: Throwable) { + "No coroutine dump: ${e.cause ?: e}" + } + + companion object { + private const val MAX_DUMPS = 3 + private val dumps = AtomicInteger() + } +} diff --git a/src/test/kotlin/xyz/shelltime/jetbrains/config/ConfigDiscoveryTest.kt b/src/test/kotlin/xyz/shelltime/jetbrains/config/ConfigDiscoveryTest.kt new file mode 100644 index 0000000..75605f2 --- /dev/null +++ b/src/test/kotlin/xyz/shelltime/jetbrains/config/ConfigDiscoveryTest.kt @@ -0,0 +1,72 @@ +package xyz.shelltime.jetbrains.config + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File + +class ConfigDiscoveryTest { + + @TempDir + lateinit var dir: File + + private fun write(name: String, content: String) = File(dir, name).writeText(content) + + @Test + fun `reads config yaml, which shelltime init writes`() { + write("config.yaml", "socketPath: /tmp/yaml.sock\ncodeTracking:\n enabled: false\n") + + val config = ConfigLoader(dir.path).getConfig() + + assertEquals("/tmp/yaml.sock", config.socketPath) + assertFalse(config.enabled) + } + + @Test + fun `prefers config yaml over config yml, like the CLI`() { + write("config.yml", "socketPath: /tmp/yml.sock\n") + write("config.yaml", "socketPath: /tmp/yaml.sock\n") + + assertEquals("/tmp/yaml.sock", ConfigLoader(dir.path).getConfig().socketPath) + } + + @Test + fun `merges config local over the base file`() { + write("config.yaml", "socketPath: /tmp/base.sock\nwebEndpoint: https://shelltime.xyz\ncodeTracking:\n enabled: false\n") + write("config.local.yaml", "socketPath: /tmp/local.sock\ncodeTracking:\n enabled: true\n") + + val config = ConfigLoader(dir.path).getConfig() + + assertEquals("/tmp/local.sock", config.socketPath) + assertEquals("https://shelltime.xyz", config.webEndpoint) + assertTrue(config.enabled) + } + + @Test + fun `a local file that only sets the token keeps tracking as the base says`() { + write("config.yaml", "codeTracking:\n enabled: false\n") + write("config.local.yaml", "codeTracking:\n token: secret\n") + + assertFalse(ConfigLoader(dir.path).getConfig().enabled) + } + + @Test + fun `reloads when a file changes`() { + val loader = ConfigLoader(dir.path) + write("config.yaml", "socketPath: /tmp/first.sock\n") + assertEquals("/tmp/first.sock", loader.getConfig().socketPath) + + write("config.local.yaml", "socketPath: /tmp/second.sock\n") + assertEquals("/tmp/second.sock", loader.getConfig().socketPath) + } + + @Test + fun `defaults without any config file`() { + val config = ConfigLoader(dir.path).getConfig() + + assertTrue(config.enabled) + assertEquals(Constants.DEFAULT_SOCKET_PATH, config.socketPath) + } +} diff --git a/src/test/kotlin/xyz/shelltime/jetbrains/config/ConfigTomlTest.kt b/src/test/kotlin/xyz/shelltime/jetbrains/config/ConfigTomlTest.kt new file mode 100644 index 0000000..bc5f472 --- /dev/null +++ b/src/test/kotlin/xyz/shelltime/jetbrains/config/ConfigTomlTest.kt @@ -0,0 +1,36 @@ +package xyz.shelltime.jetbrains.config + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File + +class ConfigTomlTest { + + @TempDir + lateinit var dir: File + + @Test + fun `reads the keys the CLI writes to config toml`() { + File(dir, "config.toml").writeText( + "APIEndpoint = \"http://localhost:27312\"\nWebEndpoint = \"http://localhost:3302\"\n" + + "socketPath = \"/tmp/toml.sock\"\n\n[codeTracking]\nenabled = true\n" + ) + + val config = ConfigLoader(dir.path).getConfig() + + assertEquals("http://localhost:27312", config.apiEndpoint) + assertEquals("http://localhost:3302", config.webEndpoint) + assertEquals("/tmp/toml.sock", config.socketPath) + assertTrue(config.enabled) + } + + @Test + fun `config yaml wins over an older config toml`() { + File(dir, "config.toml").writeText("socketPath = \"/tmp/old.sock\"\n") + File(dir, "config.yaml").writeText("socketPath: /tmp/new.sock\n") + + assertEquals("/tmp/new.sock", ConfigLoader(dir.path).getConfig().socketPath) + } +} diff --git a/src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatDataTest.kt b/src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatDataTest.kt index c59100f..f476f57 100644 --- a/src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatDataTest.kt +++ b/src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatDataTest.kt @@ -97,13 +97,15 @@ class HeartbeatDataTest { @Test fun `StatusResponse deserializes correctly`() { - val jsonString = """{"version":"1.0.0","uptime":3600,"platform":"darwin/arm64","goVersion":"1.21"}""" + // What the daemon sends (cli/daemon/socket.go handleStatus) + val jsonString = """{"version":"1.0.0","startedAt":"2026-10-24T07:05:59.862759872Z","uptime":"1h 0m 0s","goVersion":"go1.27.1","platform":"darwin/arm64"}""" val response = json.decodeFromString(jsonString) assertEquals("1.0.0", response.version) - assertEquals(3600L, response.uptime) + assertEquals("1h 0m 0s", response.uptime) + assertEquals("2026-10-24T07:05:59.862759872Z", response.startedAt) assertEquals("darwin/arm64", response.platform) - assertEquals("1.21", response.goVersion) + assertEquals("go1.27.1", response.goVersion) } @Test diff --git a/src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatSenderTest.kt b/src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatSenderTest.kt new file mode 100644 index 0000000..c73fb0b --- /dev/null +++ b/src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatSenderTest.kt @@ -0,0 +1,271 @@ +package xyz.shelltime.jetbrains.heartbeat + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Timeout +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.extension.ExtendWith +import xyz.shelltime.jetbrains.ThreadDumpOnTimeout +import xyz.shelltime.jetbrains.config.Constants +import xyz.shelltime.jetbrains.socket.HeartbeatDelivery +import xyz.shelltime.jetbrains.socket.HeartbeatTransport +import java.io.File +import java.nio.file.Files + +/** + * The collector's queue, filled by the tests + */ +private class FakeSource : HeartbeatSource { + private val collected = ArrayDeque() + + @Synchronized + fun collect(heartbeats: List) = collected.addAll(heartbeats) + + @Synchronized + override fun flush(): List = collected.toList().also { collected.clear() } + + @Synchronized + override fun getPendingCount(): Int = collected.size +} + +/** + * A daemon that answers each message with the next reply (OK once they run out), or with send, + * which suspends in the flush's own coroutine + */ +private class FakeDaemon : HeartbeatTransport { + private val messages = mutableListOf>() + val replies = ArrayDeque() + + @Volatile + var send: (suspend () -> HeartbeatDelivery)? = null + + @Volatile + var status: StatusResponse? = null + + @Volatile + var connected = false + + /** The heartbeat ids of each message, in the order they were sent */ + val sentMessages: List> + @Synchronized get() = messages.toList() + + override suspend fun sendHeartbeats(payload: HeartbeatPayload): HeartbeatDelivery { + val reply = synchronized(this) { + messages.add(payload.heartbeats.map { it.heartbeatId }) + replies.removeFirstOrNull() + } + return send?.invoke() ?: reply ?: HeartbeatDelivery.OK + } + + override suspend fun getStatus(): StatusResponse? = status + + override fun isConnected(): Boolean = connected +} + +@ExtendWith(ThreadDumpOnTimeout::class) +class HeartbeatSenderTest { + + private lateinit var source: FakeSource + private lateinit var daemon: FakeDaemon + private lateinit var sender: HeartbeatSender + private lateinit var offlineFile: File + + @BeforeEach + fun setUp() { + source = FakeSource() + daemon = FakeDaemon() + // Never the daemon's real file: a running daemon would upload what lands there + offlineFile = File(Files.createTempDirectory("st-sender").toFile(), "coding-heartbeat.data.log") + sender = HeartbeatSender(source, daemon, offlineFile = offlineFile) + } + + @AfterEach + fun tearDown() { + sender.dispose() + offlineFile.parentFile.deleteRecursively() + } + + private fun collect(heartbeats: List) = source.collect(heartbeats) + + @Test + fun `large backlog is sent in messages of at most MAX_HEARTBEATS_PER_MESSAGE`() = runBlocking { + collect(testHeartbeats(1_200)) + + val result = sender.flush() + + assertEquals(FlushResult(1_200, DaemonState.CONNECTED), result) + assertEquals(listOf(500, 500, 200), daemon.sentMessages.map { it.size }) + assertEquals(testHeartbeats(1_200).map { it.heartbeatId }, daemon.sentMessages.flatten()) + assertEquals(0, sender.getPendingCount()) + } + + @Test + fun `failed message keeps it and the rest for the next flush, oldest first`() = runBlocking { + collect(testHeartbeats(1_200)) + daemon.replies.addAll(listOf(HeartbeatDelivery.OK, HeartbeatDelivery.FAILED)) + + val first = sender.flush() + + assertEquals(FlushResult(500, DaemonState.DISCONNECTED), first) + assertEquals(DaemonState.DISCONNECTED, sender.state) + assertEquals(700, sender.getPendingCount()) + + collect(testHeartbeats(2, prefix = "new")) + val second = sender.flush() + + assertEquals(FlushResult(702, DaemonState.CONNECTED), second) + val expected = testHeartbeats(1_200).drop(500).map { it.heartbeatId } + listOf("new-0", "new-1") + assertEquals(expected, daemon.sentMessages.drop(2).flatten()) + assertEquals(0, sender.getPendingCount()) + } + + @Test + fun `disabled daemon keeps the heartbeats and reports the disabled state`() = runBlocking { + val states = mutableListOf() + sender.setCallback(object : HeartbeatSenderCallback { + override fun onStatusChanged(state: DaemonState, pendingCount: Int) { + states.add(state) + } + }) + collect(testHeartbeats(3)) + daemon.replies.add(HeartbeatDelivery.DISABLED) + + val result = sender.flush() + + assertEquals(FlushResult(0, DaemonState.DISABLED), result) + assertEquals(3, sender.getPendingCount()) + assertEquals(listOf(DaemonState.DISABLED), states) + } + + @Test + fun `queue keeps the newest MAX_PENDING_HEARTBEATS while the daemon is down`() = runBlocking { + daemon.replies.addAll(List(20) { HeartbeatDelivery.FAILED }) + collect(testHeartbeats(Constants.MAX_PENDING_HEARTBEATS, prefix = "old")) + sender.flush() + collect(testHeartbeats(10, prefix = "new")) + sender.flush() + + assertEquals(Constants.MAX_PENDING_HEARTBEATS, sender.getPendingCount()) + val pending = sender.drainPending().map { it.heartbeatId } + assertEquals("old-10", pending.first()) + assertEquals("new-9", pending.last()) + } + + @Test + fun `cancelled flush keeps what was not delivered`() { + collect(testHeartbeats(1_200)) + var calls = 0 + daemon.send = { + calls++ + if (calls == 2) throw CancellationException("IDE closing") + HeartbeatDelivery.OK + } + + assertThrows { runBlocking { sender.flush() } } + + assertEquals(700, sender.getPendingCount()) + } + + @Test + @Timeout(10) + fun `stopAndJoin waits for a periodic flush cancelled mid-send to put its heartbeats back`() { + collect(testHeartbeats(3)) + val sending = CompletableDeferred() + daemon.send = { + sending.complete(Unit) + try { + awaitCancellation() + } finally { + // Closing the interrupted socket takes a moment + withContext(NonCancellable) { delay(200) } + } + } + // MIN_FLUSH_INTERVAL_MS would make the test wait 10 s for the first periodic flush + HeartbeatSender::class.java.getDeclaredField("flushInterval") + .apply { isAccessible = true } + .setLong(sender, 10) + sender.start() + + runBlocking { + sending.await() + sender.stopAndJoin() + } + + assertEquals(listOf("hb-0", "hb-1", "hb-2"), sender.drainPending().map { it.heartbeatId }) + } + + @Test + @Timeout(10) + fun `a flush that fails after the queue was drained leaves its heartbeats in the daemon's file`() { + // A Flush action still sending when the project closes + collect(testHeartbeats(3)) + val sending = CompletableDeferred() + val reply = CompletableDeferred() + daemon.send = { + sending.complete(Unit) + reply.await() + } + + runBlocking { + val flush = async { sender.flush() } + sending.await() + assertEquals(emptyList(), sender.drainPending()) + reply.complete(HeartbeatDelivery.FAILED) + flush.await() + } + + assertEquals(0, sender.getPendingCount()) + val saved = offlineFile.readLines().flatMap { + Json.decodeFromString(HeartbeatPayload.serializer(), it).heartbeats + } + assertEquals(listOf("hb-0", "hb-1", "hb-2"), saved.map { it.heartbeatId }) + } + + @Test + fun `drainPending returns undelivered heartbeats before newly collected ones and empties the queue`() = runBlocking { + collect(testHeartbeats(2, prefix = "old")) + daemon.replies.add(HeartbeatDelivery.FAILED) + sender.flush() + collect(testHeartbeats(1, prefix = "new")) + + val drained = sender.drainPending().map { it.heartbeatId } + + assertEquals(listOf("old-0", "old-1", "new-0"), drained) + assertEquals(0, sender.getPendingCount()) + } + + @Test + fun `empty flush sends nothing`() = runBlocking { + val result = sender.flush() + + assertEquals(0, result.sent) + assertEquals(emptyList>(), daemon.sentMessages) + } + + @Test + fun `refreshState keeps the disabled state while the daemon answers`() = runBlocking { + collect(testHeartbeats(1)) + daemon.replies.add(HeartbeatDelivery.DISABLED) + sender.flush() + daemon.status = StatusResponse(version = "0.1.95") + + sender.refreshState() + assertEquals(DaemonState.DISABLED, sender.state) + + daemon.status = null + daemon.connected = false + sender.refreshState() + assertEquals(DaemonState.DISCONNECTED, sender.state) + } +} diff --git a/src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/OfflineStoreTest.kt b/src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/OfflineStoreTest.kt new file mode 100644 index 0000000..11bd60a --- /dev/null +++ b/src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/OfflineStoreTest.kt @@ -0,0 +1,69 @@ +package xyz.shelltime.jetbrains.heartbeat + +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.io.File +import java.io.RandomAccessFile +import java.nio.file.Files + +class OfflineStoreTest { + + private lateinit var dir: File + private lateinit var file: File + + @BeforeEach + fun setUp() { + dir = Files.createTempDirectory("st-offline").toFile() + file = File(dir, ".shelltime/coding-heartbeat.data.log") + } + + @AfterEach + fun tearDown() { + dir.deleteRecursively() + } + + private fun savedPayloads(): List { + val json = Json { ignoreUnknownKeys = true } + return file.readLines().filter { it.isNotBlank() }.map { json.decodeFromString(it) } + } + + @Test + fun `writes one payload line per message the daemon resends`() { + assertTrue(OfflineStore.save(testHeartbeats(1_100), file)) + + val payloads = savedPayloads() + assertEquals(listOf(500, 500, 100), payloads.map { it.heartbeats.size }) + assertEquals(testHeartbeats(1_100), payloads.flatMap { it.heartbeats }) + } + + @Test + fun `appends to what the daemon has not resent yet`() { + file.parentFile.mkdirs() + file.writeText("{\"heartbeats\":[]}\n") + + assertTrue(OfflineStore.save(testHeartbeats(2), file)) + + val lines = file.readLines() + assertEquals(2, lines.size) + assertEquals("{\"heartbeats\":[]}", lines[0]) + assertEquals(listOf("hb-0", "hb-1"), savedPayloads()[1].heartbeats.map { it.heartbeatId }) + } + + @Test + fun `nothing to save writes no file`() { + assertTrue(OfflineStore.save(emptyList(), file)) + assertFalse(file.exists()) + } + + @Test + fun `stops growing a file no daemon reads`() { + file.parentFile.mkdirs() + RandomAccessFile(file, "rw").use { it.setLength(50L * 1024 * 1024 + 1) } + + assertFalse(OfflineStore.save(testHeartbeats(1), file)) + assertEquals(50L * 1024 * 1024 + 1, file.length()) + } +} diff --git a/src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/TestHeartbeats.kt b/src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/TestHeartbeats.kt new file mode 100644 index 0000000..75d2bf8 --- /dev/null +++ b/src/test/kotlin/xyz/shelltime/jetbrains/heartbeat/TestHeartbeats.kt @@ -0,0 +1,27 @@ +package xyz.shelltime.jetbrains.heartbeat + +/** + * A heartbeat with the given id, for tests that only care which heartbeats travel + */ +fun testHeartbeat(id: String, time: Long = 1704067200): HeartbeatData = HeartbeatData( + heartbeatId = id, + entity = "/project/src/Main.kt", + entityType = "file", + category = "coding", + time = time, + project = "user/project", + projectRootPath = "/project", + branch = "main", + language = "kotlin", + editor = "jetbrains", + editorVersion = "IntelliJ IDEA 2024.1", + plugin = "shelltime", + pluginVersion = "0.0.1", + machine = "laptop", + os = "Linux", + osVersion = "6.1", + isWrite = false +) + +fun testHeartbeats(count: Int, prefix: String = "hb"): List = + (0 until count).map { testHeartbeat("$prefix-$it") } diff --git a/src/test/kotlin/xyz/shelltime/jetbrains/socket/SocketClientTest.kt b/src/test/kotlin/xyz/shelltime/jetbrains/socket/SocketClientTest.kt new file mode 100644 index 0000000..4a4610a --- /dev/null +++ b/src/test/kotlin/xyz/shelltime/jetbrains/socket/SocketClientTest.kt @@ -0,0 +1,206 @@ +package xyz.shelltime.jetbrains.socket + +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Timeout +import org.junit.jupiter.api.extension.ExtendWith +import xyz.shelltime.jetbrains.ThreadDumpOnTimeout +import xyz.shelltime.jetbrains.heartbeat.HeartbeatPayload +import xyz.shelltime.jetbrains.heartbeat.testHeartbeats +import java.io.File +import java.net.StandardProtocolFamily +import java.net.UnixDomainSocketAddress +import java.nio.ByteBuffer +import java.nio.channels.ServerSocketChannel +import java.nio.channels.SocketChannel +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +@ExtendWith(ThreadDumpOnTimeout::class) +class SocketClientTest { + + private lateinit var dir: File + private lateinit var socketPath: String + private var server: ServerSocketChannel? = null + private val received = CopyOnWriteArrayList() + + // The daemon's threads, stopped before the test ends: the IntelliJ test framework fails a test + // that leaves a thread running + private val threads = CopyOnWriteArrayList() + private val release = CountDownLatch(1) + + @BeforeEach + fun setUp() { + dir = Files.createTempDirectory("st-sock").toFile() + socketPath = File(dir, "d.sock").absolutePath + } + + @AfterEach + fun tearDown() { + release.countDown() + server?.close() + threads.forEach { it.join(TimeUnit.SECONDS.toMillis(5)) } + dir.deleteRecursively() + } + + /** + * A daemon that reads one line per connection and answers it with [reply], or never + * answers when [reply] is null + */ + private fun startDaemon(reply: String?) { + startDaemon { _ -> reply } + } + + /** + * A daemon that answers each message with what [reply] returns for it: never when that + * is null, and nothing when it is empty (it closes the connection) + */ + private fun startDaemon(reply: (String) -> String?) { + val channel = ServerSocketChannel.open(StandardProtocolFamily.UNIX) + channel.bind(UnixDomainSocketAddress.of(socketPath)) + server = channel + start { + while (channel.isOpen) { + val conn = try { + channel.accept() + } catch (e: Exception) { + return@start + } + start { serve(conn, reply) } + } + } + } + + private fun start(body: () -> Unit) { + threads.add(Thread(body).apply { isDaemon = true; start() }) + } + + private fun serve(conn: SocketChannel, reply: (String) -> String?) { + conn.use { + val line = StringBuilder() + val buffer = ByteBuffer.allocate(64 * 1024) + while (!line.endsWith("\n")) { + buffer.clear() + if (conn.read(buffer) < 0) return + buffer.flip() + line.append(StandardCharsets.UTF_8.decode(buffer)) + } + val message = line.toString().trim() + received.add(message) + val answer = reply(message) + if (answer == null) { + release.await(30, TimeUnit.SECONDS) + return + } + if (answer.isNotEmpty()) { + conn.write(ByteBuffer.wrap((answer + "\n").toByteArray(StandardCharsets.UTF_8))) + } + } + } + + private fun payload(count: Int) = HeartbeatPayload(heartbeats = testHeartbeats(count)) + + private fun typeOf(message: String) = + Json.parseToJsonElement(message).jsonObject["type"]!!.jsonPrimitive.content + + @Test + fun `ok reply delivers the heartbeats as one heartbeat message`() = runBlocking { + startDaemon("""{"status":"ok"}""") + val client = SocketClient(socketPath) + + assertEquals(HeartbeatDelivery.OK, client.sendHeartbeats(payload(3))) + assertTrue(client.isConnected()) + + assertEquals(1, received.size) + val message = Json.parseToJsonElement(received[0]).jsonObject + assertEquals("heartbeat", message["type"]!!.jsonPrimitive.content) + val heartbeats = message["payload"]!!.jsonObject["heartbeats"]!!.jsonArray + assertEquals(listOf("hb-0", "hb-1", "hb-2"), heartbeats.map { it.jsonObject["heartbeatId"]!!.jsonPrimitive.content }) + } + + @Test + fun `disabled reply is not a delivery`() = runBlocking { + startDaemon("""{"status":"disabled"}""") + val client = SocketClient(socketPath) + + assertEquals(HeartbeatDelivery.DISABLED, client.sendHeartbeats(payload(1))) + // The daemon runs, it only drops editor heartbeats + assertTrue(client.isConnected()) + } + + @Test + fun `reply from an older daemon without a status still counts as delivered`() = runBlocking { + startDaemon("ok") + val client = SocketClient(socketPath) + + assertEquals(HeartbeatDelivery.OK, client.sendHeartbeats(payload(1))) + } + + @Test + fun `no reply from a daemon that still takes connections counts as delivered`() = runBlocking { + // Daemons 0.1.42 to 0.1.47 took heartbeats without replying; 0.1.42 doesn't answer status + startDaemon { _ -> "" } + val client = SocketClient(socketPath) + + assertEquals(HeartbeatDelivery.OK, client.sendHeartbeats(payload(1))) + assertTrue(client.isConnected()) + assertEquals(listOf("heartbeat"), received.map { typeOf(it) }) + } + + @Test + fun `no reply from a daemon that is stopping fails`() = runBlocking { + // A stopping daemon closes its listener, then drops the message it could not queue + startDaemon { _ -> + server?.close() + "" + } + val client = SocketClient(socketPath) + + assertEquals(HeartbeatDelivery.FAILED, client.sendHeartbeats(payload(1))) + assertFalse(client.isConnected()) + assertEquals(listOf("heartbeat"), received.map { typeOf(it) }) + } + + @Test + fun `no daemon fails`() = runBlocking { + val client = SocketClient(socketPath) + + assertEquals(HeartbeatDelivery.FAILED, client.sendHeartbeats(payload(1))) + assertFalse(client.isConnected()) + } + + @Test + @Timeout(15) + fun `daemon that never answers fails after the connection timeout`() = runBlocking { + startDaemon(null) + val client = SocketClient(socketPath) + + val started = System.nanoTime() + assertEquals(HeartbeatDelivery.FAILED, client.sendHeartbeats(payload(1))) + val elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started) + + assertTrue(elapsedMs < 10_000, "took ${elapsedMs}ms") + assertFalse(client.isConnected()) + } + + @Test + fun `status reply is parsed`() = runBlocking { + startDaemon("""{"version":"0.1.95","uptime":"1h2m3s","startedAt":"2026-10-24T08:00:00Z"}""") + val client = SocketClient(socketPath) + + val status = client.getStatus() + assertNotNull(status) + assertEquals("0.1.95", status!!.version) + assertEquals("1h2m3s", status.uptime) + } +} diff --git a/src/test/kotlin/xyz/shelltime/jetbrains/utils/LanguageIdsTest.kt b/src/test/kotlin/xyz/shelltime/jetbrains/utils/LanguageIdsTest.kt new file mode 100644 index 0000000..40b086f --- /dev/null +++ b/src/test/kotlin/xyz/shelltime/jetbrains/utils/LanguageIdsTest.kt @@ -0,0 +1,35 @@ +package xyz.shelltime.jetbrains.utils + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class LanguageIdsTest { + + @Test + fun `file types map to the VS Code language ids`() { + assertEquals("typescriptreact", LanguageIds.languageId("TypeScript JSX", "tsx", "App.tsx")) + assertEquals("typescript", LanguageIds.languageId("TypeScript", "ts", "index.ts")) + assertEquals("csharp", LanguageIds.languageId("C#", "cs", "Program.cs")) + assertEquals("shellscript", LanguageIds.languageId("Shell Script", "sh", "run.sh")) + assertEquals("plaintext", LanguageIds.languageId("PLAIN_TEXT", "txt", "notes.txt")) + assertEquals("kotlin", LanguageIds.languageId("Kotlin", "kts", "build.gradle.kts")) + } + + @Test + fun `file types that don't say fall back to the extension`() { + // TextMate highlighting in IDEs without the language plugin, CLion's C/C++ + assertEquals("python", LanguageIds.languageId("TextMate", "py", "main.py")) + assertEquals("rust", LanguageIds.languageId("TextMate", "rs", "lib.rs")) + assertEquals("cpp", LanguageIds.languageId("ObjectiveC", "hpp", "util.hpp")) + assertEquals("c", LanguageIds.languageId("ObjectiveC", "h", "util.h")) + assertEquals("groovy", LanguageIds.languageId("Gradle", "gradle", "build.gradle")) + assertEquals("zig", LanguageIds.languageId("TextMate", "zig", "main.zig")) + } + + @Test + fun `files without an extension use their name`() { + assertEquals("dockerfile", LanguageIds.languageId("Dockerfile", null, "Dockerfile")) + assertEquals("makefile", LanguageIds.languageId("TextMate", null, "Makefile")) + assertEquals("unknown file type", LanguageIds.languageId("Unknown File Type", null, "LICENSE")) + } +} diff --git a/src/test/kotlin/xyz/shelltime/jetbrains/utils/ProjectUtilsTest.kt b/src/test/kotlin/xyz/shelltime/jetbrains/utils/ProjectUtilsTest.kt index 954b759..0c19a09 100644 --- a/src/test/kotlin/xyz/shelltime/jetbrains/utils/ProjectUtilsTest.kt +++ b/src/test/kotlin/xyz/shelltime/jetbrains/utils/ProjectUtilsTest.kt @@ -17,10 +17,12 @@ class ProjectUtilsTest { } @Test - fun `shouldExclude returns true for build directories`() { - assertTrue(ProjectUtils.shouldExclude("/project/build/classes/Main.class")) - assertTrue(ProjectUtils.shouldExclude("/project/out/production/file.class")) - assertTrue(ProjectUtils.shouldExclude("/project/target/classes/Main.class")) + fun `shouldExclude leaves build directory names to the project's excluded folders`() { + // Real sources live in packages named build, out, target or vendor + assertFalse(ProjectUtils.shouldExclude("/project/src/main/kotlin/com/acme/build/Foo.kt", "/project")) + assertFalse(ProjectUtils.shouldExclude("/project/internal/build/build.go", "/project")) + assertFalse(ProjectUtils.shouldExclude("/project/vendor/lib/lib.go", "/project")) + assertFalse(ProjectUtils.shouldExclude("/project/target/classes/Main.class", "/project")) } @Test @@ -45,7 +47,7 @@ class ProjectUtilsTest { @Test fun `shouldExclude still excludes directories inside the project`() { - assertTrue(ProjectUtils.shouldExclude("/home/ci/build/repo/build/classes/Main.class", "/home/ci/build/repo")) + assertTrue(ProjectUtils.shouldExclude("/home/ci/build/repo/.idea/workspace.xml", "/home/ci/build/repo")) assertTrue(ProjectUtils.shouldExclude("/home/ci/build/repo/.git/config", "/home/ci/build/repo")) assertTrue(ProjectUtils.shouldExclude("/app/web/node_modules/pkg/index.js", "/app")) } @@ -53,7 +55,7 @@ class ProjectUtilsTest { @Test fun `shouldExclude checks the full path for files outside the project`() { assertTrue(ProjectUtils.shouldExclude("/other/node_modules/pkg/index.js", "/app")) - assertTrue(ProjectUtils.shouldExclude("/application/build/Main.class", "/app")) + assertTrue(ProjectUtils.shouldExclude("/application/.gradle/caches/file", "/app")) } @Test diff --git a/src/test/kotlin/xyz/shelltime/jetbrains/version/VersionCheckerTest.kt b/src/test/kotlin/xyz/shelltime/jetbrains/version/VersionCheckerTest.kt index 161c23a..d77027c 100644 --- a/src/test/kotlin/xyz/shelltime/jetbrains/version/VersionCheckerTest.kt +++ b/src/test/kotlin/xyz/shelltime/jetbrains/version/VersionCheckerTest.kt @@ -1,39 +1,80 @@ package xyz.shelltime.jetbrains.version -import io.mockk.* +import com.sun.net.httpserver.HttpServer import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test -import org.junit.jupiter.api.Assertions.* -import java.io.ByteArrayInputStream -import java.net.HttpURLConnection -import java.net.URL - +import xyz.shelltime.jetbrains.config.Constants +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.ServerSocket +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit + +/** + * Runs the checker against a local HTTP server. Mocking URL's constructors instead routed every URL + * in the JVM through the mock, the class loader's resource reads included, so a library that + * initialized during a test (kotlin-reflect, for MockK) read the mock's stream and hung. + */ class VersionCheckerTest { + private lateinit var server: HttpServer + private val requests = LinkedBlockingQueue() + + @Volatile + private var status = 200 + + @Volatile + private var body = "" + @BeforeEach fun setup() { - mockkConstructor(URL::class) + server = HttpServer.create(InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0) + server.createContext("/") { exchange -> + try { + requests.add("${exchange.requestURI.rawPath}?${exchange.requestURI.rawQuery}") + val bytes = body.toByteArray() + exchange.responseHeaders.add("Content-Type", "application/json") + // No keep-alive connection, so no JDK thread outlives the test + exchange.responseHeaders.add("Connection", "close") + exchange.sendResponseHeaders(status, if (bytes.isEmpty()) -1 else bytes.size.toLong()) + if (bytes.isNotEmpty()) exchange.responseBody.write(bytes) + } finally { + exchange.close() + } + } + server.start() } @AfterEach fun teardown() { - unmockkAll() + server.stop(0) } + private val apiEndpoint get() = "http://127.0.0.1:${server.address.port}" + + private fun respond(status: Int, body: String) { + this.status = status + this.body = body + } + + /** The next request the checker made, waiting for it to arrive */ + private fun nextRequest(): String? = requests.poll(10, TimeUnit.SECONDS) + @Test fun `dispose cancels coroutine scope without errors`() { - val checker = VersionChecker("https://api.test.com", "https://web.test.com") + val checker = VersionChecker(apiEndpoint, "https://web.test.com") assertDoesNotThrow { checker.dispose() } } @Test fun `checkVersion handles connection failure gracefully`() = runBlocking { - every { anyConstructed().openConnection() } throws java.io.IOException("Connection failed") - - val checker = VersionChecker("https://api.test.com", "https://web.test.com") + // A port nothing listens on + val port = ServerSocket(0, 0, InetAddress.getLoopbackAddress()).use { it.localPort } + val checker = VersionChecker("http://127.0.0.1:$port", "https://web.test.com") // Should not throw - version check failures are silent assertDoesNotThrow { checker.checkVersion("1.0.0", null) } @@ -41,154 +82,85 @@ class VersionCheckerTest { // Allow time for coroutine to execute delay(200) - // Verify the connection was attempted - verify { anyConstructed().openConnection() } - checker.dispose() } @Test fun `checkVersion handles HTTP error response gracefully`() = runBlocking { - val mockConnection = mockk(relaxed = true) - every { anyConstructed().openConnection() } returns mockConnection - every { mockConnection.responseCode } returns 500 - - val checker = VersionChecker("https://api.test.com", "https://web.test.com") + respond(500, "") + val checker = VersionChecker(apiEndpoint, "https://web.test.com") // Should not throw - HTTP errors are handled gracefully assertDoesNotThrow { checker.checkVersion("1.0.0", null) } - // Allow time for coroutine to execute - delay(200) - - // Verify HTTP error was handled - verify { mockConnection.responseCode } - verify { mockConnection.disconnect() } + assertEquals("${Constants.VERSION_CHECK_ENDPOINT}?version=1.0.0", nextRequest()) checker.dispose() } @Test fun `checkVersion shows notification when update is available`() = runBlocking { - val mockConnection = mockk(relaxed = true) - every { anyConstructed().openConnection() } returns mockConnection - every { mockConnection.responseCode } returns 200 - every { mockConnection.inputStream } returns ByteArrayInputStream( - """{"isLatest":false,"latestVersion":"2.0.0","version":"1.0.0"}""".toByteArray() - ) - - val checker = VersionChecker("https://api.test.com", "https://web.test.com") + respond(200, """{"isLatest":false,"latestVersion":"2.0.0","version":"1.0.0"}""") + val checker = VersionChecker(apiEndpoint, "https://web.test.com") assertDoesNotThrow { checker.checkVersion("1.0.0", null) } - // Allow time for coroutine to execute - delay(200) - - // Verify the connection was made and response was read - verify { mockConnection.responseCode } - verify { mockConnection.inputStream } - verify { mockConnection.disconnect() } + assertEquals("${Constants.VERSION_CHECK_ENDPOINT}?version=1.0.0", nextRequest()) checker.dispose() } @Test fun `checkVersion does not show notification when version is up to date`() = runBlocking { - val mockConnection = mockk(relaxed = true) - every { anyConstructed().openConnection() } returns mockConnection - every { mockConnection.responseCode } returns 200 - every { mockConnection.inputStream } returns ByteArrayInputStream( - """{"isLatest":true,"latestVersion":"1.0.0","version":"1.0.0"}""".toByteArray() - ) - - val checker = VersionChecker("https://api.test.com", "https://web.test.com") + respond(200, """{"isLatest":true,"latestVersion":"1.0.0","version":"1.0.0"}""") + val checker = VersionChecker(apiEndpoint, "https://web.test.com") assertDoesNotThrow { checker.checkVersion("1.0.0", null) } - // Allow time for coroutine to execute - delay(200) - - // Verify the connection was made and response was read - verify { mockConnection.responseCode } - verify { mockConnection.inputStream } - verify { mockConnection.disconnect() } + assertEquals("${Constants.VERSION_CHECK_ENDPOINT}?version=1.0.0", nextRequest()) checker.dispose() } @Test fun `version check encodes special characters in version`() = runBlocking { - val mockConnection = mockk(relaxed = true) - val mockInputStream = mockk(relaxed = true) - val mockBufferedReader = mockk(relaxed = true) + respond(200, """{"isLatest":true,"latestVersion":"1.0.0","version":"1.0.0"}""") + val checker = VersionChecker(apiEndpoint, "https://web.test.com") - every { anyConstructed().openConnection() } returns mockConnection - every { mockConnection.responseCode } returns 200 - every { mockConnection.inputStream } returns ByteArrayInputStream( - """{"isLatest":true,"latestVersion":"1.0.0","version":"1.0.0"}""".toByteArray() - ) - - val checker = VersionChecker("https://api.test.com", "https://web.test.com") - - // Version with special characters should be URL-encoded assertDoesNotThrow { checker.checkVersion("1.0.0-beta+build.123", null) } - // Allow time for coroutine to execute - delay(200) - - // Verify the connection was made (URL encoding happens in the URL construction) - verify { anyConstructed().openConnection() } - verify { mockConnection.disconnect() } + assertEquals("${Constants.VERSION_CHECK_ENDPOINT}?version=1.0.0-beta%2Bbuild.123", nextRequest()) checker.dispose() } @Test fun `checkVersion handles malformed JSON response gracefully`() = runBlocking { - val mockConnection = mockk(relaxed = true) - every { anyConstructed().openConnection() } returns mockConnection - every { mockConnection.responseCode } returns 200 - every { mockConnection.inputStream } returns ByteArrayInputStream( - """{"invalid json""".toByteArray() - ) - - val checker = VersionChecker("https://api.test.com", "https://web.test.com") + respond(200, """{"invalid json""") + val checker = VersionChecker(apiEndpoint, "https://web.test.com") // Should not throw - JSON parse errors are handled gracefully assertDoesNotThrow { checker.checkVersion("1.0.0", null) } - // Allow time for coroutine to execute - delay(200) - - // Verify the connection was attempted and response was read - verify { mockConnection.responseCode } - verify { mockConnection.inputStream } - verify { mockConnection.disconnect() } + assertNotNull(nextRequest()) checker.dispose() } @Test fun `hasShownWarning flag prevents multiple notifications`() = runBlocking { - val mockConnection = mockk(relaxed = true) - every { anyConstructed().openConnection() } returns mockConnection - every { mockConnection.responseCode } returns 200 - every { mockConnection.inputStream } returns ByteArrayInputStream( - """{"isLatest":false,"latestVersion":"2.0.0","version":"1.0.0"}""".toByteArray() - ) - - val checker = VersionChecker("https://api.test.com", "https://web.test.com") + respond(200, """{"isLatest":false,"latestVersion":"2.0.0","version":"1.0.0"}""") + val checker = VersionChecker(apiEndpoint, "https://web.test.com") // First call should trigger check checker.checkVersion("1.0.0", null) - delay(200) + assertNotNull(nextRequest()) + // Allow time for the checker to read the reply + delay(500) // Second call should be skipped checker.checkVersion("1.0.0", null) - delay(200) - - // Should only open connection once - verify(exactly = 1) { anyConstructed().openConnection() } + assertNull(requests.poll(500, TimeUnit.MILLISECONDS)) checker.dispose() }