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
26 changes: 24 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
17 changes: 10 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

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

Expand Down
14 changes: 14 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
44 changes: 28 additions & 16 deletions src/main/kotlin/xyz/shelltime/jetbrains/actions/FlushAction.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand All @@ -19,26 +21,36 @@ class FlushAction : AnAction() {
val project = e.project ?: return

val projectService = project.service<ShellTimeProjectService>()
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(
Expand Down
38 changes: 13 additions & 25 deletions src/main/kotlin/xyz/shelltime/jetbrains/actions/ShowStatusAction.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand All @@ -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,
Expand Down
Loading
Loading