const vscode = require("vscode"); const fs = require("fs"); const path = require("path"); const HEARTBEAT_MS = 30000; const HEAT_COLORS = ["charts.blue", "charts.green", "charts.yellow", "charts.orange", "charts.red"]; let store = null; let statusBarItem = null; let decorationProvider = null; let trackingEnabled = true; let lastActivityAt = Date.now(); let currentProjectKey = null; let currentFileKey = null; let heartbeatTimer = null; function loadStore(dataFile) { try { const raw = fs.readFileSync(dataFile, "utf8"); return JSON.parse(raw); } catch (err) { return { projects: {} }; } } function saveStore(dataFile) { fs.mkdirSync(path.dirname(dataFile), { recursive: true }); fs.writeFileSync(dataFile, JSON.stringify(store, null, 2), "utf8"); } function getProjectKey(uri) { const folder = vscode.workspace.getWorkspaceFolder(uri); if (!folder) { return null; } return folder.uri.fsPath; } function ensureProject(projectKey, label) { if (!store.projects[projectKey]) { store.projects[projectKey] = { label, totalSeconds: 0, files: {} }; } return store.projects[projectKey]; } function addSeconds(seconds) { if (!currentProjectKey || !currentFileKey) { return; } const folder = vscode.workspace.workspaceFolders?.find((f) => f.uri.fsPath === currentProjectKey); const label = folder ? folder.name : path.basename(currentProjectKey); const project = ensureProject(currentProjectKey, label); project.totalSeconds += seconds; project.files[currentFileKey] = (project.files[currentFileKey] || 0) + seconds; project.label = label; } function idleThresholdMs() { const minutes = vscode.workspace.getConfiguration("codeheat").get("idleThresholdMinutes", 3); return minutes * 60 * 1000; } function heartbeat(dataFile) { if (!trackingEnabled) { return; } const idleFor = Date.now() - lastActivityAt; if (idleFor >= idleThresholdMs()) { return; } addSeconds(HEARTBEAT_MS / 1000); saveStore(dataFile); refreshStatusBar(); decorationProvider?.refresh(); } function refreshStatusBar() { if (!statusBarItem) { return; } if (!trackingEnabled) { statusBarItem.text = "$(circle-slash) CodeHeat paused"; statusBarItem.tooltip = "CodeHeat: tracking paused, click to resume"; return; } const project = currentProjectKey ? store.projects[currentProjectKey] : null; const seconds = project ? project.totalSeconds : 0; statusBarItem.text = `$(clock) ${formatDuration(seconds)}`; statusBarItem.tooltip = project ? `CodeHeat: ${formatDuration(seconds)} tracked in ${project.label}` : "CodeHeat: no workspace folder open"; } function formatDuration(totalSeconds) { const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); if (hours === 0 && minutes === 0) { return "<1m"; } if (hours === 0) { return `${minutes}m`; } return `${hours}h ${minutes}m`; } function onEditorActivity(uri) { lastActivityAt = Date.now(); if (!uri) { return; } const projectKey = getProjectKey(uri); if (!projectKey) { currentProjectKey = null; currentFileKey = null; refreshStatusBar(); return; } currentProjectKey = projectKey; currentFileKey = path.relative(projectKey, uri.fsPath); refreshStatusBar(); } class HeatDecorationProvider { constructor() { this._emitter = new vscode.EventEmitter(); this.onDidChangeFileDecorations = this._emitter.event; } refresh() { this._emitter.fire(undefined); } provideFileDecoration(uri) { if (!vscode.workspace.getConfiguration("codeheat").get("heatmapEnabled", true)) { return undefined; } const projectKey = getProjectKey(uri); if (!projectKey) { return undefined; } const project = store.projects[projectKey]; if (!project) { return undefined; } const relPath = path.relative(projectKey, uri.fsPath); const seconds = project.files[relPath]; if (!seconds) { return undefined; } const values = Object.values(project.files).filter((value) => value > 0); const maxSeconds = Math.max(...values); const bucket = maxSeconds > 0 ? Math.min(HEAT_COLORS.length - 1, Math.floor((seconds / maxSeconds) * HEAT_COLORS.length)) : 0; return { badge: String(bucket + 1), color: new vscode.ThemeColor(HEAT_COLORS[bucket]), tooltip: `CodeHeat: ${formatDuration(seconds)} tracked in this file`, propagate: false }; } } function buildSummaryHtml(projectKey) { const project = store.projects[projectKey]; if (!project) { return "

No time tracked in this project yet.

"; } const files = Object.entries(project.files).sort((a, b) => b[1] - a[1]).slice(0, 25); const maxSeconds = files.length > 0 ? files[0][1] : 1; const rows = files .map(([relPath, seconds]) => { const widthPercent = Math.max(2, Math.round((seconds / maxSeconds) * 100)); const safePath = relPath.replace(/&/g, "&").replace(/
${safePath}
${formatDuration(seconds)}
`; }) .join(""); return `

${project.label}

Total tracked: ${formatDuration(project.totalSeconds)}

${rows}
`; } function buildSummaryStyle() { return ` body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); padding: 16px 24px; } h2 { margin-bottom: 4px; } .total { color: var(--vscode-descriptionForeground); margin-top: 0; } .rows { display: flex; flex-direction: column; gap: 6px; margin-top: 16px; } .row { display: grid; grid-template-columns: 1fr 2fr auto; align-items: center; gap: 12px; } .label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.9em; } .bar-track { background: var(--vscode-editorWidget-background); border-radius: 4px; height: 10px; overflow: hidden; } .bar-fill { background: var(--vscode-charts-blue, #3794ff); height: 100%; } .value { font-variant-numeric: tabular-nums; color: var(--vscode-descriptionForeground); font-size: 0.85em; } `; } function showSummary() { if (!currentProjectKey && vscode.workspace.workspaceFolders?.length) { currentProjectKey = vscode.workspace.workspaceFolders[0].uri.fsPath; } const panel = vscode.window.createWebviewPanel("codeheatSummary", "CodeHeat Summary", vscode.ViewColumn.Active, {}); panel.webview.html = `${buildSummaryHtml(currentProjectKey)}`; } function activate(context) { const dataFile = path.join(context.globalStorageUri.fsPath, "codeheat-data.json"); store = loadStore(dataFile); statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100); statusBarItem.command = "codeheat.showSummary"; statusBarItem.show(); decorationProvider = new HeatDecorationProvider(); context.subscriptions.push( statusBarItem, vscode.window.registerFileDecorationProvider(decorationProvider), vscode.window.onDidChangeActiveTextEditor((editor) => onEditorActivity(editor?.document.uri)), vscode.workspace.onDidChangeTextDocument((event) => onEditorActivity(event.document.uri)), vscode.window.onDidChangeTextEditorSelection((event) => onEditorActivity(event.textEditor.document.uri)), vscode.commands.registerCommand("codeheat.showSummary", showSummary), vscode.commands.registerCommand("codeheat.toggleTracking", () => { trackingEnabled = !trackingEnabled; refreshStatusBar(); vscode.window.setStatusBarMessage(`CodeHeat: tracking ${trackingEnabled ? "resumed" : "paused"}`, 3000); }), vscode.commands.registerCommand("codeheat.exportData", async () => { const target = await vscode.window.showSaveDialog({ filters: { JSON: ["json"] }, saveLabel: "Export CodeHeat Data" }); if (!target) { return; } fs.writeFileSync(target.fsPath, JSON.stringify(store, null, 2), "utf8"); vscode.window.showInformationMessage(`CodeHeat: exported to ${target.fsPath}`); }), vscode.commands.registerCommand("codeheat.clearProjectData", async () => { if (!currentProjectKey || !store.projects[currentProjectKey]) { vscode.window.showInformationMessage("CodeHeat: no data for this project yet."); return; } const label = store.projects[currentProjectKey].label; const confirmed = await vscode.window.showWarningMessage(`Clear all CodeHeat data for ${label}?`, { modal: true }, "Clear"); if (confirmed === "Clear") { delete store.projects[currentProjectKey]; saveStore(dataFile); decorationProvider.refresh(); refreshStatusBar(); } }) ); if (vscode.window.activeTextEditor) { onEditorActivity(vscode.window.activeTextEditor.document.uri); } heartbeatTimer = setInterval(() => heartbeat(dataFile), HEARTBEAT_MS); context.subscriptions.push({ dispose: () => clearInterval(heartbeatTimer) }); refreshStatusBar(); } function deactivate() { if (heartbeatTimer) { clearInterval(heartbeatTimer); } } module.exports = { activate, deactivate };