Repository navigation
Expand file tree
/
Copy pathextension.js
More file actions
538 lines (478 loc) · 19.4 KB
/
Copy pathextension.js
File metadata and controls
538 lines (478 loc) · 19.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
'use strict';
const vscode = require('vscode');
const { StatsEngine } = require('./src/stats');
const { getMoodDisplay, checkMilestones,
getRandomHiMessage, getRandomTip, PET_THEMES } = require('./src/moods');
const { showDashboard, updateDashboardIfOpen } = require('./src/dashboard');
const { SidebarProvider } = require('./src/sidebar');
/** @type {vscode.StatusBarItem} */
let statusBarItem;
let idleTimer;
let codingTimer;
let breakTimer = null; // tracks continuous coding for break reminder
let flashTimeout = null;
let comboTimeout = null; // typing combo flash timer
let lastActivity = Date.now();
let currentMood = 'happy';
let prevErrorCount = 0;
let continuousCodingMins = 0; // minutes coded without a long idle
let lastBreakReminderDay = null; // ISO date of last weekly review shown
/** @type {StatsEngine} */
let stats;
// ─── Config helpers ───────────────────────────────────────────────────────────
function cfg() { return vscode.workspace.getConfiguration('codecritter'); }
function petName() { return cfg().get('petName', 'Critter'); }
function dailyGoal() { return cfg().get('dailyGoal', 100); }
function tipsOn() { return cfg().get('enableTips', true); }
function petTheme() { return cfg().get('petTheme', 'default'); }
// ─── Status bar helpers ───────────────────────────────────────────────────────
function _buildText(moodKey) {
const m = getMoodDisplay(moodKey);
return `${m.statusText} Lv.${stats.getLevel()}`;
}
function _buildTooltip(moodKey) {
const m = getMoodDisplay(moodKey);
const sn = stats.getSnapshot();
const theme = PET_THEMES[petTheme()] || PET_THEMES.default;
const lines = [
`${petName()} the CodeCritter — ${m.label} (Level ${sn.level})`,
`${m.tip}`,
`━━━━━━━━━━━━━━━━━━━`,
`💾 ${sn.saveCount.toLocaleString()} saves · 💻 ${sn.linesTyped.toLocaleString()} lines typed`,
`🔥 ${sn.currentStreak}-day streak · 🐛 ${sn.errorsFixed} errors fixed`,
`🎨 Theme: ${theme.label}`,
``,
`Click to say hi! | Open Command Palette → "CodeCritter"`
];
return new vscode.MarkdownString(lines.join('\n'), true);
}
function _applyMood(moodKey) {
statusBarItem.text = _buildText(moodKey);
statusBarItem.tooltip = _buildTooltip(moodKey);
}
/**
* Sets the critter's mood.
* If temporary=true, flashes for 2.5 s then reverts to currentMood.
*
* @param {string} moodKey
* @param {boolean} [temporary=false]
*/
function setMood(moodKey, temporary = false, durationMs = 2500, onRevert = null) {
if (temporary) {
if (flashTimeout) clearTimeout(flashTimeout);
_applyMood(moodKey);
flashTimeout = setTimeout(() => {
flashTimeout = null;
_applyMood(currentMood);
if (typeof onRevert === 'function') {
onRevert();
}
}, durationMs);
} else {
if (flashTimeout) return; // don't override a flash
currentMood = moodKey;
_applyMood(moodKey);
}
}
// ─── Milestone handling ───────────────────────────────────────────────────────
function checkAndShowMilestones(snapshot) {
const newMilestones = checkMilestones(snapshot, stats);
for (const m of newMilestones) {
vscode.window.showInformationMessage(
`${m.emoji} Achievement Unlocked — **${m.label}**: ${m.desc}! 🎉`
);
setMood('celebrating', true);
}
}
// ─── Weekly Review ────────────────────────────────────────────────────────────
/**
* Checks if today is Monday and we haven't shown the weekly review yet.
* Shows a one-line summary of last week's stats.
*/
function maybeShowWeeklyReview() {
if (!cfg().get('weeklyReview', true)) return;
const today = new Date();
const todayStr = today.toISOString().slice(0, 10);
if (today.getDay() !== 1) return; // only on Monday
if (lastBreakReminderDay === todayStr) return; // already shown today
lastBreakReminderDay = todayStr;
const sn = stats.getSnapshot();
vscode.window.showInformationMessage(
`🐾 Weekly Check-in! ` +
`You've typed ${sn.linesTyped.toLocaleString()} lines total, ` +
`saved ${sn.saveCount.toLocaleString()} files, ` +
`and you're on a ${sn.currentStreak}-day streak. Keep it up! 🔥`,
'Open Dashboard'
).then(choice => {
if (choice === 'Open Dashboard') {
vscode.commands.executeCommand('codecritter.showDashboard');
}
});
}
// ─── activate ─────────────────────────────────────────────────────────────────
/**
* @param {import('vscode').ExtensionContext} context
*/
function activate(context) {
stats = new StatsEngine(context);
// ── Status bar ──
const alignment = cfg().get('statusBarAlignment', 'right') === 'left'
? vscode.StatusBarAlignment.Left
: vscode.StatusBarAlignment.Right;
statusBarItem = vscode.window.createStatusBarItem(alignment, 100);
statusBarItem.command = 'codecritter.sayHi';
_applyMood('happy');
if (cfg().get('showInStatusBar', true)) {
statusBarItem.show();
}
// Show weekly review on startup
maybeShowWeeklyReview();
// ── Sidebar Provider ──
const sidebarProvider = new SidebarProvider(context, stats, () => petName(), () => currentMood);
context.subscriptions.push(
vscode.window.registerWebviewViewProvider('codecritter.sidebarView', sidebarProvider)
);
// ── Commands ──
/** Say hi — click on status bar */
const cmdSayHi = vscode.commands.registerCommand('codecritter.sayHi', () => {
const name = petName();
const level = stats.getLevel();
const msg = getRandomHiMessage();
const tip = tipsOn() ? `\n\n${getRandomTip()}` : '';
vscode.window.showInformationMessage(`🐾 ${name} (Lv.${level}): ${msg}${tip}`);
setMood('excited', true);
});
/** Open the stats dashboard */
const cmdDashboard = vscode.commands.registerCommand('codecritter.showDashboard', () => {
showDashboard(context, stats, petName(), currentMood);
});
/** Reset all stats with confirmation */
const cmdReset = vscode.commands.registerCommand('codecritter.resetStats', async () => {
const choice = await vscode.window.showWarningMessage(
`⚠️ Reset ALL of ${petName()}'s stats? This cannot be undone.`,
{ modal: true },
'Reset Everything'
);
if (choice === 'Reset Everything') {
stats.resetAll();
currentMood = 'happy';
_applyMood('happy');
vscode.window.showInformationMessage('🐾 Stats reset! Starting fresh from Level 1.');
updateDashboardIfOpen(stats, petName(), 'happy');
sidebarProvider.update();
}
});
/** Set daily lines goal via input box */
const cmdSetGoal = vscode.commands.registerCommand('codecritter.setDailyGoal', async () => {
const current = String(dailyGoal());
const val = await vscode.window.showInputBox({
title: 'CodeCritter — Set Daily Goal',
prompt: 'How many lines do you want to type today?',
value: current,
validateInput: v => {
const n = Number(v);
if (isNaN(n) || n < 1 || !Number.isInteger(n)) return 'Please enter a positive whole number.';
return null;
}
});
if (val !== undefined) {
await cfg().update('dailyGoal', Number(val), vscode.ConfigurationTarget.Global);
vscode.window.showInformationMessage(`🎯 Daily goal set to ${Number(val).toLocaleString()} lines!`);
}
});
/** Choose pet theme from quick-pick */
const cmdChooseTheme = vscode.commands.registerCommand('codecritter.choosePetTheme', async () => {
const items = Object.entries(PET_THEMES).map(([key, t]) => ({
label: t.label,
description: key === petTheme() ? '✓ Current' : '',
themeKey: key
}));
const picked = await vscode.window.showQuickPick(items, {
title: 'CodeCritter — Choose Pet Theme',
placeHolder: 'Pick a color skin for your CodeCritter'
});
if (picked) {
await cfg().update('petTheme', picked.themeKey, vscode.ConfigurationTarget.Global);
vscode.window.showInformationMessage(`🎨 Pet theme changed to ${picked.label}!`);
_applyMood(currentMood); // refresh tooltip
updateDashboardIfOpen(stats, petName(), currentMood);
sidebarProvider.update();
}
});
/** Export Stats */
const cmdExportStats = vscode.commands.registerCommand('codecritter.exportStats', async () => {
const snap = stats.getSnapshot();
const data = JSON.stringify(snap, null, 2);
const uri = await vscode.window.showSaveDialog({
defaultUri: vscode.Uri.file('codecritter-backup.json'),
filters: { 'JSON': ['json'] },
title: 'Export CodeCritter Stats'
});
if (uri) {
await vscode.workspace.fs.writeFile(uri, Buffer.from(data, 'utf8'));
vscode.window.showInformationMessage('💾 CodeCritter stats exported successfully!');
}
});
/** Import Stats */
const cmdImportStats = vscode.commands.registerCommand('codecritter.importStats', async () => {
const uris = await vscode.window.showOpenDialog({
canSelectMany: false,
filters: { 'JSON': ['json'] },
title: 'Import CodeCritter Stats'
});
if (uris && uris[0]) {
try {
const fileData = await vscode.workspace.fs.readFile(uris[0]);
const json = JSON.parse(Buffer.from(fileData).toString('utf8'));
if (stats.importSnapshot(json)) {
vscode.window.showInformationMessage('📥 CodeCritter stats imported successfully!');
_applyMood(currentMood);
updateDashboardIfOpen(stats, petName(), currentMood);
sidebarProvider.update();
} else {
vscode.window.showErrorMessage('❌ Invalid backup file format.');
}
} catch (err) {
vscode.window.showErrorMessage('❌ Failed to read or parse backup file.');
}
}
});
/** Rename the pet using an input box */
const cmdRenamePet = vscode.commands.registerCommand('codecritter.petName', async () => {
const current = petName();
const val = await vscode.window.showInputBox({
title: 'CodeCritter — Rename Pet Name',
prompt: 'What would you like to name your pet?',
value: current,
validateInput: v => {
if (v.trim() === '') return 'Pet name cannot be empty.';
if (v.trim().length > 20) return 'Pet name must be 20 characters max.';
return null;
}
});
if (val !== undefined) {
const newName = val.trim();
await cfg().update('petName', newName, vscode.ConfigurationTarget.Global);
vscode.window.showInformationMessage(`✏️ Your pet has been renamed to ${newName}!`);
_applyMood(currentMood);
updateDashboardIfOpen(stats, newName, currentMood);
sidebarProvider.update();
}
});
/** Feed Pet */
const cmdFeedPet = vscode.commands.registerCommand('codecritter.feedPet', () => {
vscode.window.showInformationMessage(`🍕 You fed ${petName()}! Nom nom nom.`);
setMood('excited', true, 5000, () => {
updateDashboardIfOpen(stats, petName(), currentMood);
sidebarProvider.update();
});
updateDashboardIfOpen(stats, petName(), 'excited');
sidebarProvider.update();
});
/** Play Pet */
const cmdPlayPet = vscode.commands.registerCommand('codecritter.playPet', () => {
vscode.window.showInformationMessage(`🎾 You played with ${petName()}! So much fun!`);
setMood('celebrating', true, 5000, () => {
updateDashboardIfOpen(stats, petName(), currentMood);
sidebarProvider.update();
});
updateDashboardIfOpen(stats, petName(), 'celebrating');
sidebarProvider.update();
});
// ── Event: File Saved ──
const onSave = vscode.workspace.onDidSaveTextDocument(() => {
lastActivity = Date.now();
stats.recordSave();
setMood('excited', true);
const snap = stats.getSnapshot();
checkAndShowMilestones(snap);
updateDashboardIfOpen(stats, petName(), currentMood);
sidebarProvider.update();
});
// ── Event: Text Changed (typing) ──
const onChange = vscode.workspace.onDidChangeTextDocument(e => {
lastActivity = Date.now();
// Count newly inserted lines with a paste-cap to prevent stat inflation.
// Single keystroke Enter = 1 line. Large pastes are capped at 10 lines max
// per change event so that pasting a 500-line file doesn't give 500 XP.
let newLines = 0;
for (const change of e.contentChanges) {
const inserted = change.text.split('\n').length - 1;
if (inserted > 0) {
newLines += Math.min(inserted, 10); // cap: max 10 lines per change
}
}
if (newLines > 0) {
stats.recordLines(newLines);
// Check daily goal
if (stats.checkAndMarkDailyGoal(dailyGoal())) {
vscode.window.showInformationMessage(
`🎯 Daily goal reached! ${dailyGoal().toLocaleString()} lines typed today. ${petName()} is proud of you! 🥳`
);
setMood('celebrating', true);
}
// ── Typing combo flash ──
// When a user is actively typing (not just paste-importing), flash the
// combo mood for 2 s whenever they've typed 5+ lines since last flash.
if (!comboTimeout && newLines >= 2 && !flashTimeout) {
setMood('comboTyping', true);
comboTimeout = setTimeout(() => { comboTimeout = null; }, 4000);
}
const snap = stats.getSnapshot();
checkAndShowMilestones(snap);
updateDashboardIfOpen(stats, petName(), currentMood);
sidebarProvider.update();
}
});
// ── Event: Diagnostics (errors/warnings) ──
const onDiagnostics = vscode.languages.onDidChangeDiagnostics(() => {
// Query each visible document once, even when it is open in split editors.
const seen = new Set();
let errorCount = 0;
for (const editor of vscode.window.visibleTextEditors) {
const uri = editor.document.uri;
const key = uri.toString();
if (seen.has(key)) continue;
seen.add(key);
for (const diagnostic of vscode.languages.getDiagnostics(uri)) {
if (diagnostic.severity === vscode.DiagnosticSeverity.Error) errorCount++;
}
}
// Award credit for errors fixed
if (errorCount < prevErrorCount) {
const fixed = prevErrorCount - errorCount;
for (let i = 0; i < fixed; i++) stats.recordErrorFixed();
const snap = stats.getSnapshot();
checkAndShowMilestones(snap);
updateDashboardIfOpen(stats, petName(), currentMood);
sidebarProvider.update();
}
prevErrorCount = errorCount;
// Update persistent mood (not during a flash)
if (!flashTimeout) {
if (errorCount > 0) {
currentMood = 'struggling';
} else if (currentMood === 'struggling') {
// Cleared all errors — go focused for 5 s then happy
currentMood = 'focused';
setTimeout(() => {
if (currentMood === 'focused') {
currentMood = 'happy';
_applyMood('happy');
}
}, 5000);
}
_applyMood(currentMood);
}
});
// ── Reset Idle Mood on Activity / Focus / Wake ──
function resetIdleMood() {
lastActivity = Date.now();
if (!flashTimeout && (currentMood === 'sleepy' || currentMood === 'bored')) {
currentMood = prevErrorCount > 0 ? 'struggling' : 'happy';
_applyMood(currentMood);
updateDashboardIfOpen(stats, petName(), currentMood);
sidebarProvider.update();
}
}
// ── Event: Active Editor Changed ──
const onEditorChange = typeof vscode.window.onDidChangeActiveTextEditor === 'function'
? vscode.window.onDidChangeActiveTextEditor(editor => {
if (editor) {
resetIdleMood();
}
})
: { dispose() {} };
// ── Event: Window Focus / System Wake ──
const onWindowStateChange = typeof vscode.window.onDidChangeWindowState === 'function'
? vscode.window.onDidChangeWindowState(state => {
if (state && state.focused) {
resetIdleMood();
}
})
: { dispose() {} };
// ── Idle Mood Timer (every 30 s) ──
idleTimer = setInterval(() => {
if (flashTimeout) return;
const idleMins = (Date.now() - lastActivity) / 60000;
let target;
if (idleMins > 10) target = 'sleepy';
else if (idleMins > 3) target = 'bored';
else if (prevErrorCount > 0) target = 'struggling';
else target = 'happy';
if (target !== currentMood) {
currentMood = target;
_applyMood(currentMood);
sidebarProvider.update();
}
}, 30_000);
// ── Coding Time + Break Reminder Tracker (every 1 min) ──
codingTimer = setInterval(() => {
const idleMins = (Date.now() - lastActivity) / 60000;
if (idleMins < 5) {
stats.recordCodingTime(1);
continuousCodingMins++;
// Break reminder
const breakEnabled = cfg().get('breakReminder', false);
const breakInterval = cfg().get('breakReminderInterval', 45);
if (breakEnabled && continuousCodingMins >= breakInterval) {
continuousCodingMins = 0; // reset so it doesn't fire again immediately
vscode.window.showInformationMessage(
`🐾 ${petName()} thinks you should take a short break! ` +
`You've been coding for ${breakInterval} min straight. Stretch, hydrate, come back fresh! 💧`,
'Snooze 15 min',
'Got it!'
).then(choice => {
if (choice === 'Snooze 15 min') {
// Don't remind again for 15 more minutes
continuousCodingMins = -15;
}
});
}
} else {
// User is idle — reset continuous coding counter
continuousCodingMins = 0;
}
}, 60_000);
// ── Settings change listener ──
const onConfigChange = vscode.workspace.onDidChangeConfiguration(e => {
if (!e.affectsConfiguration('codecritter')) return;
const show = cfg().get('showInStatusBar', true);
show ? statusBarItem.show() : statusBarItem.hide();
_applyMood(currentMood); // rebuild text/tooltip for petName/theme changes
updateDashboardIfOpen(stats, petName(), currentMood); // refresh theme in dashboard
sidebarProvider.update();
});
// ── Register everything ──
context.subscriptions.push(
statusBarItem,
sidebarProvider,
cmdSayHi,
cmdDashboard,
cmdReset,
cmdSetGoal,
cmdChooseTheme,
cmdExportStats,
cmdImportStats,
cmdFeedPet,
cmdPlayPet,
cmdRenamePet,
onSave,
onChange,
onDiagnostics,
onEditorChange,
onWindowStateChange,
onConfigChange,
{ dispose: () => {
clearInterval(idleTimer);
clearInterval(codingTimer);
if (breakTimer) clearTimeout(breakTimer);
}}
);
}
// ─── deactivate ───────────────────────────────────────────────────────────────
function deactivate() {
if (flashTimeout) clearTimeout(flashTimeout);
if (comboTimeout) clearTimeout(comboTimeout);
}
module.exports = { activate, deactivate };