diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/config/ShellTimeConfigurable.kt b/src/main/kotlin/xyz/shelltime/jetbrains/config/ShellTimeConfigurable.kt index 9b6cdc0..11788f4 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/config/ShellTimeConfigurable.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/config/ShellTimeConfigurable.kt @@ -1,11 +1,14 @@ package xyz.shelltime.jetbrains.config +import com.intellij.openapi.components.service import com.intellij.openapi.options.Configurable +import com.intellij.openapi.project.ProjectManager import com.intellij.ui.components.JBCheckBox import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBTextField import com.intellij.util.ui.FormBuilder import xyz.shelltime.jetbrains.ShellTimeBundle +import xyz.shelltime.jetbrains.services.ShellTimeProjectService import xyz.shelltime.jetbrains.services.ShellTimeService import javax.swing.JComponent import javax.swing.JPanel @@ -72,6 +75,11 @@ class ShellTimeConfigurable : Configurable { heartbeatInterval = heartbeatIntervalField?.text?.toLongOrNull() ?: Constants.DEFAULT_FLUSH_INTERVAL_MS ) + + // Apply the change to the running trackers, not only to the stored settings + ProjectManager.getInstance().openProjects + .filter { !it.isDisposed } + .forEach { it.service().updateSettings() } } override fun reset() { diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatCollector.kt b/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatCollector.kt index 3ed1079..91484be 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatCollector.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatCollector.kt @@ -4,6 +4,7 @@ import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationInfo import com.intellij.openapi.editor.Document import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.EditorFactory import com.intellij.openapi.editor.event.CaretEvent import com.intellij.openapi.editor.event.CaretListener import com.intellij.openapi.editor.event.DocumentEvent @@ -11,6 +12,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.util.Disposer import com.intellij.openapi.vfs.VirtualFile import com.intellij.xdebugger.XDebuggerManager import xyz.shelltime.jetbrains.config.Constants @@ -37,9 +39,13 @@ class HeartbeatCollector( ) : Disposable { private val logger = Logger("Collector", debug) private val lastHeartbeat = ConcurrentHashMap() + @Volatile private var lastActivity: LastActivityState? = null private val pendingHeartbeats = ConcurrentLinkedQueue() + /** Owns the editor listeners while collecting; null when stopped */ + private var listenerDisposable: Disposable? = null + private val documentListener = object : DocumentListener { override fun documentChanged(event: DocumentEvent) { handleDocumentChange(event) @@ -52,31 +58,61 @@ class HeartbeatCollector( } } + /** + * Start listening to edits and cursor moves in this project's editors. + * + * The listeners are added to the editor event multicaster, so they also cover + * editors that were opened before this collector existed (restored tabs). + * + * @param parentDisposable Disposed together with the listeners + */ + fun start(parentDisposable: Disposable) { + if (listenerDisposable != null) return + + val disposable = Disposer.newDisposable(parentDisposable, "ShellTimeHeartbeatListeners") + val multicaster = EditorFactory.getInstance().eventMulticaster + multicaster.addDocumentListener(documentListener, disposable) + multicaster.addCaretListener(caretListener, disposable) + listenerDisposable = disposable + + logger.log("Started collecting heartbeats") + } + + /** + * Stop listening to editor events. Already queued heartbeats are kept. + */ + fun stop() { + val disposable = listenerDisposable ?: return + listenerDisposable = null + Disposer.dispose(disposable) + logger.log("Stopped collecting heartbeats") + } + + /** + * Navigation events (caret moves, file switches) can repeat without the user + * doing anything, so they are dropped when the file and cursor are unchanged. + * Edits always count, even when they leave the caret in place. + */ + private fun isDuplicateNavigation(filePath: String, lineNumber: Int?, cursorPosition: Int?): Boolean { + val current = LastActivityState(filePath, lineNumber, cursorPosition) + val isDuplicate = lastActivity == current + lastActivity = current + + if (isDuplicate) { + logger.log("Skipping duplicate activity for $filePath") + } + return isDuplicate + } + /** * Check if a heartbeat should be sent for this file */ - private fun shouldSendHeartbeat( - filePath: String, - isWrite: Boolean, - lineNumber: Int?, - cursorPosition: Int? - ): Boolean { + private fun shouldSendHeartbeat(filePath: String, isWrite: Boolean): Boolean { // Saves always trigger heartbeats if (isWrite) { return true } - // Check for duplicate activity - lastActivity?.let { last -> - if (last.entity == filePath && - last.lineNumber == lineNumber && - last.cursorPosition == cursorPosition - ) { - logger.log("Skipping duplicate activity for $filePath") - return false - } - } - // Check debounce interval val now = System.currentTimeMillis() val lastTime = lastHeartbeat[filePath] ?: 0 @@ -85,8 +121,6 @@ class HeartbeatCollector( return false } - // Update last activity state - lastActivity = LastActivityState(filePath, lineNumber, cursorPosition) lastHeartbeat[filePath] = now return true } @@ -146,10 +180,21 @@ class HeartbeatCollector( private fun isValidFile(file: VirtualFile?): Boolean { if (file == null) return false if (!file.isInLocalFileSystem) return false - if (ProjectUtils.shouldExclude(file.path)) return false + if (ProjectUtils.shouldExclude(file.path, project.basePath)) return false return true } + /** + * Find an editor of this project that shows the document, preferring the selected one + */ + private fun findEditor(document: Document): Editor? { + val selected = FileEditorManager.getInstance(project).selectedTextEditor + if (selected != null && selected.document == document) { + return selected + } + return EditorFactory.getInstance().getEditors(document, project).firstOrNull() + } + /** * Handle document change events */ @@ -159,11 +204,15 @@ class HeartbeatCollector( if (!isValidFile(file)) return - val editor = getActiveEditor() - val lineNumber = editor?.let { it.caretModel.logicalPosition.line + 1 } - val cursorPosition = editor?.caretModel?.logicalPosition?.column + // 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 + val lineNumber = editor.caretModel.logicalPosition.line + 1 + val cursorPosition = editor.caretModel.logicalPosition.column + + lastActivity = LastActivityState(file.path, lineNumber, cursorPosition) - if (shouldSendHeartbeat(file.path, false, lineNumber, cursorPosition)) { + if (shouldSendHeartbeat(file.path, false)) { val heartbeat = createHeartbeat(file, document, false, lineNumber, cursorPosition) addHeartbeat(heartbeat) } @@ -174,6 +223,8 @@ class HeartbeatCollector( */ private fun handleCaretChange(event: CaretEvent) { val editor = event.editor + if (editor.project != project) return + val document = editor.document val file = FileDocumentManager.getInstance().getFile(document) ?: return @@ -182,7 +233,9 @@ class HeartbeatCollector( val lineNumber = event.newPosition.line + 1 val cursorPosition = event.newPosition.column - if (shouldSendHeartbeat(file.path, false, lineNumber, cursorPosition)) { + if (isDuplicateNavigation(file.path, lineNumber, cursorPosition)) return + + if (shouldSendHeartbeat(file.path, false)) { val heartbeat = createHeartbeat(file, document, false, lineNumber, cursorPosition) addHeartbeat(heartbeat) } @@ -195,11 +248,13 @@ class HeartbeatCollector( if (!isValidFile(file)) return val document = FileDocumentManager.getInstance().getDocument(file!!) ?: return - val editor = getActiveEditor() + val editor = findEditor(document) val lineNumber = editor?.let { it.caretModel.logicalPosition.line + 1 } val cursorPosition = editor?.caretModel?.logicalPosition?.column - if (shouldSendHeartbeat(file.path, false, lineNumber, cursorPosition)) { + if (isDuplicateNavigation(file.path, lineNumber, cursorPosition)) return + + if (shouldSendHeartbeat(file.path, false)) { val heartbeat = createHeartbeat(file, document, false, lineNumber, cursorPosition) addHeartbeat(heartbeat) } @@ -212,7 +267,7 @@ class HeartbeatCollector( if (!isValidFile(file)) return val document = FileDocumentManager.getInstance().getDocument(file!!) ?: return - val editor = getActiveEditor() + val editor = findEditor(document) val lineNumber = editor?.let { it.caretModel.logicalPosition.line + 1 } val cursorPosition = editor?.caretModel?.logicalPosition?.column @@ -225,21 +280,6 @@ class HeartbeatCollector( lastHeartbeat[file.path] = System.currentTimeMillis() } - /** - * Get the active editor for the project - */ - private fun getActiveEditor(): Editor? { - return FileEditorManager.getInstance(project).selectedTextEditor - } - - /** - * Register listeners for an editor - */ - fun registerEditor(editor: Editor) { - editor.document.addDocumentListener(documentListener, this) - editor.caretModel.addCaretListener(caretListener, this) - } - /** * Flush pending heartbeats * @@ -268,6 +308,7 @@ class HeartbeatCollector( } override fun dispose() { + stop() lastHeartbeat.clear() pendingHeartbeats.clear() logger.log("Disposed heartbeat collector") diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/listeners/FileEditorListener.kt b/src/main/kotlin/xyz/shelltime/jetbrains/listeners/FileEditorListener.kt index 257417f..1c39c9b 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/listeners/FileEditorListener.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/listeners/FileEditorListener.kt @@ -1,12 +1,15 @@ package xyz.shelltime.jetbrains.listeners import com.intellij.openapi.components.service +import com.intellij.openapi.editor.Document import com.intellij.openapi.fileEditor.FileDocumentManager import com.intellij.openapi.fileEditor.FileDocumentManagerListener import com.intellij.openapi.fileEditor.FileEditorManager import com.intellij.openapi.fileEditor.FileEditorManagerEvent import com.intellij.openapi.fileEditor.FileEditorManagerListener import com.intellij.openapi.project.Project +import com.intellij.openapi.project.ProjectLocator +import com.intellij.openapi.project.ProjectManager import com.intellij.openapi.vfs.VirtualFile import xyz.shelltime.jetbrains.services.ShellTimeProjectService @@ -31,14 +34,19 @@ class FileEditorListener(private val project: Project) : FileEditorManagerListen */ class FileSaveListener : FileDocumentManagerListener { - override fun beforeDocumentSaving(document: com.intellij.openapi.editor.Document) { + override fun beforeDocumentSaving(document: Document) { val file = FileDocumentManager.getInstance().getFile(document) ?: return + val project = findProject(file) ?: return - // Notify all open projects about the save - com.intellij.openapi.project.ProjectManager.getInstance().openProjects.forEach { project -> - if (!project.isDisposed) { - project.service().onFileSave(file) - } - } + // Only the project the file belongs to records the save. Notifying every + // open project produced one write heartbeat per project, each attributed + // to that project. + project.service().onFileSave(file) + } + + private fun findProject(file: VirtualFile): Project? { + val openProjects = ProjectManager.getInstance().openProjects.filter { !it.isDisposed } + return openProjects.firstOrNull { FileEditorManager.getInstance(it).isFileOpen(file) } + ?: ProjectLocator.getInstance().guessProjectForFile(file)?.takeIf { !it.isDisposed } } } diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/services/ShellTimeProjectService.kt b/src/main/kotlin/xyz/shelltime/jetbrains/services/ShellTimeProjectService.kt index 1f67e3a..429bf83 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/services/ShellTimeProjectService.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/services/ShellTimeProjectService.kt @@ -2,16 +2,12 @@ package xyz.shelltime.jetbrains.services import com.intellij.openapi.Disposable import com.intellij.openapi.components.Service -import com.intellij.openapi.components.service -import com.intellij.openapi.editor.EditorFactory -import com.intellij.openapi.editor.event.EditorFactoryEvent -import com.intellij.openapi.editor.event.EditorFactoryListener -import com.intellij.openapi.fileEditor.FileDocumentManager import com.intellij.openapi.project.Project import com.intellij.openapi.vfs.VirtualFile import xyz.shelltime.jetbrains.heartbeat.HeartbeatCollector import xyz.shelltime.jetbrains.heartbeat.HeartbeatSender import xyz.shelltime.jetbrains.heartbeat.HeartbeatSenderCallback +import xyz.shelltime.jetbrains.utils.SystemUtils import xyz.shelltime.jetbrains.version.VersionChecker import kotlinx.coroutines.* @@ -31,16 +27,9 @@ class ShellTimeProjectService(private val project: Project) : Disposable { private var statusCallback: HeartbeatSenderCallback? = null private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - private val editorFactoryListener = object : EditorFactoryListener { - override fun editorCreated(event: EditorFactoryEvent) { - val editor = event.editor - val editorProject = editor.project - - // Only register listeners for editors in this project - if (editorProject == project) { - collector.registerEditor(editor) - } - } + 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 } init { @@ -71,8 +60,11 @@ class ShellTimeProjectService(private val project: Project) : Disposable { // Set status callback if already registered statusCallback?.let { sender.setCallback(it) } + // Resolve the machine name once, off the EDT + scope.launch { SystemUtils.getMachineName() } + // Register for editor events - EditorFactory.getInstance().addEditorFactoryListener(editorFactoryListener, this) + collector.start(this) // Start the sender sender.start() @@ -158,25 +150,45 @@ class ShellTimeProjectService(private val project: Project) : Disposable { */ fun updateSettings() { if (!settings.enabled) { - // Disable tracking + // Disable tracking, but still send what was collected before + if (::collector.isInitialized) { + collector.stop() + } if (::sender.isInitialized) { sender.stop() + scope.launch { sender.flush() } } return } - if (::collector.isInitialized) { - collector.setDebug(settings.debug) + if (!::collector.isInitialized) { + // Tracking was disabled when the project opened + start() + return } - if (::sender.isInitialized) { - sender.setDebug(settings.debug) - sender.setFlushInterval(settings.heartbeatInterval) - } + collector.setDebug(settings.debug) + collector.start(this) + + sender.setDebug(settings.debug) + sender.setFlushInterval(settings.heartbeatInterval) + sender.start() } override fun dispose() { + if (::collector.isInitialized) { + 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) { + withTimeoutOrNull(FINAL_FLUSH_TIMEOUT_MS) { sender.flush() } + } + } catch (e: Exception) { + // Best effort only + } sender.dispose() } if (::collector.isInitialized) { diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/utils/ProjectUtils.kt b/src/main/kotlin/xyz/shelltime/jetbrains/utils/ProjectUtils.kt index cfaa020..402852d 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/utils/ProjectUtils.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/utils/ProjectUtils.kt @@ -108,12 +108,25 @@ object ProjectUtils { /** * Check if a file should be excluded from tracking * + * Patterns are matched against the path inside the project, so a project that + * itself lives under a directory like `build/` or `out/` is still tracked. + * * @param filePath The file path to check + * @param projectBasePath The project root, if known * @return True if the file should be excluded */ - fun shouldExclude(filePath: String): Boolean { + fun shouldExclude(filePath: String, projectBasePath: String? = null): Boolean { + val normalizedPath = filePath.replace('\\', '/') + val basePath = projectBasePath?.replace('\\', '/')?.trimEnd('/') + val path = if (!basePath.isNullOrEmpty() && normalizedPath.startsWith("$basePath/")) { + // Keep the leading slash so top-level directories still match + normalizedPath.substring(basePath.length) + } else { + normalizedPath + } + // Exclude .git directory - if (filePath.contains("/.git/") || filePath.contains("\\.git\\")) { + if (path.contains("/.git/")) { return true } @@ -130,7 +143,7 @@ object ProjectUtils { ) for (pattern in excludePatterns) { - if (filePath.contains(pattern)) { + if (path.contains(pattern)) { return true } } diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/utils/SystemUtils.kt b/src/main/kotlin/xyz/shelltime/jetbrains/utils/SystemUtils.kt index 3960f49..3831147 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/utils/SystemUtils.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/utils/SystemUtils.kt @@ -1,7 +1,9 @@ package xyz.shelltime.jetbrains.utils +import java.io.File import java.net.InetAddress import java.util.UUID +import java.util.concurrent.TimeUnit /** * System-related utility functions @@ -28,16 +30,51 @@ object SystemUtils { return System.getProperty("os.version") ?: "Unknown" } + private val cachedMachineName: String by lazy { resolveMachineName() } + /** * Get the machine hostname + * + * This must match the name the ShellTime CLI and the other editor plugins report + * (plain gethostname()), because the server groups activity into sessions per + * machine. InetAddress.getLocalHost().hostName is avoided: the JDK resolves the + * name through DNS, which can return a different name (e.g. "host.lan" or an + * FQDN) and can block. The result is computed once. */ - fun getMachineName(): String { + fun getMachineName(): String = cachedMachineName + + private fun resolveMachineName(): String { + return readLinuxHostname() + ?: runHostnameCommand() + ?: System.getenv("COMPUTERNAME")?.takeIf { it.isNotBlank() } + ?: System.getenv("HOSTNAME")?.takeIf { it.isNotBlank() } + ?: try { + InetAddress.getLocalHost().hostName + } catch (e: Exception) { + null + } + ?: "Unknown" + } + + private fun readLinuxHostname(): String? { + return try { + File("/proc/sys/kernel/hostname").takeIf { it.isFile }?.readText()?.trim()?.takeIf { it.isNotEmpty() } + } catch (e: Exception) { + null + } + } + + private fun runHostnameCommand(): String? { return try { - InetAddress.getLocalHost().hostName + val process = ProcessBuilder("hostname").redirectErrorStream(true).start() + if (!process.waitFor(2, TimeUnit.SECONDS)) { + process.destroyForcibly() + return null + } + if (process.exitValue() != 0) return null + process.inputStream.bufferedReader().readText().trim().takeIf { it.isNotEmpty() } } catch (e: Exception) { - System.getenv("COMPUTERNAME") - ?: System.getenv("HOSTNAME") - ?: "Unknown" + null } } diff --git a/src/test/kotlin/xyz/shelltime/jetbrains/utils/ProjectUtilsTest.kt b/src/test/kotlin/xyz/shelltime/jetbrains/utils/ProjectUtilsTest.kt index 30e7282..954b759 100644 --- a/src/test/kotlin/xyz/shelltime/jetbrains/utils/ProjectUtilsTest.kt +++ b/src/test/kotlin/xyz/shelltime/jetbrains/utils/ProjectUtilsTest.kt @@ -36,6 +36,32 @@ class ProjectUtilsTest { assertFalse(ProjectUtils.shouldExclude("/project/index.js")) } + @Test + fun `shouldExclude ignores excluded directory names above the project root`() { + assertFalse(ProjectUtils.shouldExclude("/home/ci/build/repo/src/Main.kt", "/home/ci/build/repo")) + assertFalse(ProjectUtils.shouldExclude("/Users/me/out/app/index.js", "/Users/me/out/app/")) + assertFalse(ProjectUtils.shouldExclude("/go/src/vendor/tool/main.go", "/go/src/vendor/tool")) + } + + @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/.git/config", "/home/ci/build/repo")) + assertTrue(ProjectUtils.shouldExclude("/app/web/node_modules/pkg/index.js", "/app")) + } + + @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")) + } + + @Test + fun `shouldExclude handles Windows separators`() { + assertTrue(ProjectUtils.shouldExclude("C:\\project\\.git\\config")) + assertFalse(ProjectUtils.shouldExclude("C:\\build\\project\\src\\Main.kt", "C:\\build\\project")) + } + @Test fun `getProjectName returns null project gracefully`() { val name = ProjectUtils.getProjectName(null, null) diff --git a/src/test/kotlin/xyz/shelltime/jetbrains/utils/SystemUtilsTest.kt b/src/test/kotlin/xyz/shelltime/jetbrains/utils/SystemUtilsTest.kt index fc278f0..e5fdc5a 100644 --- a/src/test/kotlin/xyz/shelltime/jetbrains/utils/SystemUtilsTest.kt +++ b/src/test/kotlin/xyz/shelltime/jetbrains/utils/SystemUtilsTest.kt @@ -1,7 +1,9 @@ package xyz.shelltime.jetbrains.utils import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Assumptions.assumeTrue import org.junit.jupiter.api.Test +import java.io.File class SystemUtilsTest { @@ -23,6 +25,22 @@ class SystemUtilsTest { assertTrue(machineName.isNotEmpty()) } + @Test + fun `getMachineName is stable and has no surrounding whitespace`() { + val machineName = SystemUtils.getMachineName() + assertEquals(machineName, SystemUtils.getMachineName()) + assertEquals(machineName.trim(), machineName) + } + + @Test + fun `getMachineName matches the kernel hostname on Linux`() { + // The CLI daemon and the other editor plugins report gethostname(), and the + // server groups sessions per machine, so the names must be identical + val kernelHostname = File("/proc/sys/kernel/hostname") + assumeTrue(kernelHostname.isFile) + assertEquals(kernelHostname.readText().trim(), SystemUtils.getMachineName()) + } + @Test fun `generateUUID returns valid UUID format`() { val uuid = SystemUtils.generateUUID()