Skip to content

Commit 365e405

Browse files
committed
Implement contributor stats retrieval and enhance Git repository handling
1 parent 11bd9f1 commit 365e405

6 files changed

Lines changed: 194 additions & 12 deletions

File tree

src/extension.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ export function activate(context: vscode.ExtensionContext) {
7575
statsProvider.refresh();
7676
});
7777

78+
const gitRepoMissing = vscode.commands.registerCommand('codecount.gitRepoMissing', () => {
79+
vscode.window.showWarningMessage('Git repo hasn’t been defined.');
80+
});
81+
7882
const openContributorFile = vscode.commands.registerCommand(
7983
'codecount.openContributorFile',
8084
async (args?: { author?: string; filePath?: string }) => {
@@ -91,6 +95,7 @@ export function activate(context: vscode.ExtensionContext) {
9195

9296
const absolutePath = vscode.Uri.file(path.join(rootPath, filePath));
9397
try {
98+
await vscode.workspace.fs.stat(absolutePath);
9499
const document = await vscode.workspace.openTextDocument(absolutePath);
95100
await vscode.window.showTextDocument(document, { preview: false });
96101
} catch {
@@ -142,11 +147,11 @@ export function activate(context: vscode.ExtensionContext) {
142147
context.subscriptions.push(fileslines);
143148
context.subscriptions.push(fileslinesByExtension);
144149
context.subscriptions.push(refreshStats);
150+
context.subscriptions.push(gitRepoMissing);
145151
context.subscriptions.push(openContributorFile);
146152
context.subscriptions.push(
147153
vscode.window.registerTreeDataProvider('codecount.stats', statsProvider)
148154
);
149155
}
150156

151-
// This method is called when your extension is deactivated
152157
export function deactivate() {}

src/services/StatsService.ts

Lines changed: 103 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import type {
1111
ContributorStatsResult,
1212
LanguageStat,
1313
LanguageStatsResult
14-
} from '../views/stats';
14+
} from '../types';
1515

1616
const GIT_AUTHOR_PREFIX = '--AUTHOR--';
1717

@@ -41,10 +41,14 @@ export class StatsService {
4141

4242
const stats = new Map<string, number>();
4343
for (const uri of filtered) {
44-
const document = await vscode.workspace.openTextDocument(uri);
45-
const languageId = document.languageId || 'unknown';
46-
const codeLines = this.lineCounter.countDocumentLines(document);
47-
stats.set(languageId, (stats.get(languageId) ?? 0) + codeLines);
44+
try {
45+
const document = await vscode.workspace.openTextDocument(uri);
46+
const languageId = document.languageId || 'unknown';
47+
const codeLines = this.lineCounter.countDocumentLines(document);
48+
stats.set(languageId, (stats.get(languageId) ?? 0) + codeLines);
49+
} catch {
50+
continue;
51+
}
4852
}
4953

5054
const entries = Array.from(stats.entries())
@@ -75,6 +79,22 @@ export class StatsService {
7579
return { available: true, stats };
7680
}
7781

82+
async getContributorStatsAll(): Promise<ContributorStatsResult> {
83+
const rootPath = await this.getGitRoot();
84+
if (!rootPath) {
85+
return { available: false, stats: [] };
86+
}
87+
88+
let stats: Array<{ name: string; linesAdded: number }> = [];
89+
try {
90+
stats = await this.getContributorStatsFromGitAll(rootPath);
91+
} catch {
92+
return { available: true, stats: [] };
93+
}
94+
95+
return { available: true, stats };
96+
}
97+
7898
async getContributorLanguageStats(authorName: string): Promise<{ available: boolean; stats: ContributorLanguageStat[] }> {
7999
const rootPath = await this.getGitRoot();
80100
if (!rootPath) {
@@ -190,6 +210,53 @@ export class StatsService {
190210
rootPath
191211
);
192212

213+
const stats = new Map<string, number>();
214+
const existsCache = new Map<string, boolean>();
215+
let currentAuthor = 'Unknown';
216+
const lines = output.split(/\r?\n/);
217+
for (const line of lines) {
218+
if (line.startsWith(GIT_AUTHOR_PREFIX)) {
219+
currentAuthor = line.slice(GIT_AUTHOR_PREFIX.length).trim() || 'Unknown';
220+
if (!stats.has(currentAuthor)) {
221+
stats.set(currentAuthor, 0);
222+
}
223+
continue;
224+
}
225+
226+
if (!line.trim()) {
227+
continue;
228+
}
229+
230+
const [addedText, , filePath] = line.split('\t');
231+
if (!addedText || addedText === '-' || !filePath) {
232+
continue;
233+
}
234+
235+
const normalizedPath = this.normalizeGitPath(filePath);
236+
const exists = await this.fileExists(rootPath, normalizedPath, existsCache);
237+
if (!exists) {
238+
continue;
239+
}
240+
241+
const added = Number(addedText);
242+
if (Number.isNaN(added)) {
243+
continue;
244+
}
245+
246+
stats.set(currentAuthor, (stats.get(currentAuthor) ?? 0) + added);
247+
}
248+
249+
return Array.from(stats.entries())
250+
.map(([name, linesAdded]) => ({ name, linesAdded }))
251+
.sort((a, b) => b.linesAdded - a.linesAdded);
252+
}
253+
254+
private async getContributorStatsFromGitAll(rootPath: string): Promise<ContributorStat[]> {
255+
const output = await this.execGit(
256+
['log', '--numstat', `--pretty=${GIT_AUTHOR_PREFIX}%an`],
257+
rootPath
258+
);
259+
193260
const stats = new Map<string, number>();
194261
let currentAuthor = 'Unknown';
195262
const lines = output.split(/\r?\n/);
@@ -235,6 +302,7 @@ export class StatsService {
235302

236303
const languageTotals = new Map<string, number>();
237304
const languageByPath = new Map<string, string>();
305+
const existsCache = new Map<string, boolean>();
238306
let currentAuthor = 'Unknown';
239307
const lines = output.split(/\r?\n/);
240308
for (const line of lines) {
@@ -262,6 +330,10 @@ export class StatsService {
262330
}
263331

264332
const normalizedPath = this.normalizeGitPath(filePath);
333+
const exists = await this.fileExists(rootPath, normalizedPath, existsCache);
334+
if (!exists) {
335+
continue;
336+
}
265337
const languageId = await this.getLanguageForPath(rootPath, normalizedPath, languageByPath);
266338
languageTotals.set(languageId, (languageTotals.get(languageId) ?? 0) + added);
267339
}
@@ -283,6 +355,7 @@ export class StatsService {
283355

284356
const fileTotals = new Map<string, { added: number; deleted: number }>();
285357
const languageByPath = new Map<string, string>();
358+
const existsCache = new Map<string, boolean>();
286359
let currentAuthor = 'Unknown';
287360
const lines = output.split(/\r?\n/);
288361
for (const line of lines) {
@@ -314,6 +387,10 @@ export class StatsService {
314387
}
315388

316389
const normalizedPath = this.normalizeGitPath(filePath);
390+
const exists = await this.fileExists(rootPath, normalizedPath, existsCache);
391+
if (!exists) {
392+
continue;
393+
}
317394
const fileLanguageId = await this.getLanguageForPath(rootPath, normalizedPath, languageByPath);
318395
if (fileLanguageId !== languageId) {
319396
continue;
@@ -363,6 +440,27 @@ export class StatsService {
363440
return languageId;
364441
}
365442

443+
private async fileExists(
444+
rootPath: string,
445+
filePath: string,
446+
cache: Map<string, boolean>
447+
): Promise<boolean> {
448+
if (cache.has(filePath)) {
449+
return cache.get(filePath) ?? false;
450+
}
451+
const absolutePath = path.isAbsolute(filePath)
452+
? filePath
453+
: path.join(rootPath, filePath);
454+
try {
455+
await vscode.workspace.fs.stat(vscode.Uri.file(absolutePath));
456+
cache.set(filePath, true);
457+
return true;
458+
} catch {
459+
cache.set(filePath, false);
460+
return false;
461+
}
462+
}
463+
366464
private languageFromExtension(filePath: string): string {
367465
const ext = path.extname(filePath).toLowerCase();
368466
if (!ext) {

src/types/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export type { GitignoreMatcher, GitignoreRule } from './gitignore';
2+
export type { LineCountResult } from './lineCount';
3+
export type {
4+
ContributorFileHistoryEntry,
5+
ContributorFileStat,
6+
ContributorLanguageStat,
7+
ContributorStat,
8+
ContributorStatsResult,
9+
LanguageStat,
10+
LanguageStatsResult
11+
} from './stats';
File renamed without changes.

src/views/StatsTreeProvider.ts

Lines changed: 73 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ export class StatsTreeProvider implements vscode.TreeDataProvider<StatsNode> {
2626
if (!element) {
2727
return [
2828
new StatsNode('languages', 'Languages', vscode.TreeItemCollapsibleState.Collapsed),
29-
new StatsNode('contributors', 'Contributors', vscode.TreeItemCollapsibleState.Collapsed)
29+
new StatsNode('contributors', 'Contributors (Workspace)', vscode.TreeItemCollapsibleState.Collapsed),
30+
new StatsNode('contributorsAll', 'Contributors (All)', vscode.TreeItemCollapsibleState.Collapsed)
3031
];
3132
}
3233

@@ -38,9 +39,16 @@ export class StatsTreeProvider implements vscode.TreeDataProvider<StatsNode> {
3839
return this.getContributorNodes();
3940
}
4041

42+
if (element.kind === 'contributorsAll') {
43+
return this.getContributorAllNodes();
44+
}
45+
4146
if (element.kind === 'contributor') {
4247
return this.getContributorLanguageNodes(element.value ?? String(element.label));
4348
}
49+
if (element.kind === 'contributorAll') {
50+
return [];
51+
}
4452

4553
if (element.kind === 'contributorLanguage') {
4654
const author = element.meta?.author ?? '';
@@ -91,7 +99,7 @@ export class StatsTreeProvider implements vscode.TreeDataProvider<StatsNode> {
9199
private async getContributorNodes(): Promise<StatsNode[]> {
92100
const result = await this.statsService.getContributorStats();
93101
if (!result.available) {
94-
return [new StatsNode('info', 'Git repository not found', vscode.TreeItemCollapsibleState.None)];
102+
return [this.createGitMissingNode()];
95103
}
96104
if (result.stats.length === 0) {
97105
return [new StatsNode('info', 'No Git history available', vscode.TreeItemCollapsibleState.None)];
@@ -106,6 +114,35 @@ export class StatsTreeProvider implements vscode.TreeDataProvider<StatsNode> {
106114
});
107115
}
108116

117+
private async getContributorAllNodes(): Promise<StatsNode[]> {
118+
const result = await this.statsService.getContributorStatsAll();
119+
if (!result.available) {
120+
return [this.createGitMissingNode()];
121+
}
122+
if (result.stats.length === 0) {
123+
return [new StatsNode('info', 'No Git history available', vscode.TreeItemCollapsibleState.None)];
124+
}
125+
126+
return result.stats.map((entry) => {
127+
const description = `${entry.linesAdded} lines`;
128+
const node = new StatsNode('contributorAll', entry.name, vscode.TreeItemCollapsibleState.None);
129+
node.description = description;
130+
node.tooltip = `${entry.linesAdded} lines added by ${entry.name}`;
131+
return node;
132+
});
133+
}
134+
135+
private createGitMissingNode(): StatsNode {
136+
const node = new StatsNode('info', 'Git repo hasn’t been defined', vscode.TreeItemCollapsibleState.None);
137+
node.description = 'Git repo required';
138+
node.tooltip = 'Git repo hasn’t been defined';
139+
node.command = {
140+
command: 'codecount.gitRepoMissing',
141+
title: 'Git repo hasn’t been defined'
142+
};
143+
return node;
144+
}
145+
109146
private async getContributorLanguageNodes(authorName: string): Promise<StatsNode[]> {
110147
const result = await this.statsService.getContributorLanguageStats(authorName);
111148
if (!result.available) {
@@ -143,13 +180,23 @@ export class StatsTreeProvider implements vscode.TreeDataProvider<StatsNode> {
143180
return [new StatsNode('info', 'No file stats available', vscode.TreeItemCollapsibleState.None)];
144181
}
145182

183+
const rootPath = await this.statsService.getGitRootPath();
184+
if (!rootPath) {
185+
return [new StatsNode('info', 'Git repository not found', vscode.TreeItemCollapsibleState.None)];
186+
}
187+
188+
const existingStats = await this.filterExistingFiles(rootPath, result.stats);
189+
if (existingStats.length === 0) {
190+
return [new StatsNode('info', 'No files found in workspace', vscode.TreeItemCollapsibleState.None)];
191+
}
192+
146193
const infoNode = new StatsNode(
147194
'info',
148195
'Showing top 10 files by changes',
149196
vscode.TreeItemCollapsibleState.None
150197
);
151198

152-
const fileNodes = result.stats.slice(0, 10).map((entry) => {
199+
const fileNodes = existingStats.slice(0, 10).map((entry) => {
153200
const description = `+${entry.added} -${entry.deleted}`;
154201
const node = new StatsNode(
155202
'contributorFile',
@@ -171,6 +218,27 @@ export class StatsTreeProvider implements vscode.TreeDataProvider<StatsNode> {
171218
return [infoNode, ...fileNodes];
172219
}
173220

221+
private async filterExistingFiles(
222+
rootPath: string,
223+
stats: Array<{ filePath: string; added: number; deleted: number }>
224+
): Promise<Array<{ filePath: string; added: number; deleted: number }>> {
225+
const checks = await Promise.all(
226+
stats.map(async (entry) => {
227+
const absolutePath = vscode.Uri.file(path.join(rootPath, entry.filePath));
228+
try {
229+
await vscode.workspace.fs.stat(absolutePath);
230+
return entry;
231+
} catch {
232+
return null;
233+
}
234+
})
235+
);
236+
237+
return checks.filter(
238+
(entry): entry is { filePath: string; added: number; deleted: number } => entry !== null
239+
);
240+
}
241+
174242
private formatLanguageLabel(languageId: string): string {
175243
if (!languageId) {
176244
return 'Unknown';
@@ -185,14 +253,14 @@ export class StatsTreeProvider implements vscode.TreeDataProvider<StatsNode> {
185253

186254
export class StatsNode extends vscode.TreeItem {
187255
constructor(
188-
public readonly kind: 'languages' | 'contributors' | 'language' | 'contributor' | 'contributorLanguage' | 'contributorFile' | 'info',
256+
public readonly kind: 'languages' | 'contributors' | 'contributorsAll' | 'language' | 'contributor' | 'contributorAll' | 'contributorLanguage' | 'contributorFile' | 'info',
189257
label: string,
190258
collapsibleState: vscode.TreeItemCollapsibleState,
191259
public readonly value?: string,
192260
public readonly meta?: { author?: string; languageId?: string; filePath?: string; added?: number; deleted?: number }
193261
) {
194262
super(label, collapsibleState);
195-
if (kind === 'languages' || kind === 'contributors') {
263+
if (kind === 'languages' || kind === 'contributors' || kind === 'contributorsAll') {
196264
this.iconPath = new vscode.ThemeIcon('graph');
197265
}
198266
if (kind === 'info') {

src/views/StatsWebviewPanel.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as vscode from 'vscode';
22
import { StatsService } from '../services/StatsService';
3-
import type { ContributorStat, LanguageStat } from './stats';
3+
import type { ContributorStat, LanguageStat } from '../types';
44

55
export class StatsWebviewPanel {
66
private static currentPanel: StatsWebviewPanel | undefined;

0 commit comments

Comments
 (0)