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 CHANGED
@@ -48,6 +48,11 @@
48
48
  ## Demo
49
49
  ![GitPulse Demo on ollama repo](https://raw.githubusercontent.com/mchinnappan100/npmjs-images/main/gitpluse/gitpulse-demo-1.gif)
50
50
 
51
+ ## Screenshot
52
+
53
+ ![Git Diff on postgres repo](https://raw.githubusercontent.com/mchinnappan100/npmjs-images/main/gitpluse/gitpulse-demo-2.png)
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 - Better than SourceTree, approved by Linus')
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.1",
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", "public/*"
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
- .split-pane {
63
+ .file-tree-container {
65
64
  display: flex;
66
- height: 600px;
67
- gap: 1rem;
65
+ height: 650px;
66
+ gap: 0;
67
+ position: relative;
68
68
  }
69
69
 
70
70
  .split-left {
71
- flex: 0 0 400px;
71
+ flex: 0 0 350px;
72
72
  overflow-y: auto;
73
- border-right: 1px solid #374151;
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
- <h3 class="text-xl font-bold mb-4">📂 File Tree</h3>
513
- <div class="split-pane">
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">Select a file to view</div>
519
- <div id="monaco-editor"></div>
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
- showToast(`Loading ${filepath.split('/').pop()}...`, 'info', 1500);
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 languageMap = {
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
- showToast('File loaded successfully', 'success', 2000);
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
- showToast(`Failed to load ${filepath.split('/').pop()}`, 'error', 3000);
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('Repository data updated', 'success', 2000);
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
- const graphElement = document.getElementById('git-graph');
903
- if (!graphElement) return;
1332
+ if (!response.ok) {
1333
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
1334
+ }
904
1335
 
905
- graphElement.innerHTML = graph.map(commit => {
906
- const graphPart = commit.graph.replace(/\*/g, '<span class="text-yellow-400">●</span>')
907
- .replace(/\|/g, '<span class="text-blue-400">│</span>')
908
- .replace(/\//g, '<span class="text-green-400">╱</span>')
909
- .replace(/\\/g, '<span class="text-purple-400">╲</span>');
910
-
911
- return `
912
- <div class="commit-node hover:bg-gray-800 py-1 px-2 rounded">
913
- <span class="text-gray-400">${graphPart}</span>
914
- <span class="text-yellow-500 mx-2">${commit.hash.substring(0, 7)}</span>
915
- <span class="text-gray-300">${escapeHtml(commit.subject)}</span>
916
- <span class="text-blue-400 ml-2">(${escapeHtml(commit.author)})</span>
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
- }).join('');
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 = '&nbsp;';
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 = '<div class="text-red-400">Error loading git graph</div>';
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 };