Skip to content

Commit 0d775bc

Browse files
committed
Add webview styles, templates, and utility functions for analytics dashboard
- Introduced `styles.ts` for consistent styling across the webview, including responsive design and theming. - Created `templates.ts` to render various components such as stats overview, language charts, contributor charts, and repository analytics. - Implemented utility functions in `utils.ts` for color generation, language name mapping, and HTML escaping. - Enhanced the user interface with detailed language breakdowns, contributor distributions, and ignored files sections.
1 parent 8ef988c commit 0d775bc

10 files changed

Lines changed: 1791 additions & 173 deletions

File tree

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@
6363
"command": "codecount.refreshStats",
6464
"title": "Refresh CodeCount Stats"
6565
},
66+
{
67+
"command": "codecount.openAnalytics",
68+
"title": "Open Analytics Dashboard"
69+
},
6670
{
6771
"command": "codecount.gitRepoMissing",
6872
"title": "Git Repo Missing"

src/extension.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { WorkspaceLineCounter } from './services/WorkspaceLineCounter';
66
import { LineCounterByFileFormat } from './services/LineCounterByFileFormat';
77
import { StatsTreeProvider } from './views/StatsTreeProvider';
88
import { StatsService } from './services/StatsService';
9+
import { StatsWebviewPanel } from './views/StatsWebviewPanel';
910

1011
export function activate(context: vscode.ExtensionContext) {
1112
const lineCounter = new LineCounter();
@@ -73,6 +74,11 @@ export function activate(context: vscode.ExtensionContext) {
7374

7475
const refreshStats = vscode.commands.registerCommand('codecount.refreshStats', () => {
7576
statsProvider.refresh();
77+
StatsWebviewPanel.refreshIfOpen();
78+
});
79+
80+
const openAnalytics = vscode.commands.registerCommand('codecount.openAnalytics', () => {
81+
StatsWebviewPanel.createOrShow(context.extensionUri, statsService);
7682
});
7783

7884
const gitRepoMissing = vscode.commands.registerCommand('codecount.gitRepoMissing', () => {
@@ -149,6 +155,7 @@ export function activate(context: vscode.ExtensionContext) {
149155
context.subscriptions.push(fileslines);
150156
context.subscriptions.push(fileslinesByExtension);
151157
context.subscriptions.push(refreshStats);
158+
context.subscriptions.push(openAnalytics);
152159
context.subscriptions.push(gitRepoMissing);
153160
context.subscriptions.push(openContributorFile);
154161
context.subscriptions.push(

src/services/StatsService.ts

Lines changed: 277 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,13 @@ import type {
1010
ContributorStat,
1111
ContributorStatsResult,
1212
LanguageStat,
13-
LanguageStatsResult
13+
LanguageFileStat,
14+
LanguageStatsResult,
15+
RepoAnalyticsResult
1416
} from '../types';
1517

1618
const GIT_AUTHOR_PREFIX = '--AUTHOR--';
19+
const GIT_COMMIT_PREFIX = '--COMMIT--';
1720

1821
export class StatsService {
1922
constructor(
@@ -63,6 +66,49 @@ export class StatsService {
6366
};
6467
}
6568

69+
async getLanguageFileStats(languageId: string): Promise<{ available: boolean; stats: LanguageFileStat[] }> {
70+
if (!vscode.workspace.workspaceFolders?.length) {
71+
return { available: false, stats: [] };
72+
}
73+
74+
const files = await vscode.workspace.findFiles(
75+
'**/*',
76+
'**/{node_modules,out,dist,.git}/**'
77+
);
78+
if (files.length === 0) {
79+
return { available: true, stats: [] };
80+
}
81+
82+
const filtered = await this.filterIgnoredFiles(files);
83+
if (filtered.length === 0) {
84+
return { available: true, stats: [] };
85+
}
86+
87+
const stats: LanguageFileStat[] = [];
88+
for (const uri of filtered) {
89+
try {
90+
const document = await vscode.workspace.openTextDocument(uri);
91+
if (document.languageId !== languageId) {
92+
continue;
93+
}
94+
const lines = this.lineCounter.countDocumentLines(document);
95+
const workspaceFolder = vscode.workspace.getWorkspaceFolder(uri);
96+
const rootPath = workspaceFolder?.uri.fsPath;
97+
const relativePath = rootPath
98+
? path.relative(rootPath, uri.fsPath).replace(/\\/g, '/')
99+
: uri.fsPath;
100+
stats.push({ filePath: relativePath, absolutePath: uri.fsPath, lines });
101+
} catch {
102+
continue;
103+
}
104+
}
105+
106+
return {
107+
available: true,
108+
stats: stats.sort((a, b) => b.lines - a.lines)
109+
};
110+
}
111+
66112
async getContributorStats(): Promise<ContributorStatsResult> {
67113
const rootPath = await this.getGitRoot();
68114
if (!rootPath) {
@@ -165,6 +211,236 @@ export class StatsService {
165211
}
166212
}
167213

214+
async getRepoAnalytics(): Promise<RepoAnalyticsResult> {
215+
const rootPath = await this.getGitRoot();
216+
if (!rootPath) {
217+
return {
218+
available: false,
219+
totalCommits: 0,
220+
activeDays: 0,
221+
totalAdded: 0,
222+
totalDeleted: 0,
223+
avgLinesChangedPerCommit: 0,
224+
commitsByDate: [],
225+
commitsByMonth: [],
226+
commitsByAuthor: [],
227+
commitsByWeekday: Array.from({ length: 7 }, () => 0),
228+
commitsByHour: Array.from({ length: 24 }, () => 0),
229+
topChangedFiles: []
230+
};
231+
}
232+
233+
try {
234+
const output = await this.execGit(
235+
['log', '--date=iso-strict', `--pretty=${GIT_COMMIT_PREFIX}%aI|%an`, '--numstat'],
236+
rootPath
237+
);
238+
239+
const byDate = new Map<string, { commits: number; added: number; deleted: number }>();
240+
const byMonth = new Map<string, { commits: number; added: number; deleted: number }>();
241+
const byAuthor = new Map<string, { commits: number; added: number; deleted: number }>();
242+
const byFile = new Map<string, { added: number; deleted: number }>();
243+
const commitsByWeekday = Array.from({ length: 7 }, () => 0);
244+
const commitsByHour = Array.from({ length: 24 }, () => 0);
245+
let totalCommits = 0;
246+
let totalAdded = 0;
247+
let totalDeleted = 0;
248+
let currentDateKey: string | undefined;
249+
let currentMonthKey: string | undefined;
250+
let currentAuthor: string | undefined;
251+
let firstCommitDate: string | undefined;
252+
let lastCommitDate: string | undefined;
253+
254+
const lines = output.split(/\r?\n/);
255+
for (const line of lines) {
256+
if (line.startsWith(GIT_COMMIT_PREFIX)) {
257+
const payload = line.slice(GIT_COMMIT_PREFIX.length).trim();
258+
const [dateTime, authorName] = payload.split('|');
259+
if (!dateTime) {
260+
currentDateKey = undefined;
261+
currentMonthKey = undefined;
262+
currentAuthor = undefined;
263+
continue;
264+
}
265+
266+
const dateKey = dateTime.slice(0, 10);
267+
const monthKey = dateTime.slice(0, 7);
268+
const author = (authorName ?? '').trim() || 'Unknown';
269+
const date = new Date(dateTime);
270+
if (Number.isNaN(date.getTime())) {
271+
currentDateKey = undefined;
272+
currentMonthKey = undefined;
273+
currentAuthor = undefined;
274+
continue;
275+
}
276+
277+
const weekday = date.getDay();
278+
const hourMatch = dateTime.match(/T(\d{2}):/);
279+
const hour = hourMatch ? Number(hourMatch[1]) : date.getHours();
280+
281+
totalCommits += 1;
282+
currentDateKey = dateKey;
283+
currentMonthKey = monthKey;
284+
currentAuthor = author;
285+
commitsByWeekday[weekday] += 1;
286+
if (hour >= 0 && hour < 24) {
287+
commitsByHour[hour] += 1;
288+
}
289+
290+
if (!lastCommitDate) {
291+
lastCommitDate = dateKey;
292+
}
293+
firstCommitDate = dateKey;
294+
295+
const dateBucket = byDate.get(dateKey) ?? { commits: 0, added: 0, deleted: 0 };
296+
dateBucket.commits += 1;
297+
byDate.set(dateKey, dateBucket);
298+
299+
const monthBucket = byMonth.get(monthKey) ?? { commits: 0, added: 0, deleted: 0 };
300+
monthBucket.commits += 1;
301+
byMonth.set(monthKey, monthBucket);
302+
303+
const authorBucket = byAuthor.get(author) ?? { commits: 0, added: 0, deleted: 0 };
304+
authorBucket.commits += 1;
305+
byAuthor.set(author, authorBucket);
306+
continue;
307+
}
308+
309+
if (!currentDateKey || !currentMonthKey || !currentAuthor || !line.trim()) {
310+
continue;
311+
}
312+
313+
const [addedText, deletedText, filePath] = line.split('\t');
314+
if (!addedText || !deletedText || !filePath || addedText === '-' || deletedText === '-') {
315+
continue;
316+
}
317+
318+
const added = Number(addedText);
319+
const deleted = Number(deletedText);
320+
if (Number.isNaN(added) || Number.isNaN(deleted)) {
321+
continue;
322+
}
323+
324+
totalAdded += added;
325+
totalDeleted += deleted;
326+
327+
const dateBucket = byDate.get(currentDateKey);
328+
if (dateBucket) {
329+
dateBucket.added += added;
330+
dateBucket.deleted += deleted;
331+
byDate.set(currentDateKey, dateBucket);
332+
}
333+
334+
const monthBucket = byMonth.get(currentMonthKey);
335+
if (monthBucket) {
336+
monthBucket.added += added;
337+
monthBucket.deleted += deleted;
338+
byMonth.set(currentMonthKey, monthBucket);
339+
}
340+
341+
const authorBucket = byAuthor.get(currentAuthor);
342+
if (authorBucket) {
343+
authorBucket.added += added;
344+
authorBucket.deleted += deleted;
345+
byAuthor.set(currentAuthor, authorBucket);
346+
}
347+
348+
const normalizedPath = this.normalizeGitPath(filePath);
349+
const fileBucket = byFile.get(normalizedPath) ?? { added: 0, deleted: 0 };
350+
fileBucket.added += added;
351+
fileBucket.deleted += deleted;
352+
byFile.set(normalizedPath, fileBucket);
353+
}
354+
355+
const commitsByDate = Array.from(byDate.entries())
356+
.map(([date, value]) => ({
357+
date,
358+
commits: value.commits,
359+
added: value.added,
360+
deleted: value.deleted
361+
}))
362+
.sort((a, b) => a.date.localeCompare(b.date));
363+
364+
const commitsByMonth = Array.from(byMonth.entries())
365+
.map(([month, value]) => ({
366+
month,
367+
commits: value.commits,
368+
added: value.added,
369+
deleted: value.deleted
370+
}))
371+
.sort((a, b) => a.month.localeCompare(b.month));
372+
373+
const commitsByAuthor = Array.from(byAuthor.entries())
374+
.map(([author, value]) => ({
375+
author,
376+
commits: value.commits,
377+
added: value.added,
378+
deleted: value.deleted
379+
}))
380+
.sort((a, b) => b.commits - a.commits);
381+
382+
const topChangedFiles = Array.from(byFile.entries())
383+
.map(([filePath, totals]) => ({
384+
filePath,
385+
changes: totals.added + totals.deleted,
386+
added: totals.added,
387+
deleted: totals.deleted
388+
}))
389+
.sort((a, b) => b.changes - a.changes)
390+
.slice(0, 12);
391+
392+
let busiestDay: { date: string; commits: number } | undefined;
393+
let mostChangedDay: { date: string; changes: number } | undefined;
394+
for (const item of commitsByDate) {
395+
if (!busiestDay || item.commits > busiestDay.commits) {
396+
busiestDay = { date: item.date, commits: item.commits };
397+
}
398+
const changes = item.added + item.deleted;
399+
if (!mostChangedDay || changes > mostChangedDay.changes) {
400+
mostChangedDay = { date: item.date, changes };
401+
}
402+
}
403+
404+
const avgLinesChangedPerCommit = totalCommits > 0
405+
? Math.round((totalAdded + totalDeleted) / totalCommits)
406+
: 0;
407+
408+
return {
409+
available: true,
410+
totalCommits,
411+
activeDays: commitsByDate.length,
412+
firstCommitDate,
413+
lastCommitDate,
414+
totalAdded,
415+
totalDeleted,
416+
avgLinesChangedPerCommit,
417+
busiestDay,
418+
mostChangedDay,
419+
commitsByDate,
420+
commitsByMonth,
421+
commitsByAuthor,
422+
commitsByWeekday,
423+
commitsByHour,
424+
topChangedFiles
425+
};
426+
} catch {
427+
return {
428+
available: true,
429+
totalCommits: 0,
430+
activeDays: 0,
431+
totalAdded: 0,
432+
totalDeleted: 0,
433+
avgLinesChangedPerCommit: 0,
434+
commitsByDate: [],
435+
commitsByMonth: [],
436+
commitsByAuthor: [],
437+
commitsByWeekday: Array.from({ length: 7 }, () => 0),
438+
commitsByHour: Array.from({ length: 24 }, () => 0),
439+
topChangedFiles: []
440+
};
441+
}
442+
}
443+
168444
private async filterIgnoredFiles(files: readonly vscode.Uri[]): Promise<vscode.Uri[]> {
169445
const filteredFiles: vscode.Uri[] = [];
170446
for (const uri of files) {

src/types/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ export type {
66
ContributorLanguageStat,
77
ContributorStat,
88
ContributorStatsResult,
9+
RepoAnalyticsResult,
10+
RepoCommitTrendStat,
11+
RepoTopFileStat,
912
LanguageStat,
13+
LanguageFileStat,
1014
LanguageStatsResult
1115
} from './stats';

0 commit comments

Comments
 (0)