Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<ShellTimeProjectService>().updateSettings() }
}

override fun reset() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ 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
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
Expand All @@ -37,9 +39,13 @@ class HeartbeatCollector(
) : Disposable {
private val logger = Logger("Collector", debug)
private val lastHeartbeat = ConcurrentHashMap<String, Long>()
@Volatile
private var lastActivity: LastActivityState? = null
private val pendingHeartbeats = ConcurrentLinkedQueue<HeartbeatData>()

/** 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)
Expand All @@ -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
Expand All @@ -85,8 +121,6 @@ class HeartbeatCollector(
return false
}

// Update last activity state
lastActivity = LastActivityState(filePath, lineNumber, cursorPosition)
lastHeartbeat[filePath] = now
return true
}
Expand Down Expand Up @@ -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
*/
Expand All @@ -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)
}
Expand All @@ -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

Expand All @@ -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)
}
Expand All @@ -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)
}
Expand All @@ -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

Expand All @@ -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
*
Expand Down Expand Up @@ -268,6 +308,7 @@ class HeartbeatCollector(
}

override fun dispose() {
stop()
lastHeartbeat.clear()
pendingHeartbeats.clear()
logger.log("Disposed heartbeat collector")
Expand Down
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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<ShellTimeProjectService>().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<ShellTimeProjectService>().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 }
}
}
Loading
Loading