mc-gitpulse 1.0.1 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/analyzer.js +370 -0
- package/cli.js +55 -9
- package/package.json +3 -3
- package/public/index.html +564 -50
- package/server.js +96 -2
package/README.md
CHANGED
|
@@ -48,6 +48,11 @@
|
|
|
48
48
|
## Demo
|
|
49
49
|

|
|
50
50
|
|
|
51
|
+
## Screenshot
|
|
52
|
+
|
|
53
|
+

|
|
54
|
+
|
|
55
|
+
|
|
51
56
|
## 📦 Installation
|
|
52
57
|
|
|
53
58
|
```bash
|
|
@@ -75,6 +80,11 @@ gitpulse -g /path/to/repo
|
|
|
75
80
|
# Specify git repository path
|
|
76
81
|
gitpulse --git-folder /path/to/repo
|
|
77
82
|
|
|
83
|
+
# or URL for the git repo
|
|
84
|
+
|
|
85
|
+
gitpulse -g git-repo-url
|
|
86
|
+
|
|
87
|
+
|
|
78
88
|
# Specify custom port
|
|
79
89
|
gitpulse --port 8080 --git-folder /path/to/repo
|
|
80
90
|
|
package/analyzer.js
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
const simpleGit = require('simple-git');
|
|
2
|
+
const fs = require('fs').promises;
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
class GitAnalyzer {
|
|
6
|
+
constructor(repoPath) {
|
|
7
|
+
this.repoPath = repoPath;
|
|
8
|
+
this.git = simpleGit(repoPath);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async analyze() {
|
|
12
|
+
const [
|
|
13
|
+
branches,
|
|
14
|
+
tags,
|
|
15
|
+
remotes,
|
|
16
|
+
status,
|
|
17
|
+
log,
|
|
18
|
+
contributors,
|
|
19
|
+
repoSize,
|
|
20
|
+
garbageInfo
|
|
21
|
+
] = await Promise.all([
|
|
22
|
+
this.getBranches(),
|
|
23
|
+
this.getTags(),
|
|
24
|
+
this.getRemotes(),
|
|
25
|
+
this.getStatus(),
|
|
26
|
+
this.getLog(),
|
|
27
|
+
this.getContributors(),
|
|
28
|
+
this.getRepoSize(),
|
|
29
|
+
this.analyzeGarbage()
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
const stats = this.calculateStats(log);
|
|
33
|
+
const health = this.assessRepoHealth(log, garbageInfo);
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
branches,
|
|
37
|
+
tags,
|
|
38
|
+
remotes,
|
|
39
|
+
status,
|
|
40
|
+
commitCount: log.total,
|
|
41
|
+
recentCommits: log.all.slice(0, 50),
|
|
42
|
+
contributors,
|
|
43
|
+
stats,
|
|
44
|
+
repoSize,
|
|
45
|
+
garbageInfo,
|
|
46
|
+
health,
|
|
47
|
+
path: this.repoPath
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async getBranches() {
|
|
52
|
+
const summary = await this.git.branch(['-a', '-v', '--sort=-committerdate']);
|
|
53
|
+
const current = summary.current;
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
current,
|
|
57
|
+
all: Object.entries(summary.branches).map(([name, info]) => ({
|
|
58
|
+
name,
|
|
59
|
+
current: name === current,
|
|
60
|
+
commit: info.commit,
|
|
61
|
+
label: info.label,
|
|
62
|
+
remote: name.startsWith('remotes/')
|
|
63
|
+
}))
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async getTags() {
|
|
68
|
+
const tags = await this.git.tags();
|
|
69
|
+
return tags.all;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async getRemotes() {
|
|
73
|
+
const remotes = await this.git.getRemotes(true);
|
|
74
|
+
return remotes;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async getStatus() {
|
|
78
|
+
const status = await this.git.status();
|
|
79
|
+
return {
|
|
80
|
+
modified: status.modified,
|
|
81
|
+
created: status.created,
|
|
82
|
+
deleted: status.deleted,
|
|
83
|
+
renamed: status.renamed,
|
|
84
|
+
staged: status.staged,
|
|
85
|
+
conflicted: status.conflicted,
|
|
86
|
+
ahead: status.ahead,
|
|
87
|
+
behind: status.behind,
|
|
88
|
+
tracking: status.tracking,
|
|
89
|
+
isClean: status.isClean()
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async getLog() {
|
|
94
|
+
const log = await this.git.log({
|
|
95
|
+
'--all': null,
|
|
96
|
+
'--max-count': 1000,
|
|
97
|
+
'--no-merges': null
|
|
98
|
+
});
|
|
99
|
+
return log;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async getContributors() {
|
|
103
|
+
try {
|
|
104
|
+
const result = await this.git.raw([
|
|
105
|
+
'shortlog',
|
|
106
|
+
'-sn',
|
|
107
|
+
'--all',
|
|
108
|
+
'--no-merges'
|
|
109
|
+
]);
|
|
110
|
+
|
|
111
|
+
return result
|
|
112
|
+
.trim()
|
|
113
|
+
.split('\n')
|
|
114
|
+
.map(line => {
|
|
115
|
+
const match = line.trim().match(/^(\d+)\s+(.+)$/);
|
|
116
|
+
if (match) {
|
|
117
|
+
return {
|
|
118
|
+
commits: parseInt(match[1]),
|
|
119
|
+
name: match[2]
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
})
|
|
124
|
+
.filter(Boolean);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
calculateStats(log) {
|
|
131
|
+
const commits = log.all;
|
|
132
|
+
const now = new Date();
|
|
133
|
+
const dayAgo = new Date(now - 24 * 60 * 60 * 1000);
|
|
134
|
+
const weekAgo = new Date(now - 7 * 24 * 60 * 60 * 1000);
|
|
135
|
+
const monthAgo = new Date(now - 30 * 24 * 60 * 60 * 1000);
|
|
136
|
+
|
|
137
|
+
const commitsLastDay = commits.filter(c => new Date(c.date) > dayAgo).length;
|
|
138
|
+
const commitsLastWeek = commits.filter(c => new Date(c.date) > weekAgo).length;
|
|
139
|
+
const commitsLastMonth = commits.filter(c => new Date(c.date) > monthAgo).length;
|
|
140
|
+
|
|
141
|
+
// Calculate commits per day of week
|
|
142
|
+
const dayOfWeek = [0, 0, 0, 0, 0, 0, 0];
|
|
143
|
+
const hourOfDay = new Array(24).fill(0);
|
|
144
|
+
|
|
145
|
+
commits.forEach(commit => {
|
|
146
|
+
const date = new Date(commit.date);
|
|
147
|
+
dayOfWeek[date.getDay()]++;
|
|
148
|
+
hourOfDay[date.getHours()]++;
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
total: log.total,
|
|
153
|
+
commitsLastDay,
|
|
154
|
+
commitsLastWeek,
|
|
155
|
+
commitsLastMonth,
|
|
156
|
+
dayOfWeek,
|
|
157
|
+
hourOfDay,
|
|
158
|
+
avgCommitsPerDay: (commitsLastMonth / 30).toFixed(2)
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async getRepoSize() {
|
|
163
|
+
try {
|
|
164
|
+
const gitDir = path.join(this.repoPath, '.git');
|
|
165
|
+
const size = await this.getDirectorySize(gitDir);
|
|
166
|
+
return {
|
|
167
|
+
bytes: size,
|
|
168
|
+
mb: (size / 1024 / 1024).toFixed(2),
|
|
169
|
+
gb: (size / 1024 / 1024 / 1024).toFixed(3)
|
|
170
|
+
};
|
|
171
|
+
} catch (error) {
|
|
172
|
+
return { bytes: 0, mb: '0', gb: '0' };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async getDirectorySize(dirPath) {
|
|
177
|
+
let totalSize = 0;
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
const items = await fs.readdir(dirPath, { withFileTypes: true });
|
|
181
|
+
|
|
182
|
+
for (const item of items) {
|
|
183
|
+
const itemPath = path.join(dirPath, item.name);
|
|
184
|
+
|
|
185
|
+
if (item.isDirectory()) {
|
|
186
|
+
totalSize += await this.getDirectorySize(itemPath);
|
|
187
|
+
} else {
|
|
188
|
+
const stats = await fs.stat(itemPath);
|
|
189
|
+
totalSize += stats.size;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
} catch (error) {
|
|
193
|
+
// Ignore errors for inaccessible files
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return totalSize;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async analyzeGarbage() {
|
|
200
|
+
try {
|
|
201
|
+
// Get count-objects info
|
|
202
|
+
const countObjects = await this.git.raw(['count-objects', '-v']);
|
|
203
|
+
const lines = countObjects.split('\n');
|
|
204
|
+
const info = {};
|
|
205
|
+
|
|
206
|
+
lines.forEach(line => {
|
|
207
|
+
const [key, value] = line.split(':').map(s => s.trim());
|
|
208
|
+
if (key && value) {
|
|
209
|
+
info[key] = value;
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const looseObjects = parseInt(info['count'] || 0);
|
|
214
|
+
const looseSize = parseFloat(info['size'] || 0);
|
|
215
|
+
const packCount = parseInt(info['packs'] || 0);
|
|
216
|
+
const packSize = parseFloat(info['size-pack'] || 0);
|
|
217
|
+
|
|
218
|
+
// Recommend GC if there are many loose objects
|
|
219
|
+
const shouldGC = looseObjects > 1000 || looseSize > 10;
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
looseObjects,
|
|
223
|
+
looseSize,
|
|
224
|
+
packCount,
|
|
225
|
+
packSize,
|
|
226
|
+
shouldGC,
|
|
227
|
+
recommendation: shouldGC
|
|
228
|
+
? 'Run `git gc` to optimize repository performance'
|
|
229
|
+
: 'Repository is well optimized'
|
|
230
|
+
};
|
|
231
|
+
} catch (error) {
|
|
232
|
+
return {
|
|
233
|
+
looseObjects: 0,
|
|
234
|
+
looseSize: 0,
|
|
235
|
+
packCount: 0,
|
|
236
|
+
packSize: 0,
|
|
237
|
+
shouldGC: false,
|
|
238
|
+
recommendation: 'Unable to analyze'
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
assessRepoHealth(log, garbageInfo) {
|
|
244
|
+
const scores = [];
|
|
245
|
+
const issues = [];
|
|
246
|
+
const suggestions = [];
|
|
247
|
+
|
|
248
|
+
// Check commit frequency
|
|
249
|
+
const commits = log.all;
|
|
250
|
+
const now = new Date();
|
|
251
|
+
const weekAgo = new Date(now - 7 * 24 * 60 * 60 * 1000);
|
|
252
|
+
const recentCommits = commits.filter(c => new Date(c.date) > weekAgo).length;
|
|
253
|
+
|
|
254
|
+
if (recentCommits > 0) {
|
|
255
|
+
scores.push(100);
|
|
256
|
+
} else {
|
|
257
|
+
scores.push(50);
|
|
258
|
+
issues.push('No commits in the last week');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Check garbage collection
|
|
262
|
+
if (garbageInfo.shouldGC) {
|
|
263
|
+
scores.push(60);
|
|
264
|
+
issues.push('Repository needs garbage collection');
|
|
265
|
+
suggestions.push('Run: git gc --aggressive');
|
|
266
|
+
} else {
|
|
267
|
+
scores.push(100);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Check for large pack files
|
|
271
|
+
if (garbageInfo.packSize > 100) {
|
|
272
|
+
issues.push('Large pack files detected');
|
|
273
|
+
suggestions.push('Consider using git-lfs for large files');
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const avgScore = scores.reduce((a, b) => a + b, 0) / scores.length;
|
|
277
|
+
|
|
278
|
+
let status = 'excellent';
|
|
279
|
+
if (avgScore < 90) status = 'good';
|
|
280
|
+
if (avgScore < 70) status = 'needs attention';
|
|
281
|
+
if (avgScore < 50) status = 'poor';
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
score: Math.round(avgScore),
|
|
285
|
+
status,
|
|
286
|
+
issues,
|
|
287
|
+
suggestions
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async getFileTree() {
|
|
292
|
+
try {
|
|
293
|
+
const result = await this.git.raw(['ls-tree', '-r', '--name-only', 'HEAD']);
|
|
294
|
+
const files = result.trim().split('\n').filter(Boolean);
|
|
295
|
+
|
|
296
|
+
// Build tree structure
|
|
297
|
+
const tree = {};
|
|
298
|
+
|
|
299
|
+
files.forEach(filepath => {
|
|
300
|
+
const parts = filepath.split('/');
|
|
301
|
+
let current = tree;
|
|
302
|
+
|
|
303
|
+
parts.forEach((part, index) => {
|
|
304
|
+
if (index === parts.length - 1) {
|
|
305
|
+
// It's a file
|
|
306
|
+
if (!current._files) current._files = [];
|
|
307
|
+
current._files.push(part);
|
|
308
|
+
} else {
|
|
309
|
+
// It's a directory
|
|
310
|
+
if (!current[part]) current[part] = {};
|
|
311
|
+
current = current[part];
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
return tree;
|
|
317
|
+
} catch (error) {
|
|
318
|
+
return {};
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async getCommitGraph(limit = 100) {
|
|
323
|
+
try {
|
|
324
|
+
const result = await this.git.raw([
|
|
325
|
+
'log',
|
|
326
|
+
'--all',
|
|
327
|
+
'--graph',
|
|
328
|
+
'--pretty=format:%H|%P|%s|%an|%ar',
|
|
329
|
+
`--max-count=${limit}`
|
|
330
|
+
]);
|
|
331
|
+
|
|
332
|
+
const lines = result.split('\n');
|
|
333
|
+
const commits = [];
|
|
334
|
+
|
|
335
|
+
lines.forEach(line => {
|
|
336
|
+
// Match graph characters: *, |, \, /, space
|
|
337
|
+
const graphMatch = line.match(/^([*|\\\/ ]+)/);
|
|
338
|
+
const graph = graphMatch ? graphMatch[1] : '';
|
|
339
|
+
const data = line.substring(graph.length);
|
|
340
|
+
|
|
341
|
+
if (data && data.includes('|')) {
|
|
342
|
+
const parts = data.split('|');
|
|
343
|
+
if (parts.length >= 5) {
|
|
344
|
+
const [hash, parents, subject, author, date] = parts;
|
|
345
|
+
commits.push({
|
|
346
|
+
graph,
|
|
347
|
+
hash: hash.trim(),
|
|
348
|
+
parents: parents ? parents.trim().split(' ') : [],
|
|
349
|
+
subject: subject.trim(),
|
|
350
|
+
author: author.trim(),
|
|
351
|
+
date: date.trim()
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
return commits;
|
|
358
|
+
} catch (error) {
|
|
359
|
+
console.error('Error getting commit graph:', error);
|
|
360
|
+
return [];
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async function analyzeRepo(repoPath) {
|
|
366
|
+
const analyzer = new GitAnalyzer(repoPath);
|
|
367
|
+
return await analyzer.analyze();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
module.exports = { GitAnalyzer, analyzeRepo };
|
package/cli.js
CHANGED
|
@@ -2,23 +2,67 @@
|
|
|
2
2
|
|
|
3
3
|
const { program } = require('commander');
|
|
4
4
|
const path = require('path');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
5
7
|
const chalk = require('chalk');
|
|
6
8
|
const boxen = require('boxen');
|
|
9
|
+
const ora = require('ora');
|
|
7
10
|
const { startServer } = require('./server');
|
|
8
11
|
const { analyzeRepo } = require('./analyzer');
|
|
12
|
+
const simpleGit = require('simple-git');
|
|
9
13
|
|
|
10
14
|
program
|
|
11
15
|
.name('gitpulse')
|
|
12
|
-
.description('The ultimate Git UI
|
|
16
|
+
.description('The ultimate Git UI' )
|
|
13
17
|
.version('1.0.0');
|
|
14
18
|
|
|
19
|
+
async function cloneRepository(url, targetDir) {
|
|
20
|
+
const spinner = ora('Cloning repository...').start();
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const git = simpleGit();
|
|
24
|
+
await git.clone(url, targetDir, ['--depth', '1000']); // Limit depth for performance
|
|
25
|
+
spinner.succeed(chalk.green('Repository cloned successfully'));
|
|
26
|
+
return targetDir;
|
|
27
|
+
} catch (error) {
|
|
28
|
+
spinner.fail(chalk.red('Failed to clone repository'));
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isGitUrl(input) {
|
|
34
|
+
// Check if input looks like a Git URL
|
|
35
|
+
return input.startsWith('http://') ||
|
|
36
|
+
input.startsWith('https://') ||
|
|
37
|
+
input.startsWith('git@') ||
|
|
38
|
+
input.startsWith('ssh://') ||
|
|
39
|
+
input.includes('github.com') ||
|
|
40
|
+
input.includes('gitlab.com') ||
|
|
41
|
+
input.includes('bitbucket.org');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function resolveGitPath(input) {
|
|
45
|
+
if (isGitUrl(input)) {
|
|
46
|
+
// It's a URL, clone it to a temp directory
|
|
47
|
+
const tempDir = path.join(os.tmpdir(), 'gitpulse-' + Date.now());
|
|
48
|
+
fs.mkdirSync(tempDir, { recursive: true });
|
|
49
|
+
|
|
50
|
+
console.log(chalk.yellow('🌐 Remote repository detected'));
|
|
51
|
+
console.log(chalk.dim(` Cloning to: ${tempDir}`));
|
|
52
|
+
|
|
53
|
+
await cloneRepository(input, tempDir);
|
|
54
|
+
return tempDir;
|
|
55
|
+
} else {
|
|
56
|
+
// It's a local path
|
|
57
|
+
return path.resolve(input);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
15
61
|
program
|
|
16
|
-
.option('-g, --git-folder <path>', 'Path to git repository', process.cwd())
|
|
62
|
+
.option('-g, --git-folder <path>', 'Path to git repository or Git URL', process.cwd())
|
|
17
63
|
.option('-p, --port <number>', 'Port for web server', '3000')
|
|
18
64
|
.option('--no-browser', 'Don\'t open browser automatically')
|
|
19
65
|
.action(async (options) => {
|
|
20
|
-
const gitPath = path.resolve(options.gitFolder);
|
|
21
|
-
|
|
22
66
|
console.log(boxen(
|
|
23
67
|
chalk.bold.cyan('🚀 GitPulse') + '\n\n' +
|
|
24
68
|
chalk.green('The Ultimate Git Repository Analyzer') + '\n' +
|
|
@@ -31,11 +75,13 @@ program
|
|
|
31
75
|
}
|
|
32
76
|
));
|
|
33
77
|
|
|
34
|
-
console.log(chalk.yellow('📁 Repository:'), chalk.white(gitPath));
|
|
35
|
-
console.log(chalk.yellow('🌐 Port:'), chalk.white(options.port));
|
|
36
|
-
console.log();
|
|
37
|
-
|
|
38
78
|
try {
|
|
79
|
+
const gitPath = await resolveGitPath(options.gitFolder);
|
|
80
|
+
|
|
81
|
+
console.log(chalk.yellow('📁 Repository:'), chalk.white(gitPath));
|
|
82
|
+
console.log(chalk.yellow('🌐 Port:'), chalk.white(options.port));
|
|
83
|
+
console.log();
|
|
84
|
+
|
|
39
85
|
// Quick analysis
|
|
40
86
|
const analysis = await analyzeRepo(gitPath);
|
|
41
87
|
|
|
@@ -54,4 +100,4 @@ program
|
|
|
54
100
|
}
|
|
55
101
|
});
|
|
56
102
|
|
|
57
|
-
program.parse();
|
|
103
|
+
program.parse();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mc-gitpulse",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "The ultimate Git UI - CLI + Web interface with advanced repository insights",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -19,9 +19,9 @@
|
|
|
19
19
|
],
|
|
20
20
|
"author": "Mohan Chinnappan",
|
|
21
21
|
"files": [
|
|
22
|
-
"server.js", "
|
|
22
|
+
"server.js", "analyzer.js", "cli.js",
|
|
23
|
+
"public/*"
|
|
23
24
|
],
|
|
24
|
-
|
|
25
25
|
"license": "MIT",
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"boxen": "^5.1.2",
|
package/public/index.html
CHANGED
|
@@ -7,8 +7,7 @@
|
|
|
7
7
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
8
8
|
<link rel="icon" type="image/x-icon"
|
|
9
9
|
href="https://mohan-chinnappan-n5.github.io/dfv/img/mc_favIcon.ico" />
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
|
|
12
11
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
13
12
|
<script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs/loader.js"></script>
|
|
14
13
|
<style>
|
|
@@ -61,16 +60,25 @@
|
|
|
61
60
|
transform: translateY(-2px);
|
|
62
61
|
}
|
|
63
62
|
|
|
64
|
-
.
|
|
63
|
+
.file-tree-container {
|
|
65
64
|
display: flex;
|
|
66
|
-
height:
|
|
67
|
-
gap:
|
|
65
|
+
height: 650px;
|
|
66
|
+
gap: 0;
|
|
67
|
+
position: relative;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
.split-left {
|
|
71
|
-
flex: 0 0
|
|
71
|
+
flex: 0 0 350px;
|
|
72
72
|
overflow-y: auto;
|
|
73
|
-
|
|
73
|
+
min-width: 200px;
|
|
74
|
+
max-width: 600px;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.file-history-sidebar {
|
|
78
|
+
flex: 0 0 280px;
|
|
79
|
+
overflow-y: auto;
|
|
80
|
+
min-width: 200px;
|
|
81
|
+
max-width: 500px;
|
|
74
82
|
}
|
|
75
83
|
|
|
76
84
|
.split-right {
|
|
@@ -78,15 +86,85 @@
|
|
|
78
86
|
overflow: hidden;
|
|
79
87
|
display: flex;
|
|
80
88
|
flex-direction: column;
|
|
89
|
+
min-width: 400px;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.splitter {
|
|
93
|
+
width: 4px;
|
|
94
|
+
background: #374151;
|
|
95
|
+
cursor: col-resize;
|
|
96
|
+
position: relative;
|
|
97
|
+
transition: background 0.2s;
|
|
98
|
+
flex-shrink: 0;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.splitter:hover {
|
|
102
|
+
background: #3b82f6;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.splitter:active {
|
|
106
|
+
background: #2563eb;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.splitter::before {
|
|
110
|
+
content: '';
|
|
111
|
+
position: absolute;
|
|
112
|
+
top: 50%;
|
|
113
|
+
left: 50%;
|
|
114
|
+
transform: translate(-50%, -50%);
|
|
115
|
+
width: 20px;
|
|
116
|
+
height: 40px;
|
|
117
|
+
background: transparent;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
#monaco-editor-container {
|
|
121
|
+
flex: 1;
|
|
122
|
+
position: relative;
|
|
81
123
|
}
|
|
82
124
|
|
|
83
|
-
#monaco-editor {
|
|
125
|
+
#monaco-editor, #monaco-diff-editor {
|
|
84
126
|
height: 100%;
|
|
85
127
|
border: 1px solid #374151;
|
|
86
128
|
border-radius: 0.5rem;
|
|
87
129
|
overflow: hidden;
|
|
88
130
|
}
|
|
89
131
|
|
|
132
|
+
#monaco-diff-editor {
|
|
133
|
+
position: absolute;
|
|
134
|
+
top: 0;
|
|
135
|
+
left: 0;
|
|
136
|
+
right: 0;
|
|
137
|
+
bottom: 0;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.commit-item {
|
|
141
|
+
padding: 0.5rem;
|
|
142
|
+
border-radius: 0.375rem;
|
|
143
|
+
border: 1px solid #374151;
|
|
144
|
+
cursor: pointer;
|
|
145
|
+
transition: all 0.2s;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.commit-item:hover {
|
|
149
|
+
background-color: rgba(59, 130, 246, 0.1);
|
|
150
|
+
border-color: #3b82f6;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.commit-item.selected {
|
|
154
|
+
background-color: rgba(59, 130, 246, 0.2);
|
|
155
|
+
border-color: #3b82f6;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.commit-item.selected-1 {
|
|
159
|
+
background-color: rgba(239, 68, 68, 0.2);
|
|
160
|
+
border-color: #ef4444;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.commit-item.selected-2 {
|
|
164
|
+
background-color: rgba(16, 185, 129, 0.2);
|
|
165
|
+
border-color: #10b981;
|
|
166
|
+
}
|
|
167
|
+
|
|
90
168
|
.file-item {
|
|
91
169
|
cursor: pointer;
|
|
92
170
|
padding: 0.5rem;
|
|
@@ -122,7 +200,6 @@
|
|
|
122
200
|
|
|
123
201
|
.commit-node {
|
|
124
202
|
font-family: monospace;
|
|
125
|
-
white-space: pre;
|
|
126
203
|
}
|
|
127
204
|
|
|
128
205
|
/* Modal/Popup Styles */
|
|
@@ -509,14 +586,44 @@
|
|
|
509
586
|
<!-- File Tree Tab -->
|
|
510
587
|
<div id="tree-tab" class="tab-content">
|
|
511
588
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
|
512
|
-
<
|
|
513
|
-
|
|
589
|
+
<div class="flex items-center justify-between mb-4">
|
|
590
|
+
<h3 class="text-xl font-bold">📂 File Tree</h3>
|
|
591
|
+
<div class="flex items-center space-x-4">
|
|
592
|
+
<button id="view-mode-btn" onclick="toggleViewMode()" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded transition text-sm">
|
|
593
|
+
📊 View Mode: <span id="view-mode-text">Single</span>
|
|
594
|
+
</button>
|
|
595
|
+
<button id="clear-selection-btn" onclick="clearCommitSelection()" class="hidden px-4 py-2 bg-gray-600 hover:bg-gray-700 rounded transition text-sm">
|
|
596
|
+
Clear Selection
|
|
597
|
+
</button>
|
|
598
|
+
</div>
|
|
599
|
+
</div>
|
|
600
|
+
<div class="file-tree-container">
|
|
514
601
|
<div class="split-left bg-gray-900 rounded p-4">
|
|
515
602
|
<div id="file-tree" class="font-mono text-sm"></div>
|
|
516
603
|
</div>
|
|
604
|
+
|
|
605
|
+
<div class="splitter" id="splitter1"></div>
|
|
606
|
+
|
|
607
|
+
<!-- File History Sidebar -->
|
|
608
|
+
<div id="file-history-sidebar" class="file-history-sidebar bg-gray-900 rounded p-4">
|
|
609
|
+
<h4 class="text-sm font-semibold mb-3 text-blue-400">File History</h4>
|
|
610
|
+
<div id="file-history-list" class="space-y-2 text-sm">
|
|
611
|
+
<p class="text-gray-500 text-xs">Select a file to see its commit history</p>
|
|
612
|
+
</div>
|
|
613
|
+
</div>
|
|
614
|
+
|
|
615
|
+
<div class="splitter" id="splitter2"></div>
|
|
616
|
+
|
|
617
|
+
<!-- Editor Area -->
|
|
517
618
|
<div class="split-right">
|
|
518
|
-
<div id="file-info" class="mb-2 text-sm text-gray-400
|
|
519
|
-
|
|
619
|
+
<div id="file-info" class="mb-2 text-sm text-gray-400 flex items-center justify-between">
|
|
620
|
+
<span>Select a file to view</span>
|
|
621
|
+
<span id="diff-info" class="text-xs text-blue-400"></span>
|
|
622
|
+
</div>
|
|
623
|
+
<div id="monaco-editor-container">
|
|
624
|
+
<div id="monaco-editor"></div>
|
|
625
|
+
<div id="monaco-diff-editor" class="hidden"></div>
|
|
626
|
+
</div>
|
|
520
627
|
</div>
|
|
521
628
|
</div>
|
|
522
629
|
</div>
|
|
@@ -580,8 +687,12 @@
|
|
|
580
687
|
let ws;
|
|
581
688
|
let currentData = null;
|
|
582
689
|
let monacoEditor = null;
|
|
690
|
+
let monacoDiffEditor = null;
|
|
583
691
|
let allFiles = [];
|
|
584
692
|
let modalCallback = null;
|
|
693
|
+
let currentFilePath = null;
|
|
694
|
+
let selectedCommits = [];
|
|
695
|
+
let viewMode = 'single'; // 'single' or 'diff'
|
|
585
696
|
|
|
586
697
|
// Modal Functions
|
|
587
698
|
function showModal(options) {
|
|
@@ -712,12 +823,69 @@
|
|
|
712
823
|
loadData();
|
|
713
824
|
setupTabs();
|
|
714
825
|
initMonaco();
|
|
826
|
+
initSplitters();
|
|
715
827
|
});
|
|
716
828
|
|
|
829
|
+
function initSplitters() {
|
|
830
|
+
const splitter1 = document.getElementById('splitter1');
|
|
831
|
+
const splitter2 = document.getElementById('splitter2');
|
|
832
|
+
const leftPane = document.querySelector('.split-left');
|
|
833
|
+
const middlePane = document.querySelector('.file-history-sidebar');
|
|
834
|
+
|
|
835
|
+
let isResizing = false;
|
|
836
|
+
let currentSplitter = null;
|
|
837
|
+
|
|
838
|
+
function startResize(splitter, e) {
|
|
839
|
+
isResizing = true;
|
|
840
|
+
currentSplitter = splitter;
|
|
841
|
+
document.body.style.cursor = 'col-resize';
|
|
842
|
+
document.body.style.userSelect = 'none';
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function stopResize() {
|
|
846
|
+
isResizing = false;
|
|
847
|
+
currentSplitter = null;
|
|
848
|
+
document.body.style.cursor = '';
|
|
849
|
+
document.body.style.userSelect = '';
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function resize(e) {
|
|
853
|
+
if (!isResizing || !currentSplitter) return;
|
|
854
|
+
|
|
855
|
+
const container = document.querySelector('.file-tree-container');
|
|
856
|
+
const containerRect = container.getBoundingClientRect();
|
|
857
|
+
const offsetX = e.clientX - containerRect.left;
|
|
858
|
+
|
|
859
|
+
if (currentSplitter === splitter1) {
|
|
860
|
+
// Resizing left pane
|
|
861
|
+
const newWidth = Math.max(200, Math.min(600, offsetX));
|
|
862
|
+
leftPane.style.flexBasis = newWidth + 'px';
|
|
863
|
+
} else if (currentSplitter === splitter2) {
|
|
864
|
+
// Resizing middle pane
|
|
865
|
+
const leftWidth = leftPane.getBoundingClientRect().width;
|
|
866
|
+
const splitter1Width = splitter1.getBoundingClientRect().width;
|
|
867
|
+
const newWidth = Math.max(200, Math.min(500, offsetX - leftWidth - splitter1Width));
|
|
868
|
+
middlePane.style.flexBasis = newWidth + 'px';
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
if (splitter1) {
|
|
873
|
+
splitter1.addEventListener('mousedown', (e) => startResize(splitter1, e));
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
if (splitter2) {
|
|
877
|
+
splitter2.addEventListener('mousedown', (e) => startResize(splitter2, e));
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
document.addEventListener('mousemove', resize);
|
|
881
|
+
document.addEventListener('mouseup', stopResize);
|
|
882
|
+
}
|
|
883
|
+
|
|
717
884
|
function initMonaco() {
|
|
718
885
|
require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs' } });
|
|
719
886
|
|
|
720
887
|
require(['vs/editor/editor.main'], function() {
|
|
888
|
+
// Single file editor
|
|
721
889
|
monacoEditor = monaco.editor.create(document.getElementById('monaco-editor'), {
|
|
722
890
|
value: '// Select a file from the tree to view its contents',
|
|
723
891
|
language: 'javascript',
|
|
@@ -730,12 +898,27 @@
|
|
|
730
898
|
scrollBeyondLastLine: false,
|
|
731
899
|
wordWrap: 'on'
|
|
732
900
|
});
|
|
901
|
+
|
|
902
|
+
// Diff editor
|
|
903
|
+
monacoDiffEditor = monaco.editor.createDiffEditor(document.getElementById('monaco-diff-editor'), {
|
|
904
|
+
theme: 'vs-dark',
|
|
905
|
+
automaticLayout: true,
|
|
906
|
+
readOnly: true,
|
|
907
|
+
renderSideBySide: true,
|
|
908
|
+
minimap: { enabled: true },
|
|
909
|
+
fontSize: 14,
|
|
910
|
+
lineNumbers: 'on',
|
|
911
|
+
scrollBeyondLastLine: false,
|
|
912
|
+
wordWrap: 'on'
|
|
913
|
+
});
|
|
733
914
|
});
|
|
734
915
|
}
|
|
735
916
|
|
|
736
917
|
async function loadFileContent(filepath) {
|
|
737
918
|
try {
|
|
738
|
-
|
|
919
|
+
currentFilePath = filepath;
|
|
920
|
+
selectedCommits = [];
|
|
921
|
+
updateClearSelectionButton();
|
|
739
922
|
|
|
740
923
|
const ref = currentData.branches.current;
|
|
741
924
|
const response = await fetch(`/api/file/${ref}/${filepath}`);
|
|
@@ -744,18 +927,7 @@
|
|
|
744
927
|
if (data.content) {
|
|
745
928
|
// Detect language from file extension
|
|
746
929
|
const ext = filepath.split('.').pop().toLowerCase();
|
|
747
|
-
const
|
|
748
|
-
js: 'javascript', ts: 'typescript', jsx: 'javascript', tsx: 'typescript',
|
|
749
|
-
py: 'python', java: 'java', cpp: 'cpp', c: 'c', cs: 'csharp',
|
|
750
|
-
html: 'html', css: 'css', scss: 'scss', sass: 'sass',
|
|
751
|
-
json: 'json', xml: 'xml', yaml: 'yaml', yml: 'yaml',
|
|
752
|
-
md: 'markdown', sh: 'shell', bash: 'shell',
|
|
753
|
-
sql: 'sql', php: 'php', rb: 'ruby', go: 'go',
|
|
754
|
-
rs: 'rust', swift: 'swift', kt: 'kotlin',
|
|
755
|
-
vue: 'html', svelte: 'html'
|
|
756
|
-
};
|
|
757
|
-
|
|
758
|
-
const language = languageMap[ext] || 'plaintext';
|
|
930
|
+
const language = getLanguageFromExtension(ext);
|
|
759
931
|
|
|
760
932
|
monaco.editor.setModelLanguage(monacoEditor.getModel(), language);
|
|
761
933
|
monacoEditor.setValue(data.content);
|
|
@@ -767,17 +939,272 @@
|
|
|
767
939
|
</div>
|
|
768
940
|
`;
|
|
769
941
|
|
|
770
|
-
|
|
942
|
+
const filename = filepath.split('/').pop();
|
|
943
|
+
showToast(`✓ ${filename}`, 'success', 2000);
|
|
944
|
+
|
|
945
|
+
// Load file history
|
|
946
|
+
loadFileHistory(filepath);
|
|
771
947
|
}
|
|
772
948
|
} catch (error) {
|
|
773
949
|
monacoEditor.setValue(`// Error loading file: ${error.message}`);
|
|
774
950
|
document.getElementById('file-info').innerHTML = `
|
|
775
951
|
<span class="text-red-400">❌ Error loading ${escapeHtml(filepath)}</span>
|
|
776
952
|
`;
|
|
777
|
-
|
|
953
|
+
const filename = filepath.split('/').pop();
|
|
954
|
+
showToast(`Failed: ${filename}`, 'error', 3000);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
async function loadFileHistory(filepath) {
|
|
959
|
+
try {
|
|
960
|
+
const response = await fetch(`/api/file-history/${encodeURIComponent(filepath)}?limit=1000`);
|
|
961
|
+
const data = await response.json();
|
|
962
|
+
|
|
963
|
+
const historyList = document.getElementById('file-history-list');
|
|
964
|
+
|
|
965
|
+
if (data.commits.length === 0) {
|
|
966
|
+
historyList.innerHTML = '<p class="text-gray-500 text-xs">No commit history available</p>';
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
historyList.innerHTML = `
|
|
971
|
+
<div class="text-xs text-gray-400 mb-2">Showing ${data.commits.length} commits</div>
|
|
972
|
+
${data.commits.map((commit, index) => `
|
|
973
|
+
<div class="commit-item" data-commit="${commit.hash}" onclick="selectCommit('${commit.hash}', ${index})">
|
|
974
|
+
<div class="flex items-center justify-between mb-1">
|
|
975
|
+
<span class="font-mono text-xs text-yellow-400">${commit.shortHash}</span>
|
|
976
|
+
<span class="text-xs text-gray-500">${commit.relativeDate}</span>
|
|
977
|
+
</div>
|
|
978
|
+
<div class="text-xs text-gray-300 mb-1 truncate" title="${escapeHtml(commit.message)}">
|
|
979
|
+
${escapeHtml(commit.message)}
|
|
980
|
+
</div>
|
|
981
|
+
<div class="text-xs text-gray-500 truncate">
|
|
982
|
+
${escapeHtml(commit.author)}
|
|
983
|
+
</div>
|
|
984
|
+
</div>
|
|
985
|
+
`).join('')}
|
|
986
|
+
`;
|
|
987
|
+
|
|
988
|
+
} catch (error) {
|
|
989
|
+
console.error('Error loading file history:', error);
|
|
990
|
+
document.getElementById('file-history-list').innerHTML =
|
|
991
|
+
'<p class="text-red-400 text-xs">Failed to load history</p>';
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
function selectCommit(commitHash, index) {
|
|
996
|
+
if (viewMode === 'single') {
|
|
997
|
+
// In single mode, just view that commit
|
|
998
|
+
viewCommitVersion(commitHash);
|
|
999
|
+
} else {
|
|
1000
|
+
// In diff mode, select up to 2 commits
|
|
1001
|
+
const commitItem = document.querySelector(`[data-commit="${commitHash}"]`);
|
|
1002
|
+
|
|
1003
|
+
if (selectedCommits.includes(commitHash)) {
|
|
1004
|
+
// Deselect
|
|
1005
|
+
selectedCommits = selectedCommits.filter(c => c !== commitHash);
|
|
1006
|
+
commitItem.classList.remove('selected-1', 'selected-2');
|
|
1007
|
+
} else {
|
|
1008
|
+
if (selectedCommits.length >= 2) {
|
|
1009
|
+
// Remove oldest selection
|
|
1010
|
+
const oldCommit = selectedCommits.shift();
|
|
1011
|
+
const oldItem = document.querySelector(`[data-commit="${oldCommit}"]`);
|
|
1012
|
+
if (oldItem) oldItem.classList.remove('selected-1', 'selected-2');
|
|
1013
|
+
}
|
|
1014
|
+
selectedCommits.push(commitHash);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// Update visual selection
|
|
1018
|
+
document.querySelectorAll('.commit-item').forEach(item => {
|
|
1019
|
+
item.classList.remove('selected-1', 'selected-2');
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
if (selectedCommits.length > 0) {
|
|
1023
|
+
const item1 = document.querySelector(`[data-commit="${selectedCommits[0]}"]`);
|
|
1024
|
+
if (item1) item1.classList.add('selected-1');
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
if (selectedCommits.length > 1) {
|
|
1028
|
+
const item2 = document.querySelector(`[data-commit="${selectedCommits[1]}"]`);
|
|
1029
|
+
if (item2) item2.classList.add('selected-2');
|
|
1030
|
+
|
|
1031
|
+
// Show diff
|
|
1032
|
+
viewDiff(selectedCommits[0], selectedCommits[1]);
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
updateClearSelectionButton();
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
async function viewCommitVersion(commitHash) {
|
|
1040
|
+
try {
|
|
1041
|
+
const response = await fetch(`/api/file/${commitHash}/${currentFilePath}`);
|
|
1042
|
+
const data = await response.json();
|
|
1043
|
+
|
|
1044
|
+
if (data.content) {
|
|
1045
|
+
const ext = currentFilePath.split('.').pop().toLowerCase();
|
|
1046
|
+
const language = getLanguageFromExtension(ext);
|
|
1047
|
+
|
|
1048
|
+
monaco.editor.setModelLanguage(monacoEditor.getModel(), language);
|
|
1049
|
+
monacoEditor.setValue(data.content);
|
|
1050
|
+
|
|
1051
|
+
const shortHash = commitHash.substring(0, 7);
|
|
1052
|
+
document.getElementById('file-info').innerHTML = `
|
|
1053
|
+
<div class="flex items-center justify-between">
|
|
1054
|
+
<span class="text-blue-400">📄 ${escapeHtml(currentFilePath)} @ ${shortHash}</span>
|
|
1055
|
+
<span class="text-gray-500">${language}</span>
|
|
1056
|
+
</div>
|
|
1057
|
+
`;
|
|
1058
|
+
|
|
1059
|
+
showToast(`Viewing @ ${shortHash}`, 'success', 2000);
|
|
1060
|
+
}
|
|
1061
|
+
} catch (error) {
|
|
1062
|
+
showToast('Failed to load commit version', 'error', 3000);
|
|
778
1063
|
}
|
|
779
1064
|
}
|
|
780
1065
|
|
|
1066
|
+
async function viewDiff(commit1, commit2) {
|
|
1067
|
+
try {
|
|
1068
|
+
// Check if Monaco is ready
|
|
1069
|
+
if (!monacoDiffEditor) {
|
|
1070
|
+
throw new Error('Monaco Diff Editor not initialized yet. Please wait a moment and try again.');
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
const response = await fetch(
|
|
1074
|
+
`/api/file-diff/${encodeURIComponent(currentFilePath)}?commit1=${commit1}&commit2=${commit2}`
|
|
1075
|
+
);
|
|
1076
|
+
|
|
1077
|
+
if (!response.ok) {
|
|
1078
|
+
throw new Error(`HTTP error! status: ${response.status}`);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
const data = await response.json();
|
|
1082
|
+
|
|
1083
|
+
if (data.error) {
|
|
1084
|
+
throw new Error(data.error);
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
const ext = currentFilePath.split('.').pop().toLowerCase();
|
|
1088
|
+
const language = getLanguageFromExtension(ext);
|
|
1089
|
+
|
|
1090
|
+
// Ensure monaco.editor is available
|
|
1091
|
+
if (typeof monaco === 'undefined' || !monaco.editor) {
|
|
1092
|
+
throw new Error('Monaco Editor not loaded yet. Please refresh and try again.');
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
const originalModel = monaco.editor.createModel(data.content1, language);
|
|
1096
|
+
const modifiedModel = monaco.editor.createModel(data.content2, language);
|
|
1097
|
+
|
|
1098
|
+
monacoDiffEditor.setModel({
|
|
1099
|
+
original: originalModel,
|
|
1100
|
+
modified: modifiedModel
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
// Switch to diff view
|
|
1104
|
+
const monacoEditorEl = document.getElementById('monaco-editor');
|
|
1105
|
+
const monacoDiffEditorEl = document.getElementById('monaco-diff-editor');
|
|
1106
|
+
const fileInfoEl = document.getElementById('file-info');
|
|
1107
|
+
const diffInfoEl = document.getElementById('diff-info');
|
|
1108
|
+
|
|
1109
|
+
if (monacoEditorEl) monacoEditorEl.classList.add('hidden');
|
|
1110
|
+
if (monacoDiffEditorEl) monacoDiffEditorEl.classList.remove('hidden');
|
|
1111
|
+
|
|
1112
|
+
const short1 = commit1.substring(0, 7);
|
|
1113
|
+
const short2 = commit2.substring(0, 7);
|
|
1114
|
+
|
|
1115
|
+
if (fileInfoEl) {
|
|
1116
|
+
fileInfoEl.innerHTML = `
|
|
1117
|
+
<div class="flex items-center justify-between">
|
|
1118
|
+
<span class="text-blue-400">📄 ${escapeHtml(currentFilePath)}</span>
|
|
1119
|
+
<span class="text-gray-500">${language}</span>
|
|
1120
|
+
</div>
|
|
1121
|
+
`;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
if (diffInfoEl) {
|
|
1125
|
+
diffInfoEl.innerHTML = `
|
|
1126
|
+
<span class="text-red-400">${short1}</span> ⟷ <span class="text-green-400">${short2}</span>
|
|
1127
|
+
`;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// Only show success toast
|
|
1131
|
+
showToast(`Comparing ${short1} ⟷ ${short2}`, 'success', 2500);
|
|
1132
|
+
|
|
1133
|
+
} catch (error) {
|
|
1134
|
+
console.error('Diff loading error:', error);
|
|
1135
|
+
showToast(`Diff error: ${error.message}`, 'error', 4000);
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
function toggleViewMode() {
|
|
1140
|
+
viewMode = viewMode === 'single' ? 'diff' : 'single';
|
|
1141
|
+
document.getElementById('view-mode-text').textContent =
|
|
1142
|
+
viewMode === 'single' ? 'Single' : 'Diff';
|
|
1143
|
+
|
|
1144
|
+
clearCommitSelection();
|
|
1145
|
+
|
|
1146
|
+
showToast(
|
|
1147
|
+
viewMode === 'single'
|
|
1148
|
+
? 'Single file view mode'
|
|
1149
|
+
: 'Diff mode: Select 2 commits to compare',
|
|
1150
|
+
'info',
|
|
1151
|
+
3000
|
|
1152
|
+
);
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
function clearCommitSelection() {
|
|
1156
|
+
selectedCommits = [];
|
|
1157
|
+
document.querySelectorAll('.commit-item').forEach(item => {
|
|
1158
|
+
item.classList.remove('selected-1', 'selected-2');
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1161
|
+
// Switch back to single editor
|
|
1162
|
+
document.getElementById('monaco-editor').classList.remove('hidden');
|
|
1163
|
+
document.getElementById('monaco-diff-editor').classList.add('hidden');
|
|
1164
|
+
document.getElementById('diff-info').innerHTML = '';
|
|
1165
|
+
|
|
1166
|
+
updateClearSelectionButton();
|
|
1167
|
+
|
|
1168
|
+
// Reload current file
|
|
1169
|
+
if (currentFilePath) {
|
|
1170
|
+
const ref = currentData.branches.current;
|
|
1171
|
+
fetch(`/api/file/${ref}/${currentFilePath}`)
|
|
1172
|
+
.then(res => res.json())
|
|
1173
|
+
.then(data => {
|
|
1174
|
+
if (data.content) {
|
|
1175
|
+
const ext = currentFilePath.split('.').pop().toLowerCase();
|
|
1176
|
+
const language = getLanguageFromExtension(ext);
|
|
1177
|
+
monaco.editor.setModelLanguage(monacoEditor.getModel(), language);
|
|
1178
|
+
monacoEditor.setValue(data.content);
|
|
1179
|
+
}
|
|
1180
|
+
});
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
function updateClearSelectionButton() {
|
|
1185
|
+
const btn = document.getElementById('clear-selection-btn');
|
|
1186
|
+
if (selectedCommits.length > 0 || viewMode === 'diff') {
|
|
1187
|
+
btn.classList.remove('hidden');
|
|
1188
|
+
} else {
|
|
1189
|
+
btn.classList.add('hidden');
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
function getLanguageFromExtension(ext) {
|
|
1194
|
+
const languageMap = {
|
|
1195
|
+
js: 'javascript', ts: 'typescript', jsx: 'javascript', tsx: 'typescript',
|
|
1196
|
+
py: 'python', java: 'java', cpp: 'cpp', c: 'c', cs: 'csharp',
|
|
1197
|
+
html: 'html', css: 'css', scss: 'scss', sass: 'sass',
|
|
1198
|
+
json: 'json', xml: 'xml', yaml: 'yaml', yml: 'yaml',
|
|
1199
|
+
md: 'markdown', sh: 'shell', bash: 'shell',
|
|
1200
|
+
sql: 'sql', php: 'php', rb: 'ruby', go: 'go',
|
|
1201
|
+
rs: 'rust', swift: 'swift', kt: 'kotlin',
|
|
1202
|
+
vue: 'html', svelte: 'html'
|
|
1203
|
+
};
|
|
1204
|
+
return languageMap[ext] || 'plaintext';
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
|
|
781
1208
|
function initWebSocket() {
|
|
782
1209
|
ws = new WebSocket(`ws://${window.location.host}`);
|
|
783
1210
|
|
|
@@ -795,7 +1222,7 @@
|
|
|
795
1222
|
if (data.type === 'update') {
|
|
796
1223
|
currentData = data.data;
|
|
797
1224
|
renderData();
|
|
798
|
-
showToast('
|
|
1225
|
+
showToast('✓ Data refreshed', 'success', 2000);
|
|
799
1226
|
}
|
|
800
1227
|
};
|
|
801
1228
|
}
|
|
@@ -838,8 +1265,6 @@
|
|
|
838
1265
|
}
|
|
839
1266
|
|
|
840
1267
|
function refreshData() {
|
|
841
|
-
showToast('Refreshing repository data...', 'info', 2000);
|
|
842
|
-
|
|
843
1268
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
844
1269
|
ws.send(JSON.stringify({ type: 'refresh' }));
|
|
845
1270
|
} else {
|
|
@@ -895,34 +1320,123 @@
|
|
|
895
1320
|
|
|
896
1321
|
async function loadGitGraph() {
|
|
897
1322
|
try {
|
|
1323
|
+
const graphElement = document.getElementById('git-graph');
|
|
1324
|
+
if (!graphElement) return;
|
|
1325
|
+
|
|
1326
|
+
// Show loading state
|
|
1327
|
+
graphElement.innerHTML = '<div class="text-gray-400 py-4">Loading git graph...</div>';
|
|
1328
|
+
|
|
898
1329
|
const limit = document.getElementById('graph-limit')?.value || 100;
|
|
899
1330
|
const response = await fetch(`/api/graph?limit=${limit}`);
|
|
900
|
-
const graph = await response.json();
|
|
901
1331
|
|
|
902
|
-
|
|
903
|
-
|
|
1332
|
+
if (!response.ok) {
|
|
1333
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
1334
|
+
}
|
|
904
1335
|
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
<span class="text-gray-500 ml-2">${escapeHtml(commit.date)}</span>
|
|
1336
|
+
const graph = await response.json();
|
|
1337
|
+
|
|
1338
|
+
if (!graph || graph.length === 0) {
|
|
1339
|
+
graphElement.innerHTML = `
|
|
1340
|
+
<div class="text-yellow-400 py-4">
|
|
1341
|
+
<p class="mb-2">⚠️ No commits found in git graph</p>
|
|
1342
|
+
<p class="text-sm text-gray-400">This could mean:</p>
|
|
1343
|
+
<ul class="text-sm text-gray-400 list-disc list-inside ml-4 mt-2">
|
|
1344
|
+
<li>The repository is empty</li>
|
|
1345
|
+
<li>There are no commits yet</li>
|
|
1346
|
+
<li>There's an issue with git log</li>
|
|
1347
|
+
</ul>
|
|
918
1348
|
</div>
|
|
919
1349
|
`;
|
|
920
|
-
|
|
1350
|
+
return;
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
// Clear existing content
|
|
1354
|
+
graphElement.innerHTML = '';
|
|
1355
|
+
|
|
1356
|
+
// Build DOM elements directly instead of using innerHTML with strings
|
|
1357
|
+
graph.forEach(commit => {
|
|
1358
|
+
const row = document.createElement('div');
|
|
1359
|
+
row.className = 'commit-node hover:bg-gray-800 py-1 px-2 rounded flex items-start';
|
|
1360
|
+
|
|
1361
|
+
// Graph column
|
|
1362
|
+
const graphCol = document.createElement('div');
|
|
1363
|
+
graphCol.className = 'text-gray-400 font-mono inline-block';
|
|
1364
|
+
|
|
1365
|
+
// Process each character in the graph
|
|
1366
|
+
const graphChars = (commit.graph || '').split('');
|
|
1367
|
+
graphChars.forEach(char => {
|
|
1368
|
+
const span = document.createElement('span');
|
|
1369
|
+
switch(char) {
|
|
1370
|
+
case '*':
|
|
1371
|
+
span.className = 'text-yellow-400';
|
|
1372
|
+
span.textContent = '●';
|
|
1373
|
+
break;
|
|
1374
|
+
case '|':
|
|
1375
|
+
span.className = 'text-blue-400';
|
|
1376
|
+
span.textContent = '│';
|
|
1377
|
+
break;
|
|
1378
|
+
case '/':
|
|
1379
|
+
span.className = 'text-green-400';
|
|
1380
|
+
span.textContent = '╱';
|
|
1381
|
+
break;
|
|
1382
|
+
case '\\':
|
|
1383
|
+
span.className = 'text-purple-400';
|
|
1384
|
+
span.textContent = '╲';
|
|
1385
|
+
break;
|
|
1386
|
+
case ' ':
|
|
1387
|
+
span.innerHTML = ' ';
|
|
1388
|
+
break;
|
|
1389
|
+
default:
|
|
1390
|
+
span.textContent = char;
|
|
1391
|
+
}
|
|
1392
|
+
graphCol.appendChild(span);
|
|
1393
|
+
});
|
|
1394
|
+
|
|
1395
|
+
// Hash column
|
|
1396
|
+
const hashCol = document.createElement('span');
|
|
1397
|
+
hashCol.className = 'text-yellow-500 mx-2 font-mono flex-shrink-0';
|
|
1398
|
+
hashCol.textContent = commit.hash.substring(0, 7);
|
|
1399
|
+
|
|
1400
|
+
// Subject column
|
|
1401
|
+
const subjectCol = document.createElement('span');
|
|
1402
|
+
subjectCol.className = 'text-gray-300 flex-1';
|
|
1403
|
+
subjectCol.textContent = commit.subject;
|
|
1404
|
+
|
|
1405
|
+
// Author column
|
|
1406
|
+
const authorCol = document.createElement('span');
|
|
1407
|
+
authorCol.className = 'text-blue-400 ml-2 flex-shrink-0';
|
|
1408
|
+
authorCol.textContent = `(${commit.author})`;
|
|
1409
|
+
|
|
1410
|
+
// Date column
|
|
1411
|
+
const dateCol = document.createElement('span');
|
|
1412
|
+
dateCol.className = 'text-gray-500 ml-2 whitespace-nowrap flex-shrink-0';
|
|
1413
|
+
dateCol.textContent = commit.date;
|
|
1414
|
+
|
|
1415
|
+
// Assemble row
|
|
1416
|
+
row.appendChild(graphCol);
|
|
1417
|
+
row.appendChild(hashCol);
|
|
1418
|
+
row.appendChild(subjectCol);
|
|
1419
|
+
row.appendChild(authorCol);
|
|
1420
|
+
row.appendChild(dateCol);
|
|
1421
|
+
|
|
1422
|
+
graphElement.appendChild(row);
|
|
1423
|
+
});
|
|
1424
|
+
|
|
1425
|
+
console.log(`Git graph loaded: ${graph.length} commits`);
|
|
1426
|
+
|
|
921
1427
|
} catch (error) {
|
|
922
1428
|
console.error('Error loading git graph:', error);
|
|
923
1429
|
const graphElement = document.getElementById('git-graph');
|
|
924
1430
|
if (graphElement) {
|
|
925
|
-
graphElement.innerHTML =
|
|
1431
|
+
graphElement.innerHTML = `
|
|
1432
|
+
<div class="text-red-400 py-4">
|
|
1433
|
+
<p class="mb-2">❌ Error loading git graph</p>
|
|
1434
|
+
<p class="text-sm">${error.message}</p>
|
|
1435
|
+
<button onclick="loadGitGraph()" class="mt-3 px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded text-sm">
|
|
1436
|
+
Try Again
|
|
1437
|
+
</button>
|
|
1438
|
+
</div>
|
|
1439
|
+
`;
|
|
926
1440
|
}
|
|
927
1441
|
}
|
|
928
1442
|
}
|
|
@@ -1358,4 +1872,4 @@
|
|
|
1358
1872
|
}
|
|
1359
1873
|
</script>
|
|
1360
1874
|
</body>
|
|
1361
|
-
</html>
|
|
1875
|
+
</html>
|
package/server.js
CHANGED
|
@@ -2,9 +2,29 @@ const express = require('express');
|
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const http = require('http');
|
|
4
4
|
const WebSocket = require('ws');
|
|
5
|
-
const {openResource} = require('open-resource');
|
|
6
5
|
const chalk = require('chalk');
|
|
7
6
|
const { GitAnalyzer } = require('./analyzer');
|
|
7
|
+
const { openResource } = require('open-resource');
|
|
8
|
+
|
|
9
|
+
function getRelativeTime(date) {
|
|
10
|
+
const now = new Date();
|
|
11
|
+
const diff = now - date;
|
|
12
|
+
const seconds = Math.floor(diff / 1000);
|
|
13
|
+
const minutes = Math.floor(seconds / 60);
|
|
14
|
+
const hours = Math.floor(minutes / 60);
|
|
15
|
+
const days = Math.floor(hours / 24);
|
|
16
|
+
const weeks = Math.floor(days / 7);
|
|
17
|
+
const months = Math.floor(days / 30);
|
|
18
|
+
const years = Math.floor(days / 365);
|
|
19
|
+
|
|
20
|
+
if (seconds < 60) return 'just now';
|
|
21
|
+
if (minutes < 60) return `${minutes} minute${minutes > 1 ? 's' : ''} ago`;
|
|
22
|
+
if (hours < 24) return `${hours} hour${hours > 1 ? 's' : ''} ago`;
|
|
23
|
+
if (days < 7) return `${days} day${days > 1 ? 's' : ''} ago`;
|
|
24
|
+
if (weeks < 4) return `${weeks} week${weeks > 1 ? 's' : ''} ago`;
|
|
25
|
+
if (months < 12) return `${months} month${months > 1 ? 's' : ''} ago`;
|
|
26
|
+
return `${years} year${years > 1 ? 's' : ''} ago`;
|
|
27
|
+
}
|
|
8
28
|
|
|
9
29
|
async function startServer(repoPath, port, openBrowser = true) {
|
|
10
30
|
const app = express();
|
|
@@ -39,9 +59,19 @@ async function startServer(repoPath, port, openBrowser = true) {
|
|
|
39
59
|
app.get('/api/graph', async (req, res) => {
|
|
40
60
|
try {
|
|
41
61
|
const limit = parseInt(req.query.limit) || 100;
|
|
62
|
+
console.log(`Fetching git graph with limit: ${limit}`);
|
|
63
|
+
|
|
42
64
|
const graph = await analyzer.getCommitGraph(limit);
|
|
65
|
+
|
|
66
|
+
console.log(`Git graph returned ${graph.length} commits`);
|
|
67
|
+
|
|
68
|
+
if (graph.length === 0) {
|
|
69
|
+
console.warn('Warning: Git graph is empty');
|
|
70
|
+
}
|
|
71
|
+
|
|
43
72
|
res.json(graph);
|
|
44
73
|
} catch (error) {
|
|
74
|
+
console.error('Error in /api/graph:', error);
|
|
45
75
|
res.status(500).json({ error: error.message });
|
|
46
76
|
}
|
|
47
77
|
});
|
|
@@ -57,6 +87,70 @@ async function startServer(repoPath, port, openBrowser = true) {
|
|
|
57
87
|
}
|
|
58
88
|
});
|
|
59
89
|
|
|
90
|
+
app.get('/api/file-history/:filepath(*)', async (req, res) => {
|
|
91
|
+
try {
|
|
92
|
+
const filepath = req.params.filepath;
|
|
93
|
+
const limit = parseInt(req.query.limit) || 1000;
|
|
94
|
+
|
|
95
|
+
// Get file history with commit details
|
|
96
|
+
const log = await analyzer.git.log({
|
|
97
|
+
file: filepath,
|
|
98
|
+
'--max-count': limit
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const commits = log.all.map(commit => ({
|
|
102
|
+
hash: commit.hash,
|
|
103
|
+
shortHash: commit.hash.substring(0, 7),
|
|
104
|
+
message: commit.message,
|
|
105
|
+
author: commit.author_name,
|
|
106
|
+
date: commit.date,
|
|
107
|
+
relativeDate: getRelativeTime(new Date(commit.date))
|
|
108
|
+
}));
|
|
109
|
+
|
|
110
|
+
res.json({ commits, filepath });
|
|
111
|
+
} catch (error) {
|
|
112
|
+
res.status(500).json({ error: error.message });
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
app.get('/api/file-diff/:filepath(*)', async (req, res) => {
|
|
117
|
+
try {
|
|
118
|
+
const filepath = req.params.filepath;
|
|
119
|
+
const commit1 = req.query.commit1;
|
|
120
|
+
const commit2 = req.query.commit2;
|
|
121
|
+
|
|
122
|
+
if (!commit1 || !commit2) {
|
|
123
|
+
return res.status(400).json({ error: 'Both commit1 and commit2 are required' });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Get file content at both commits
|
|
127
|
+
let content1 = '';
|
|
128
|
+
let content2 = '';
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
content1 = await analyzer.git.show([`${commit1}:${filepath}`]);
|
|
132
|
+
} catch (e) {
|
|
133
|
+
content1 = '// File does not exist at this commit';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
content2 = await analyzer.git.show([`${commit2}:${filepath}`]);
|
|
138
|
+
} catch (e) {
|
|
139
|
+
content2 = '// File does not exist at this commit';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
res.json({
|
|
143
|
+
content1,
|
|
144
|
+
content2,
|
|
145
|
+
commit1,
|
|
146
|
+
commit2,
|
|
147
|
+
filepath
|
|
148
|
+
});
|
|
149
|
+
} catch (error) {
|
|
150
|
+
res.status(500).json({ error: error.message });
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
60
154
|
app.post('/api/gc', async (req, res) => {
|
|
61
155
|
try {
|
|
62
156
|
await analyzer.git.raw(['gc', '--aggressive']);
|
|
@@ -104,4 +198,4 @@ async function startServer(repoPath, port, openBrowser = true) {
|
|
|
104
198
|
return server;
|
|
105
199
|
}
|
|
106
200
|
|
|
107
|
-
module.exports = { startServer };
|
|
201
|
+
module.exports = { startServer };
|