feat: add VS Code extensions for claudocs companion and code heatmap
This commit is contained in:
commit
663f6a0181
3 files changed
+322
No files matched your search
@@ -0,0 +1,2 @@
|
||||
.vscodeignore
|
||||
*.vsix
|
||||
+272
@@ -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 "<p>No time tracked in this project yet.</p>";
|
||||
}
|
||||
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(/</g, "<");
|
||||
return `
|
||||
<div class="row">
|
||||
<div class="label" title="${safePath}">${safePath}</div>
|
||||
<div class="bar-track"><div class="bar-fill" style="width:${widthPercent}%"></div></div>
|
||||
<div class="value">${formatDuration(seconds)}</div>
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
return `
|
||||
<h2>${project.label}</h2>
|
||||
<p class="total">Total tracked: <strong>${formatDuration(project.totalSeconds)}</strong></p>
|
||||
<div class="rows">${rows}</div>`;
|
||||
}
|
||||
|
||||
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 = `<!DOCTYPE html><html><head><style>${buildSummaryStyle()}</style></head><body>${buildSummaryHtml(currentProjectKey)}</body></html>`;
|
||||
}
|
||||
|
||||
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 };
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user