From 663f6a01817ee34c07d9c0bac322f9506a23ece5 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 29 Jul 2026 15:26:24 +0200 Subject: [PATCH] feat: add VS Code extensions for claudocs companion and code heatmap --- .vscodeignore | 2 + extension.js | 272 ++++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 48 +++++++++ 3 files changed, 322 insertions(+) create mode 100644 .vscodeignore create mode 100644 extension.js create mode 100644 package.json diff --git a/.vscodeignore b/.vscodeignore new file mode 100644 index 0000000..7120c77 --- /dev/null +++ b/.vscodeignore @@ -0,0 +1,2 @@ +.vscodeignore +*.vsix diff --git a/extension.js b/extension.js new file mode 100644 index 0000000..0c3fd29 --- /dev/null +++ b/extension.js @@ -0,0 +1,272 @@ +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 }; diff --git a/package.json b/package.json new file mode 100644 index 0000000..3bdb860 --- /dev/null +++ b/package.json @@ -0,0 +1,48 @@ +{ + "name": "codeheat", + "displayName": "CodeHeat: Local Time Tracker", + "description": "Tracks time spent per project and per file, fully local, with a heatmap in the file explorer. No account, no server, no telemetry.", + "version": "0.1.0", + "publisher": "foksiu-local", + "engines": { + "vscode": "^1.85.0" + }, + "categories": ["Other", "Visualization"], + "activationEvents": ["onStartupFinished"], + "main": "./extension.js", + "contributes": { + "commands": [ + { + "command": "codeheat.showSummary", + "title": "CodeHeat: Show Time Summary" + }, + { + "command": "codeheat.toggleTracking", + "title": "CodeHeat: Toggle Tracking" + }, + { + "command": "codeheat.exportData", + "title": "CodeHeat: Export Data as JSON" + }, + { + "command": "codeheat.clearProjectData", + "title": "CodeHeat: Clear Data for This Project" + } + ], + "configuration": { + "title": "CodeHeat", + "properties": { + "codeheat.idleThresholdMinutes": { + "type": "number", + "default": 3, + "description": "Minutes of no editor activity before tracking pauses." + }, + "codeheat.heatmapEnabled": { + "type": "boolean", + "default": true, + "description": "Show heat badges on files in the explorer based on time spent." + } + } + } + } +}