Files

273 lines
9.2 KiB
JavaScript

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, "&amp;").replace(/</g, "&lt;");
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 };