seeclaudecode 1.0.0
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/.github/workflows/publish.yml +27 -0
- package/README.md +138 -0
- package/bin/cli.js +300 -0
- package/package.json +40 -0
- package/public/app.js +1282 -0
- package/public/index.html +209 -0
- package/public/styles.css +1725 -0
- package/server.js +458 -0
package/public/app.js
ADDED
|
@@ -0,0 +1,1282 @@
|
|
|
1
|
+
// SeeClaude - Watch Claude Code Work
|
|
2
|
+
class SeeClaude {
|
|
3
|
+
constructor() {
|
|
4
|
+
this.ws = null;
|
|
5
|
+
this.structure = null;
|
|
6
|
+
this.activeFiles = new Set();
|
|
7
|
+
this.changedFiles = new Set();
|
|
8
|
+
this.changedPaths = new Set(); // Parent directories of changed files
|
|
9
|
+
this.activePaths = new Set();
|
|
10
|
+
this.activities = [];
|
|
11
|
+
this.currentView = 'graph';
|
|
12
|
+
this.expandedNodes = new Set();
|
|
13
|
+
this.graphNodes = [];
|
|
14
|
+
this.graphEdges = [];
|
|
15
|
+
this.isPanelOpen = true;
|
|
16
|
+
|
|
17
|
+
// Pan/zoom state for graph
|
|
18
|
+
this.panX = 0;
|
|
19
|
+
this.panY = 0;
|
|
20
|
+
this.zoom = 1;
|
|
21
|
+
this.isDragging = false;
|
|
22
|
+
this.lastMouseX = 0;
|
|
23
|
+
this.lastMouseY = 0;
|
|
24
|
+
|
|
25
|
+
this.init();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async init() {
|
|
29
|
+
this.createTooltip();
|
|
30
|
+
this.setupWebSocket();
|
|
31
|
+
this.setupEventListeners();
|
|
32
|
+
await this.loadStructure();
|
|
33
|
+
await this.loadGitStatus(); // Wait for git status before rendering
|
|
34
|
+
this.buildGraphData(); // Rebuild with change data
|
|
35
|
+
this.render();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
createTooltip() {
|
|
39
|
+
this.tooltip = document.createElement('div');
|
|
40
|
+
this.tooltip.className = 'tooltip';
|
|
41
|
+
document.body.appendChild(this.tooltip);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
setupWebSocket() {
|
|
45
|
+
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
46
|
+
this.ws = new WebSocket(`${protocol}//${window.location.host}`);
|
|
47
|
+
|
|
48
|
+
this.ws.onopen = () => {
|
|
49
|
+
document.getElementById('connection-state').textContent = 'Connected';
|
|
50
|
+
document.getElementById('status-indicator').classList.add('connected');
|
|
51
|
+
this.addActivity('system', 'Connected to server', 'Connection established');
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
this.ws.onclose = () => {
|
|
55
|
+
document.getElementById('connection-state').textContent = 'Disconnected';
|
|
56
|
+
document.getElementById('status-indicator').classList.remove('connected');
|
|
57
|
+
this.addActivity('system', 'Connection lost', 'Attempting to reconnect...');
|
|
58
|
+
setTimeout(() => this.setupWebSocket(), 3000);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
this.ws.onmessage = (event) => {
|
|
62
|
+
const data = JSON.parse(event.data);
|
|
63
|
+
this.handleMessage(data);
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
handleMessage(data) {
|
|
68
|
+
switch (data.type) {
|
|
69
|
+
case 'init':
|
|
70
|
+
data.activeFiles?.forEach(f => this.activeFiles.add(f));
|
|
71
|
+
data.changedFiles?.forEach(f => this.changedFiles.add(f));
|
|
72
|
+
this.updateActivePaths();
|
|
73
|
+
this.render();
|
|
74
|
+
break;
|
|
75
|
+
|
|
76
|
+
case 'active':
|
|
77
|
+
this.handleActiveEdit(data);
|
|
78
|
+
break;
|
|
79
|
+
|
|
80
|
+
case 'complete':
|
|
81
|
+
this.handleEditComplete(data);
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
updateActivePaths() {
|
|
87
|
+
this.activePaths.clear();
|
|
88
|
+
this.activeFiles.forEach(file => {
|
|
89
|
+
const parts = file.split('/');
|
|
90
|
+
let currentPath = '';
|
|
91
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
92
|
+
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i];
|
|
93
|
+
this.activePaths.add(currentPath);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
handleActiveEdit(data) {
|
|
99
|
+
this.activeFiles.add(data.file);
|
|
100
|
+
this.updateActivePaths();
|
|
101
|
+
|
|
102
|
+
const fileName = data.file.split('/').pop();
|
|
103
|
+
document.getElementById('current-task').textContent = `Editing ${fileName}`;
|
|
104
|
+
|
|
105
|
+
this.addActivity('edit', `Editing ${fileName}`, data.file, true);
|
|
106
|
+
this.expandPathTo(data.file);
|
|
107
|
+
this.render();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
handleEditComplete(data) {
|
|
111
|
+
this.activeFiles.clear();
|
|
112
|
+
this.activePaths.clear();
|
|
113
|
+
data.files.forEach(f => this.changedFiles.add(f));
|
|
114
|
+
|
|
115
|
+
document.getElementById('current-task').textContent = `Finished editing ${data.files.length} file(s)`;
|
|
116
|
+
|
|
117
|
+
this.addActivity('complete', `Completed editing`, `${data.files.length} file(s) modified`);
|
|
118
|
+
this.updateDiffPanel(data.diff);
|
|
119
|
+
this.render();
|
|
120
|
+
|
|
121
|
+
setTimeout(() => {
|
|
122
|
+
document.getElementById('current-task').textContent = 'Waiting for activity...';
|
|
123
|
+
}, 5000);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
addActivity(type, action, description, isActive = false) {
|
|
127
|
+
const activity = {
|
|
128
|
+
id: Date.now(),
|
|
129
|
+
type,
|
|
130
|
+
action,
|
|
131
|
+
description,
|
|
132
|
+
time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
|
133
|
+
isActive
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
this.activities.unshift(activity);
|
|
137
|
+
if (this.activities.length > 20) this.activities.pop();
|
|
138
|
+
this.renderActivityFeed();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
renderActivityFeed() {
|
|
142
|
+
const container = document.getElementById('activity-feed');
|
|
143
|
+
|
|
144
|
+
if (this.activities.length === 0) {
|
|
145
|
+
container.innerHTML = `
|
|
146
|
+
<div class="activity-empty">
|
|
147
|
+
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
148
|
+
<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>
|
|
149
|
+
</svg>
|
|
150
|
+
<p>Waiting for Claude Code activity...</p>
|
|
151
|
+
</div>
|
|
152
|
+
`;
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
container.innerHTML = this.activities.map(activity => `
|
|
157
|
+
<div class="activity-item ${activity.isActive ? 'active' : ''}">
|
|
158
|
+
<div class="activity-header">
|
|
159
|
+
<span class="activity-action">${activity.action}</span>
|
|
160
|
+
<span class="activity-time">${activity.time}</span>
|
|
161
|
+
</div>
|
|
162
|
+
<div class="activity-description">${activity.description}</div>
|
|
163
|
+
</div>
|
|
164
|
+
`).join('');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
updateDiffPanel(diff) {
|
|
168
|
+
if (!diff) return;
|
|
169
|
+
|
|
170
|
+
const filesCount = (diff.modified?.length || 0) + (diff.created?.length || 0);
|
|
171
|
+
document.getElementById('files-count').textContent = filesCount;
|
|
172
|
+
|
|
173
|
+
let additions = 0, deletions = 0;
|
|
174
|
+
diff.fileDiffs?.forEach(fd => {
|
|
175
|
+
additions += fd.additions || 0;
|
|
176
|
+
deletions += fd.deletions || 0;
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
document.getElementById('additions-count').textContent = additions;
|
|
180
|
+
document.getElementById('deletions-count').textContent = deletions;
|
|
181
|
+
|
|
182
|
+
if (diff.modified?.length > 0 || diff.created?.length > 0) {
|
|
183
|
+
const file = diff.modified[0] || diff.created[0];
|
|
184
|
+
document.getElementById('diff-file').textContent = file;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const diffContent = document.getElementById('diff-content');
|
|
188
|
+
if (diff.fileDiffs?.length > 0) {
|
|
189
|
+
const fd = diff.fileDiffs[0];
|
|
190
|
+
diffContent.innerHTML = `
|
|
191
|
+
<div class="diff-line context">
|
|
192
|
+
<div class="diff-line-number">...</div>
|
|
193
|
+
<div class="diff-line-content">// Changes in ${fd.file}</div>
|
|
194
|
+
</div>
|
|
195
|
+
${fd.additions > 0 ? `
|
|
196
|
+
<div class="diff-line added">
|
|
197
|
+
<div class="diff-line-number">+</div>
|
|
198
|
+
<div class="diff-line-content">+ ${fd.additions} line(s) added</div>
|
|
199
|
+
</div>
|
|
200
|
+
` : ''}
|
|
201
|
+
${fd.deletions > 0 ? `
|
|
202
|
+
<div class="diff-line removed">
|
|
203
|
+
<div class="diff-line-number">-</div>
|
|
204
|
+
<div class="diff-line-content">- ${fd.deletions} line(s) removed</div>
|
|
205
|
+
</div>
|
|
206
|
+
` : ''}
|
|
207
|
+
`;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async showFileDiff(filePath) {
|
|
212
|
+
if (!filePath) return;
|
|
213
|
+
if (filePath === '.') filePath = '';
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
const response = await fetch(`/api/diff/${encodeURIComponent(filePath || '.')}`);
|
|
217
|
+
const diff = await response.json();
|
|
218
|
+
|
|
219
|
+
const diffContent = document.getElementById('diff-content');
|
|
220
|
+
|
|
221
|
+
// Handle directory diff
|
|
222
|
+
if (diff.type === 'directory') {
|
|
223
|
+
document.getElementById('diff-file').textContent = filePath || 'Root';
|
|
224
|
+
document.getElementById('additions-count').textContent = diff.totalAdditions || 0;
|
|
225
|
+
document.getElementById('deletions-count').textContent = diff.totalDeletions || 0;
|
|
226
|
+
document.getElementById('files-count').textContent = diff.fileCount || 0;
|
|
227
|
+
|
|
228
|
+
if (diff.status === 'unchanged' || !diff.files || diff.files.length === 0) {
|
|
229
|
+
diffContent.innerHTML = `
|
|
230
|
+
<div class="diff-empty">
|
|
231
|
+
<p>No changes in this directory</p>
|
|
232
|
+
</div>
|
|
233
|
+
`;
|
|
234
|
+
} else {
|
|
235
|
+
// Render all file diffs
|
|
236
|
+
diffContent.innerHTML = diff.files.map(fileDiff => `
|
|
237
|
+
<div class="diff-file-section">
|
|
238
|
+
<div class="diff-file-header">
|
|
239
|
+
<span class="diff-file-name">${this.escapeHtml(fileDiff.file)}</span>
|
|
240
|
+
<span class="diff-file-stats">
|
|
241
|
+
<span class="stat-add">+${fileDiff.additions || 0}</span>
|
|
242
|
+
<span class="stat-del">-${fileDiff.deletions || 0}</span>
|
|
243
|
+
</span>
|
|
244
|
+
</div>
|
|
245
|
+
${fileDiff.lines?.map(line => {
|
|
246
|
+
if (line.type === 'hunk') {
|
|
247
|
+
return `<div class="diff-line hunk"><div class="diff-line-content">${this.escapeHtml(line.content)}</div></div>`;
|
|
248
|
+
}
|
|
249
|
+
const lineNum = line.lineNumber || '';
|
|
250
|
+
const prefix = line.type === 'added' ? '+' : line.type === 'removed' ? '-' : ' ';
|
|
251
|
+
return `
|
|
252
|
+
<div class="diff-line ${line.type}">
|
|
253
|
+
<div class="diff-line-number">${lineNum}</div>
|
|
254
|
+
<div class="diff-line-prefix">${prefix}</div>
|
|
255
|
+
<div class="diff-line-content">${this.escapeHtml(line.content)}</div>
|
|
256
|
+
</div>
|
|
257
|
+
`;
|
|
258
|
+
}).join('') || '<div class="diff-line context"><div class="diff-line-content">No changes</div></div>'}
|
|
259
|
+
</div>
|
|
260
|
+
`).join('');
|
|
261
|
+
}
|
|
262
|
+
} else {
|
|
263
|
+
// Single file diff
|
|
264
|
+
document.getElementById('diff-file').textContent = filePath;
|
|
265
|
+
document.getElementById('additions-count').textContent = diff.additions || 0;
|
|
266
|
+
document.getElementById('deletions-count').textContent = diff.deletions || 0;
|
|
267
|
+
document.getElementById('files-count').textContent = '1';
|
|
268
|
+
|
|
269
|
+
if (diff.status === 'unchanged') {
|
|
270
|
+
diffContent.innerHTML = `
|
|
271
|
+
<div class="diff-empty">
|
|
272
|
+
<p>No changes in this file</p>
|
|
273
|
+
</div>
|
|
274
|
+
`;
|
|
275
|
+
} else if (!diff.lines || diff.lines.length === 0) {
|
|
276
|
+
diffContent.innerHTML = `
|
|
277
|
+
<div class="diff-empty">
|
|
278
|
+
<p>No diff available for this file</p>
|
|
279
|
+
${diff.error ? `<p class="diff-error">${diff.error}</p>` : ''}
|
|
280
|
+
</div>
|
|
281
|
+
`;
|
|
282
|
+
} else {
|
|
283
|
+
diffContent.innerHTML = diff.lines.map(line => {
|
|
284
|
+
if (line.type === 'hunk') {
|
|
285
|
+
return `<div class="diff-line hunk"><div class="diff-line-content">${this.escapeHtml(line.content)}</div></div>`;
|
|
286
|
+
}
|
|
287
|
+
const lineNum = line.lineNumber || '';
|
|
288
|
+
const prefix = line.type === 'added' ? '+' : line.type === 'removed' ? '-' : ' ';
|
|
289
|
+
return `
|
|
290
|
+
<div class="diff-line ${line.type}">
|
|
291
|
+
<div class="diff-line-number">${lineNum}</div>
|
|
292
|
+
<div class="diff-line-prefix">${prefix}</div>
|
|
293
|
+
<div class="diff-line-content">${this.escapeHtml(line.content)}</div>
|
|
294
|
+
</div>
|
|
295
|
+
`;
|
|
296
|
+
}).join('');
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Make sure the panel is open
|
|
301
|
+
if (!this.isPanelOpen) {
|
|
302
|
+
this.isPanelOpen = true;
|
|
303
|
+
document.getElementById('right-panel').classList.remove('hidden');
|
|
304
|
+
document.getElementById('panel-toggle').classList.add('hidden');
|
|
305
|
+
}
|
|
306
|
+
} catch (error) {
|
|
307
|
+
console.error('Failed to load diff:', error);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
escapeHtml(text) {
|
|
312
|
+
const div = document.createElement('div');
|
|
313
|
+
div.textContent = text || '';
|
|
314
|
+
return div.innerHTML;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async loadStructure() {
|
|
318
|
+
try {
|
|
319
|
+
const response = await fetch('/api/structure');
|
|
320
|
+
this.structure = await response.json();
|
|
321
|
+
this.buildGraphData();
|
|
322
|
+
this.expandFirstLevels(this.structure, 2);
|
|
323
|
+
this.render();
|
|
324
|
+
} catch (error) {
|
|
325
|
+
console.error('Failed to load structure:', error);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async loadGitStatus() {
|
|
330
|
+
try {
|
|
331
|
+
const response = await fetch('/api/diff');
|
|
332
|
+
const diff = await response.json();
|
|
333
|
+
this.updateDiffPanel(diff);
|
|
334
|
+
|
|
335
|
+
// Add all changed files
|
|
336
|
+
[...(diff.modified || []), ...(diff.created || []), ...(diff.deleted || [])].forEach(f => {
|
|
337
|
+
// Normalize path separators
|
|
338
|
+
const normalizedPath = f.replace(/\\/g, '/');
|
|
339
|
+
this.changedFiles.add(normalizedPath);
|
|
340
|
+
|
|
341
|
+
// Also mark parent directories as having changes
|
|
342
|
+
const parts = normalizedPath.split('/');
|
|
343
|
+
let currentPath = '';
|
|
344
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
345
|
+
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i];
|
|
346
|
+
this.changedPaths.add(currentPath);
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
console.log('Changed files:', Array.from(this.changedFiles));
|
|
351
|
+
console.log('Changed paths:', Array.from(this.changedPaths));
|
|
352
|
+
this.buildGraphData(); // Rebuild graph with change data
|
|
353
|
+
this.render();
|
|
354
|
+
} catch (error) {
|
|
355
|
+
console.error('Failed to load git status:', error);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
buildGraphData() {
|
|
360
|
+
if (!this.structure) return;
|
|
361
|
+
|
|
362
|
+
this.graphNodes = [];
|
|
363
|
+
this.graphEdges = [];
|
|
364
|
+
|
|
365
|
+
// Layout settings
|
|
366
|
+
const nodeWidth = 160;
|
|
367
|
+
const nodeHeight = 50;
|
|
368
|
+
const horizontalGap = 50;
|
|
369
|
+
const verticalGap = 200; // Extra space for vertical file lists below directories
|
|
370
|
+
|
|
371
|
+
// Count changed files in a directory (recursive)
|
|
372
|
+
const countChangedFiles = (node) => {
|
|
373
|
+
if (!node) return 0;
|
|
374
|
+
|
|
375
|
+
// Check if this exact path is changed
|
|
376
|
+
const thisChanged = this.changedFiles.has(node.path) ? 1 : 0;
|
|
377
|
+
|
|
378
|
+
if (node.type === 'file') {
|
|
379
|
+
return thisChanged;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// For directories, count all changed children
|
|
383
|
+
if (!node.children) return thisChanged;
|
|
384
|
+
|
|
385
|
+
const childCount = node.children.reduce((sum, child) => sum + countChangedFiles(child), 0);
|
|
386
|
+
return childCount;
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
// Calculate subtree width
|
|
390
|
+
const calculateWidth = (node, depth) => {
|
|
391
|
+
if (depth >= 4 || node.type !== 'directory' || !node.children) {
|
|
392
|
+
return nodeWidth;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const dirs = node.children.filter(c => c.type === 'directory').slice(0, 5);
|
|
396
|
+
if (dirs.length === 0) return nodeWidth;
|
|
397
|
+
|
|
398
|
+
const childWidths = dirs.map(d => calculateWidth(d, depth + 1));
|
|
399
|
+
return Math.max(nodeWidth, childWidths.reduce((a, b) => a + b, 0) + horizontalGap * (dirs.length - 1));
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
// Position directories and changed files
|
|
403
|
+
const positionNode = (node, depth, x, y, parentId = null) => {
|
|
404
|
+
if (node.type !== 'directory') return;
|
|
405
|
+
|
|
406
|
+
const nodeId = node.path || '.';
|
|
407
|
+
const nodeType = this.getNodeType(node);
|
|
408
|
+
const files = node.children?.filter(c => c.type === 'file') || [];
|
|
409
|
+
const dirs = node.children?.filter(c => c.type === 'directory') || [];
|
|
410
|
+
const changedCount = countChangedFiles(node);
|
|
411
|
+
|
|
412
|
+
// Get changed files in this directory
|
|
413
|
+
const changedFiles = files.filter(f => this.changedFiles.has(f.path));
|
|
414
|
+
|
|
415
|
+
this.graphNodes.push({
|
|
416
|
+
id: nodeId,
|
|
417
|
+
label: node.name,
|
|
418
|
+
type: nodeType,
|
|
419
|
+
path: node.path,
|
|
420
|
+
x,
|
|
421
|
+
y,
|
|
422
|
+
category: node.category,
|
|
423
|
+
isDirectory: true,
|
|
424
|
+
fileCount: files.length,
|
|
425
|
+
dirCount: dirs.length,
|
|
426
|
+
childCount: node.children?.length || 0,
|
|
427
|
+
changedCount,
|
|
428
|
+
hasChangedFiles: changedFiles.length > 0
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
if (parentId) {
|
|
432
|
+
this.graphEdges.push({ source: parentId, target: nodeId, hasChanges: changedCount > 0 });
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Show changed files VERTICALLY below the directory
|
|
436
|
+
if (changedFiles.length > 0) {
|
|
437
|
+
const fileWidth = 130;
|
|
438
|
+
const fileHeight = 22;
|
|
439
|
+
const fileGap = 4;
|
|
440
|
+
const visibleFiles = changedFiles.slice(0, 5); // Limit to 5 files
|
|
441
|
+
const fileX = x; // Centered below directory
|
|
442
|
+
const startY = y + nodeHeight / 2 + 30; // Start below the directory
|
|
443
|
+
|
|
444
|
+
visibleFiles.forEach((file, idx) => {
|
|
445
|
+
const fileY = startY + idx * (fileHeight + fileGap);
|
|
446
|
+
|
|
447
|
+
this.graphNodes.push({
|
|
448
|
+
id: file.path,
|
|
449
|
+
label: file.name,
|
|
450
|
+
type: file.category || 'file',
|
|
451
|
+
path: file.path,
|
|
452
|
+
x: fileX,
|
|
453
|
+
y: fileY,
|
|
454
|
+
category: file.category,
|
|
455
|
+
isDirectory: false,
|
|
456
|
+
isFile: true,
|
|
457
|
+
isChangedFile: true,
|
|
458
|
+
childCount: 0
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
this.graphEdges.push({ source: nodeId, target: file.path, hasChanges: true, isFileEdge: true });
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
// Show "+N more" indicator if there are more files
|
|
465
|
+
if (changedFiles.length > 5) {
|
|
466
|
+
const moreY = startY + visibleFiles.length * (fileHeight + fileGap);
|
|
467
|
+
this.graphNodes.push({
|
|
468
|
+
id: `${nodeId}-more`,
|
|
469
|
+
label: `+${changedFiles.length - 5} more`,
|
|
470
|
+
path: node.path,
|
|
471
|
+
x: fileX,
|
|
472
|
+
y: moreY,
|
|
473
|
+
isMoreIndicator: true
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (depth >= 4 || dirs.length === 0) return;
|
|
479
|
+
|
|
480
|
+
// Position child directories
|
|
481
|
+
const visibleDirs = dirs.slice(0, 5);
|
|
482
|
+
const childWidths = visibleDirs.map(d => calculateWidth(d, depth + 1));
|
|
483
|
+
const totalWidth = childWidths.reduce((a, b) => a + b, 0) + horizontalGap * (visibleDirs.length - 1);
|
|
484
|
+
let childX = x - totalWidth / 2;
|
|
485
|
+
|
|
486
|
+
visibleDirs.forEach((dir, i) => {
|
|
487
|
+
const dirWidth = childWidths[i];
|
|
488
|
+
positionNode(dir, depth + 1, childX + dirWidth / 2, y + verticalGap + nodeHeight, nodeId);
|
|
489
|
+
childX += dirWidth + horizontalGap;
|
|
490
|
+
});
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
positionNode(this.structure, 0, 0, 50);
|
|
494
|
+
|
|
495
|
+
// Reset pan
|
|
496
|
+
this.panX = 0;
|
|
497
|
+
this.panY = 0;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
getNodeType(node) {
|
|
501
|
+
if (node.type === 'directory') {
|
|
502
|
+
const name = node.name.toLowerCase();
|
|
503
|
+
if (name.includes('server') || name.includes('api') || name.includes('backend')) return 'server';
|
|
504
|
+
if (name.includes('client') || name.includes('src') || name.includes('components') || name.includes('pages')) return 'client';
|
|
505
|
+
if (name.includes('db') || name.includes('prisma') || name.includes('database')) return 'database';
|
|
506
|
+
if (name.includes('public') || name.includes('static') || name.includes('assets')) return 'assets';
|
|
507
|
+
if (name.includes('scripts') || name.includes('docker')) return 'config';
|
|
508
|
+
return 'folder';
|
|
509
|
+
}
|
|
510
|
+
return node.category || 'file';
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
expandFirstLevels(node, levels, currentLevel = 0) {
|
|
514
|
+
if (currentLevel >= levels || !node) return;
|
|
515
|
+
if (node.type === 'directory') {
|
|
516
|
+
this.expandedNodes.add(node.path);
|
|
517
|
+
node.children?.forEach(child => this.expandFirstLevels(child, levels, currentLevel + 1));
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
expandPathTo(filePath) {
|
|
522
|
+
const parts = filePath.split('/');
|
|
523
|
+
let currentPath = '';
|
|
524
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
525
|
+
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i];
|
|
526
|
+
this.expandedNodes.add(currentPath);
|
|
527
|
+
}
|
|
528
|
+
this.render();
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
setupEventListeners() {
|
|
532
|
+
// View buttons
|
|
533
|
+
document.querySelectorAll('.view-btn').forEach(btn => {
|
|
534
|
+
btn.addEventListener('click', () => {
|
|
535
|
+
document.querySelectorAll('.view-btn').forEach(b => b.classList.remove('active'));
|
|
536
|
+
btn.classList.add('active');
|
|
537
|
+
this.currentView = btn.dataset.view;
|
|
538
|
+
this.panX = 0;
|
|
539
|
+
this.panY = 0;
|
|
540
|
+
this.zoom = 1;
|
|
541
|
+
this.render();
|
|
542
|
+
});
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
// Panel toggle
|
|
546
|
+
document.getElementById('close-panel').addEventListener('click', () => {
|
|
547
|
+
this.isPanelOpen = false;
|
|
548
|
+
document.getElementById('right-panel').classList.add('hidden');
|
|
549
|
+
document.getElementById('panel-toggle').classList.remove('hidden');
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
document.getElementById('panel-toggle').addEventListener('click', () => {
|
|
553
|
+
this.isPanelOpen = true;
|
|
554
|
+
document.getElementById('right-panel').classList.remove('hidden');
|
|
555
|
+
document.getElementById('panel-toggle').classList.add('hidden');
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
setupGraphInteraction(container) {
|
|
560
|
+
const wrapper = container.querySelector('.graph-wrapper');
|
|
561
|
+
if (!wrapper) return;
|
|
562
|
+
|
|
563
|
+
// Mouse wheel zoom
|
|
564
|
+
wrapper.addEventListener('wheel', (e) => {
|
|
565
|
+
e.preventDefault();
|
|
566
|
+
const delta = e.deltaY > 0 ? 0.9 : 1.1;
|
|
567
|
+
this.zoom = Math.max(0.2, Math.min(3, this.zoom * delta));
|
|
568
|
+
this.updateGraphTransform();
|
|
569
|
+
}, { passive: false });
|
|
570
|
+
|
|
571
|
+
// Pan with mouse drag
|
|
572
|
+
wrapper.addEventListener('mousedown', (e) => {
|
|
573
|
+
if (e.target.closest('.graph-node')) return;
|
|
574
|
+
this.isDragging = true;
|
|
575
|
+
this.lastMouseX = e.clientX;
|
|
576
|
+
this.lastMouseY = e.clientY;
|
|
577
|
+
wrapper.style.cursor = 'grabbing';
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
wrapper.addEventListener('mousemove', (e) => {
|
|
581
|
+
if (!this.isDragging) return;
|
|
582
|
+
const dx = e.clientX - this.lastMouseX;
|
|
583
|
+
const dy = e.clientY - this.lastMouseY;
|
|
584
|
+
this.panX += dx;
|
|
585
|
+
this.panY += dy;
|
|
586
|
+
this.lastMouseX = e.clientX;
|
|
587
|
+
this.lastMouseY = e.clientY;
|
|
588
|
+
this.updateGraphTransform();
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
wrapper.addEventListener('mouseup', () => {
|
|
592
|
+
this.isDragging = false;
|
|
593
|
+
wrapper.style.cursor = 'grab';
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
wrapper.addEventListener('mouseleave', () => {
|
|
597
|
+
this.isDragging = false;
|
|
598
|
+
wrapper.style.cursor = 'grab';
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
wrapper.style.cursor = 'grab';
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
updateGraphTransform() {
|
|
605
|
+
const svg = document.querySelector('.graph-svg');
|
|
606
|
+
if (svg) {
|
|
607
|
+
svg.style.transform = `translate(${this.panX}px, ${this.panY}px) scale(${this.zoom})`;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
isPathActive(path) {
|
|
612
|
+
return this.activeFiles.has(path) || this.activePaths.has(path);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
isPathChanged(path) {
|
|
616
|
+
// Normalize path
|
|
617
|
+
const normalizedPath = path?.replace(/\\/g, '/');
|
|
618
|
+
// Check if this exact file is changed OR if it's a directory containing changes
|
|
619
|
+
return this.changedFiles.has(normalizedPath) || this.changedPaths.has(normalizedPath);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
render() {
|
|
623
|
+
const container = document.getElementById('view-container');
|
|
624
|
+
|
|
625
|
+
if (!this.structure) {
|
|
626
|
+
container.innerHTML = `
|
|
627
|
+
<div class="loading">
|
|
628
|
+
<div class="loading-spinner"></div>
|
|
629
|
+
<p>Loading repository structure...</p>
|
|
630
|
+
</div>
|
|
631
|
+
`;
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
switch (this.currentView) {
|
|
636
|
+
case 'graph':
|
|
637
|
+
this.renderGraphView(container);
|
|
638
|
+
break;
|
|
639
|
+
case 'files':
|
|
640
|
+
this.renderFileExplorer(container);
|
|
641
|
+
break;
|
|
642
|
+
case 'tree':
|
|
643
|
+
this.renderSunburstView(container);
|
|
644
|
+
break;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
renderGraphView(container) {
|
|
649
|
+
const width = container.clientWidth || 1000;
|
|
650
|
+
const height = container.clientHeight || 600;
|
|
651
|
+
|
|
652
|
+
// Calculate bounds
|
|
653
|
+
const padding = 150;
|
|
654
|
+
const minX = Math.min(...this.graphNodes.map(n => n.x)) - padding;
|
|
655
|
+
const maxX = Math.max(...this.graphNodes.map(n => n.x)) + padding;
|
|
656
|
+
const minY = Math.min(...this.graphNodes.map(n => n.y)) - padding;
|
|
657
|
+
const maxY = Math.max(...this.graphNodes.map(n => n.y)) + padding;
|
|
658
|
+
|
|
659
|
+
const svgWidth = maxX - minX + 300;
|
|
660
|
+
const svgHeight = maxY - minY + 200;
|
|
661
|
+
|
|
662
|
+
container.innerHTML = `
|
|
663
|
+
<div class="graph-container">
|
|
664
|
+
<div class="graph-wrapper">
|
|
665
|
+
<svg class="graph-svg" width="${svgWidth}" height="${svgHeight}" viewBox="${minX - 150} ${minY - 100} ${svgWidth} ${svgHeight}">
|
|
666
|
+
<defs>
|
|
667
|
+
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
|
|
668
|
+
<feGaussianBlur stdDeviation="3" result="coloredBlur"/>
|
|
669
|
+
<feMerge>
|
|
670
|
+
<feMergeNode in="coloredBlur"/>
|
|
671
|
+
<feMergeNode in="SourceGraphic"/>
|
|
672
|
+
</feMerge>
|
|
673
|
+
</filter>
|
|
674
|
+
<filter id="glow-strong" x="-50%" y="-50%" width="200%" height="200%">
|
|
675
|
+
<feGaussianBlur stdDeviation="6" result="coloredBlur"/>
|
|
676
|
+
<feMerge>
|
|
677
|
+
<feMergeNode in="coloredBlur"/>
|
|
678
|
+
<feMergeNode in="SourceGraphic"/>
|
|
679
|
+
</feMerge>
|
|
680
|
+
</filter>
|
|
681
|
+
<filter id="glow-yellow" x="-50%" y="-50%" width="200%" height="200%">
|
|
682
|
+
<feFlood flood-color="hsl(45, 93%, 58%)" flood-opacity="0.6" result="flood"/>
|
|
683
|
+
<feComposite in="flood" in2="SourceGraphic" operator="in" result="mask"/>
|
|
684
|
+
<feGaussianBlur in="mask" stdDeviation="4" result="coloredBlur"/>
|
|
685
|
+
<feMerge>
|
|
686
|
+
<feMergeNode in="coloredBlur"/>
|
|
687
|
+
<feMergeNode in="SourceGraphic"/>
|
|
688
|
+
</feMerge>
|
|
689
|
+
</filter>
|
|
690
|
+
</defs>
|
|
691
|
+
<g class="edges"></g>
|
|
692
|
+
<g class="nodes"></g>
|
|
693
|
+
</svg>
|
|
694
|
+
</div>
|
|
695
|
+
<div class="graph-controls">
|
|
696
|
+
<button class="control-btn" id="zoom-in" title="Zoom In">+</button>
|
|
697
|
+
<button class="control-btn" id="zoom-out" title="Zoom Out">-</button>
|
|
698
|
+
<button class="control-btn" id="zoom-reset" title="Reset View">⟲</button>
|
|
699
|
+
</div>
|
|
700
|
+
</div>
|
|
701
|
+
`;
|
|
702
|
+
|
|
703
|
+
const svg = container.querySelector('.graph-svg');
|
|
704
|
+
const edgesGroup = svg.querySelector('.edges');
|
|
705
|
+
const nodesGroup = svg.querySelector('.nodes');
|
|
706
|
+
|
|
707
|
+
// Draw edges
|
|
708
|
+
this.graphEdges.forEach(edge => {
|
|
709
|
+
const source = this.graphNodes.find(n => n.id === edge.source);
|
|
710
|
+
const target = this.graphNodes.find(n => n.id === edge.target);
|
|
711
|
+
if (!source || !target) return;
|
|
712
|
+
|
|
713
|
+
const isActive = this.isPathActive(source.path) || this.isPathActive(target.path);
|
|
714
|
+
const hasChanges = edge.hasChanges || this.isPathChanged(target.path);
|
|
715
|
+
const isFileEdge = edge.isFileEdge;
|
|
716
|
+
|
|
717
|
+
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
718
|
+
|
|
719
|
+
// Different path for file edges vs directory edges
|
|
720
|
+
if (isFileEdge) {
|
|
721
|
+
// Short vertical edge to file below
|
|
722
|
+
path.setAttribute('d', `M${source.x},${source.y + 25} L${target.x},${target.y - 12}`);
|
|
723
|
+
} else {
|
|
724
|
+
// Vertical edge to directory
|
|
725
|
+
const sourceY = source.y + 25;
|
|
726
|
+
const targetY = target.y - 25;
|
|
727
|
+
const midY = (sourceY + targetY) / 2;
|
|
728
|
+
path.setAttribute('d', `M${source.x},${sourceY} C${source.x},${midY} ${target.x},${midY} ${target.x},${targetY}`);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const classes = ['graph-edge'];
|
|
732
|
+
if (isActive) classes.push('active');
|
|
733
|
+
if (hasChanges) classes.push('has-changes');
|
|
734
|
+
if (isFileEdge) classes.push('file-edge');
|
|
735
|
+
path.setAttribute('class', classes.join(' '));
|
|
736
|
+
|
|
737
|
+
// Pulsing dashed line for changes
|
|
738
|
+
if (hasChanges) {
|
|
739
|
+
path.setAttribute('stroke', 'hsl(45, 93%, 58%)');
|
|
740
|
+
path.setAttribute('stroke-dasharray', '8,4');
|
|
741
|
+
path.setAttribute('filter', 'url(#glow-yellow)');
|
|
742
|
+
} else if (isActive) {
|
|
743
|
+
path.setAttribute('stroke-dasharray', '8,4');
|
|
744
|
+
path.setAttribute('filter', 'url(#glow)');
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
edgesGroup.appendChild(path);
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
// Draw nodes (directories and changed files)
|
|
751
|
+
this.graphNodes.forEach(node => {
|
|
752
|
+
// Handle "+N more" indicator
|
|
753
|
+
if (node.isMoreIndicator) {
|
|
754
|
+
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
755
|
+
g.setAttribute('class', 'graph-node more-indicator');
|
|
756
|
+
g.setAttribute('transform', `translate(${node.x - 30}, ${node.y - 10})`);
|
|
757
|
+
|
|
758
|
+
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
759
|
+
text.setAttribute('x', '0');
|
|
760
|
+
text.setAttribute('y', '12');
|
|
761
|
+
text.setAttribute('font-size', '10');
|
|
762
|
+
text.setAttribute('fill', 'hsl(45, 93%, 58%)');
|
|
763
|
+
text.setAttribute('font-weight', '600');
|
|
764
|
+
text.textContent = node.label;
|
|
765
|
+
g.appendChild(text);
|
|
766
|
+
|
|
767
|
+
g.addEventListener('click', (e) => {
|
|
768
|
+
e.stopPropagation();
|
|
769
|
+
this.showFileDiff(node.path);
|
|
770
|
+
});
|
|
771
|
+
g.style.cursor = 'pointer';
|
|
772
|
+
|
|
773
|
+
nodesGroup.appendChild(g);
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
const isActive = this.isPathActive(node.path);
|
|
778
|
+
const isDirectlyActive = this.activeFiles.has(node.path);
|
|
779
|
+
const isChanged = this.isPathChanged(node.path);
|
|
780
|
+
const isFile = node.isFile;
|
|
781
|
+
const isChangedFile = node.isChangedFile;
|
|
782
|
+
// Check both pre-calculated count and current changedPaths
|
|
783
|
+
const hasChanges = node.changedCount > 0 || this.changedPaths.has(node.path) || isChanged;
|
|
784
|
+
|
|
785
|
+
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
786
|
+
const classes = ['graph-node'];
|
|
787
|
+
if (isActive) classes.push('active');
|
|
788
|
+
if (isDirectlyActive) classes.push('directly-active');
|
|
789
|
+
if (hasChanges || isChangedFile) classes.push('has-changes');
|
|
790
|
+
if (isFile) classes.push('file-node');
|
|
791
|
+
g.setAttribute('class', classes.join(' '));
|
|
792
|
+
|
|
793
|
+
if (isFile) {
|
|
794
|
+
// Render changed file node (compact)
|
|
795
|
+
const width = 140;
|
|
796
|
+
const height = 26;
|
|
797
|
+
g.setAttribute('transform', `translate(${node.x - width/2}, ${node.y - height/2})`);
|
|
798
|
+
|
|
799
|
+
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
|
800
|
+
rect.setAttribute('class', 'node-bg');
|
|
801
|
+
rect.setAttribute('width', width);
|
|
802
|
+
rect.setAttribute('height', height);
|
|
803
|
+
rect.setAttribute('rx', '4');
|
|
804
|
+
rect.setAttribute('filter', 'url(#glow-yellow)');
|
|
805
|
+
g.appendChild(rect);
|
|
806
|
+
|
|
807
|
+
// File icon
|
|
808
|
+
const icon = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
809
|
+
icon.setAttribute('x', '10');
|
|
810
|
+
icon.setAttribute('y', '17');
|
|
811
|
+
icon.setAttribute('font-size', '11');
|
|
812
|
+
icon.textContent = this.getFileIcon(node.path?.split('.').pop() || '');
|
|
813
|
+
g.appendChild(icon);
|
|
814
|
+
|
|
815
|
+
// File name
|
|
816
|
+
const label = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
817
|
+
label.setAttribute('class', 'node-label file-label');
|
|
818
|
+
label.setAttribute('x', '26');
|
|
819
|
+
label.setAttribute('y', '17');
|
|
820
|
+
label.setAttribute('font-size', '10');
|
|
821
|
+
label.textContent = node.label.length > 16 ? node.label.slice(0, 14) + '...' : node.label;
|
|
822
|
+
g.appendChild(label);
|
|
823
|
+
|
|
824
|
+
// M badge
|
|
825
|
+
const badge = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
826
|
+
badge.setAttribute('x', width - 12);
|
|
827
|
+
badge.setAttribute('y', '17');
|
|
828
|
+
badge.setAttribute('text-anchor', 'middle');
|
|
829
|
+
badge.setAttribute('font-size', '9');
|
|
830
|
+
badge.setAttribute('font-weight', '700');
|
|
831
|
+
badge.setAttribute('fill', 'hsl(45, 93%, 58%)');
|
|
832
|
+
badge.textContent = 'M';
|
|
833
|
+
g.appendChild(badge);
|
|
834
|
+
} else {
|
|
835
|
+
// Render directory node
|
|
836
|
+
const width = 160;
|
|
837
|
+
const height = 50;
|
|
838
|
+
g.setAttribute('transform', `translate(${node.x - width/2}, ${node.y - height/2})`);
|
|
839
|
+
|
|
840
|
+
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
|
841
|
+
rect.setAttribute('class', 'node-bg');
|
|
842
|
+
rect.setAttribute('width', width);
|
|
843
|
+
rect.setAttribute('height', height);
|
|
844
|
+
rect.setAttribute('rx', '8');
|
|
845
|
+
if (hasChanges) {
|
|
846
|
+
rect.setAttribute('filter', 'url(#glow-yellow)');
|
|
847
|
+
} else if (isDirectlyActive) {
|
|
848
|
+
rect.setAttribute('filter', 'url(#glow-strong)');
|
|
849
|
+
}
|
|
850
|
+
g.appendChild(rect);
|
|
851
|
+
|
|
852
|
+
// Icon background
|
|
853
|
+
const iconBg = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
|
854
|
+
iconBg.setAttribute('cx', '25');
|
|
855
|
+
iconBg.setAttribute('cy', '25');
|
|
856
|
+
iconBg.setAttribute('r', '16');
|
|
857
|
+
iconBg.setAttribute('class', 'node-icon-bg');
|
|
858
|
+
g.appendChild(iconBg);
|
|
859
|
+
|
|
860
|
+
// Icon
|
|
861
|
+
const icon = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
862
|
+
icon.setAttribute('class', 'node-icon');
|
|
863
|
+
icon.setAttribute('x', '25');
|
|
864
|
+
icon.setAttribute('y', '30');
|
|
865
|
+
icon.setAttribute('text-anchor', 'middle');
|
|
866
|
+
icon.setAttribute('font-size', '14');
|
|
867
|
+
icon.textContent = this.getNodeIcon(node.type);
|
|
868
|
+
g.appendChild(icon);
|
|
869
|
+
|
|
870
|
+
// Label
|
|
871
|
+
const label = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
872
|
+
label.setAttribute('class', 'node-label');
|
|
873
|
+
label.setAttribute('x', '48');
|
|
874
|
+
label.setAttribute('y', '20');
|
|
875
|
+
label.setAttribute('font-size', '11');
|
|
876
|
+
label.textContent = node.label.length > 12 ? node.label.slice(0, 10) + '...' : node.label;
|
|
877
|
+
g.appendChild(label);
|
|
878
|
+
|
|
879
|
+
// Stats line
|
|
880
|
+
const stats = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
881
|
+
stats.setAttribute('class', 'node-type');
|
|
882
|
+
stats.setAttribute('x', '48');
|
|
883
|
+
stats.setAttribute('y', '34');
|
|
884
|
+
stats.setAttribute('font-size', '9');
|
|
885
|
+
const statsText = `${node.fileCount || 0} files, ${node.dirCount || 0} dirs`;
|
|
886
|
+
stats.textContent = statsText;
|
|
887
|
+
g.appendChild(stats);
|
|
888
|
+
|
|
889
|
+
// Changed badge
|
|
890
|
+
if (hasChanges && node.changedCount > 0) {
|
|
891
|
+
const badgeBg = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
|
892
|
+
badgeBg.setAttribute('x', width - 35);
|
|
893
|
+
badgeBg.setAttribute('y', '5');
|
|
894
|
+
badgeBg.setAttribute('width', '30');
|
|
895
|
+
badgeBg.setAttribute('height', '16');
|
|
896
|
+
badgeBg.setAttribute('rx', '8');
|
|
897
|
+
badgeBg.setAttribute('class', 'change-badge');
|
|
898
|
+
g.appendChild(badgeBg);
|
|
899
|
+
|
|
900
|
+
const badgeText = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
901
|
+
badgeText.setAttribute('x', width - 20);
|
|
902
|
+
badgeText.setAttribute('y', '16');
|
|
903
|
+
badgeText.setAttribute('text-anchor', 'middle');
|
|
904
|
+
badgeText.setAttribute('font-size', '9');
|
|
905
|
+
badgeText.setAttribute('font-weight', '600');
|
|
906
|
+
badgeText.setAttribute('fill', 'hsl(222, 47%, 11%)');
|
|
907
|
+
badgeText.textContent = node.changedCount;
|
|
908
|
+
g.appendChild(badgeText);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// Hover events
|
|
913
|
+
g.addEventListener('mouseenter', (e) => this.showTooltip(e, node));
|
|
914
|
+
g.addEventListener('mousemove', (e) => this.moveTooltip(e));
|
|
915
|
+
g.addEventListener('mouseleave', () => this.hideTooltip());
|
|
916
|
+
|
|
917
|
+
// Click to show diff
|
|
918
|
+
g.addEventListener('click', (e) => {
|
|
919
|
+
e.stopPropagation();
|
|
920
|
+
this.showFileDiff(node.path);
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
nodesGroup.appendChild(g);
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
// Setup interactions
|
|
927
|
+
this.setupGraphInteraction(container);
|
|
928
|
+
|
|
929
|
+
// Zoom controls
|
|
930
|
+
document.getElementById('zoom-in').addEventListener('click', () => {
|
|
931
|
+
this.zoom = Math.min(3, this.zoom * 1.2);
|
|
932
|
+
this.updateGraphTransform();
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
document.getElementById('zoom-out').addEventListener('click', () => {
|
|
936
|
+
this.zoom = Math.max(0.3, this.zoom / 1.2);
|
|
937
|
+
this.updateGraphTransform();
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
document.getElementById('zoom-reset').addEventListener('click', () => {
|
|
941
|
+
this.zoom = 1;
|
|
942
|
+
this.panX = 0;
|
|
943
|
+
this.panY = 0;
|
|
944
|
+
this.updateGraphTransform();
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
getNodeIcon(type) {
|
|
949
|
+
const icons = {
|
|
950
|
+
folder: '📁',
|
|
951
|
+
server: '🖥️',
|
|
952
|
+
client: '💻',
|
|
953
|
+
database: '🗄️',
|
|
954
|
+
assets: '🖼️',
|
|
955
|
+
config: '⚙️',
|
|
956
|
+
code: '📄',
|
|
957
|
+
style: '🎨',
|
|
958
|
+
docs: '📝'
|
|
959
|
+
};
|
|
960
|
+
return icons[type] || '📄';
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
renderFileExplorer(container) {
|
|
964
|
+
container.innerHTML = `
|
|
965
|
+
<div class="file-explorer">
|
|
966
|
+
<div class="file-tree">
|
|
967
|
+
<div class="file-tree-header">
|
|
968
|
+
<h3>EXPLORER</h3>
|
|
969
|
+
</div>
|
|
970
|
+
<div class="file-tree-content" id="file-tree-content"></div>
|
|
971
|
+
</div>
|
|
972
|
+
</div>
|
|
973
|
+
`;
|
|
974
|
+
|
|
975
|
+
const content = document.getElementById('file-tree-content');
|
|
976
|
+
this.renderTreeNode(this.structure, content, 0);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
renderTreeNode(node, container, depth) {
|
|
980
|
+
const isActive = this.isPathActive(node.path);
|
|
981
|
+
const isDirectlyActive = this.activeFiles.has(node.path);
|
|
982
|
+
const isChanged = this.isPathChanged(node.path);
|
|
983
|
+
const isExpanded = this.expandedNodes.has(node.path);
|
|
984
|
+
const hasChildren = node.type === 'directory' && node.children?.length > 0;
|
|
985
|
+
|
|
986
|
+
// For directories, check if any children are changed
|
|
987
|
+
let hasChangedChildren = false;
|
|
988
|
+
if (hasChildren) {
|
|
989
|
+
const countChanges = (n) => {
|
|
990
|
+
if (this.isPathChanged(n.path)) return true;
|
|
991
|
+
if (n.children) return n.children.some(countChanges);
|
|
992
|
+
return false;
|
|
993
|
+
};
|
|
994
|
+
hasChangedChildren = node.children.some(countChanges);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
const item = document.createElement('div');
|
|
998
|
+
const classes = ['tree-item'];
|
|
999
|
+
if (isActive) classes.push('active');
|
|
1000
|
+
if (isDirectlyActive) classes.push('directly-active');
|
|
1001
|
+
if (isChanged || hasChangedChildren) classes.push('has-changes');
|
|
1002
|
+
item.className = classes.join(' ');
|
|
1003
|
+
item.style.paddingLeft = `${depth * 16 + 8}px`;
|
|
1004
|
+
|
|
1005
|
+
const chevron = hasChildren ? (isExpanded ? '▼' : '▶') : ' ';
|
|
1006
|
+
const icon = node.type === 'directory' ? '📁' : this.getFileIcon(node.extension);
|
|
1007
|
+
|
|
1008
|
+
item.innerHTML = `
|
|
1009
|
+
<span class="tree-chevron ${isExpanded ? 'expanded' : ''}">${chevron}</span>
|
|
1010
|
+
<span class="tree-item-icon">${icon}</span>
|
|
1011
|
+
<span class="tree-item-name">${node.name}</span>
|
|
1012
|
+
${isChanged ? '<span class="tree-item-badge yellow-pulse">M</span>' : ''}
|
|
1013
|
+
${hasChangedChildren && !isChanged ? '<span class="tree-item-badge yellow-pulse">•</span>' : ''}
|
|
1014
|
+
${isDirectlyActive ? '<span class="tree-pulse-dot green"></span>' : ''}
|
|
1015
|
+
`;
|
|
1016
|
+
|
|
1017
|
+
item.addEventListener('click', (e) => {
|
|
1018
|
+
if (hasChildren) {
|
|
1019
|
+
if (this.expandedNodes.has(node.path)) {
|
|
1020
|
+
this.expandedNodes.delete(node.path);
|
|
1021
|
+
} else {
|
|
1022
|
+
this.expandedNodes.add(node.path);
|
|
1023
|
+
}
|
|
1024
|
+
this.render();
|
|
1025
|
+
} else {
|
|
1026
|
+
// File clicked - show diff
|
|
1027
|
+
this.showFileDiff(node.path);
|
|
1028
|
+
}
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
// Double click on directory to show diff for modified files in it
|
|
1032
|
+
if (hasChildren) {
|
|
1033
|
+
item.addEventListener('dblclick', (e) => {
|
|
1034
|
+
e.stopPropagation();
|
|
1035
|
+
this.showFileDiff(node.path);
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
container.appendChild(item);
|
|
1040
|
+
|
|
1041
|
+
if (hasChildren && isExpanded) {
|
|
1042
|
+
const childContainer = document.createElement('div');
|
|
1043
|
+
childContainer.className = 'tree-children';
|
|
1044
|
+
node.children.forEach(child => this.renderTreeNode(child, childContainer, depth + 1));
|
|
1045
|
+
container.appendChild(childContainer);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
getFileIcon(ext) {
|
|
1050
|
+
const icons = {
|
|
1051
|
+
js: '📜', ts: '📘', tsx: '⚛️', jsx: '⚛️',
|
|
1052
|
+
css: '🎨', scss: '🎨', html: '🌐',
|
|
1053
|
+
json: '📋', md: '📝', py: '🐍'
|
|
1054
|
+
};
|
|
1055
|
+
return icons[ext] || '📄';
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
renderSunburstView(container) {
|
|
1059
|
+
const width = container.clientWidth || 800;
|
|
1060
|
+
const height = container.clientHeight || 600;
|
|
1061
|
+
const centerX = width / 2;
|
|
1062
|
+
const centerY = height / 2;
|
|
1063
|
+
const maxRadius = Math.min(width, height) / 2 - 100;
|
|
1064
|
+
|
|
1065
|
+
container.innerHTML = `
|
|
1066
|
+
<div class="sunburst-container">
|
|
1067
|
+
<svg class="sunburst-svg" viewBox="0 0 ${width} ${height}">
|
|
1068
|
+
<defs>
|
|
1069
|
+
<filter id="sunburst-glow-green" x="-50%" y="-50%" width="200%" height="200%">
|
|
1070
|
+
<feFlood flood-color="hsl(150, 100%, 50%)" flood-opacity="0.6" result="flood"/>
|
|
1071
|
+
<feComposite in="flood" in2="SourceGraphic" operator="in" result="mask"/>
|
|
1072
|
+
<feGaussianBlur in="mask" stdDeviation="6" result="coloredBlur"/>
|
|
1073
|
+
<feMerge>
|
|
1074
|
+
<feMergeNode in="coloredBlur"/>
|
|
1075
|
+
<feMergeNode in="SourceGraphic"/>
|
|
1076
|
+
</feMerge>
|
|
1077
|
+
</filter>
|
|
1078
|
+
<filter id="sunburst-glow-yellow" x="-50%" y="-50%" width="200%" height="200%">
|
|
1079
|
+
<feFlood flood-color="hsl(45, 100%, 55%)" flood-opacity="0.7" result="flood"/>
|
|
1080
|
+
<feComposite in="flood" in2="SourceGraphic" operator="in" result="mask"/>
|
|
1081
|
+
<feGaussianBlur in="mask" stdDeviation="8" result="coloredBlur"/>
|
|
1082
|
+
<feMerge>
|
|
1083
|
+
<feMergeNode in="coloredBlur"/>
|
|
1084
|
+
<feMergeNode in="SourceGraphic"/>
|
|
1085
|
+
</feMerge>
|
|
1086
|
+
</filter>
|
|
1087
|
+
</defs>
|
|
1088
|
+
</svg>
|
|
1089
|
+
<div class="sunburst-legend">
|
|
1090
|
+
<h4>STATUS</h4>
|
|
1091
|
+
<div class="legend-items">
|
|
1092
|
+
<div class="legend-row"><span class="legend-swatch yellow-glow" style="background-color: hsl(45, 93%, 50%); display: inline-block;"></span>Pending Changes (Yellow)</div>
|
|
1093
|
+
<div class="legend-row"><span class="legend-swatch green-glow" style="background-color: hsl(150, 80%, 45%); display: inline-block;"></span>Currently Editing (Green)</div>
|
|
1094
|
+
<div class="legend-row"><span class="legend-swatch" style="background-color: hsl(217, 15%, 18%); display: inline-block; opacity: 0.4;"></span>Unchanged (Muted)</div>
|
|
1095
|
+
</div>
|
|
1096
|
+
</div>
|
|
1097
|
+
</div>
|
|
1098
|
+
`;
|
|
1099
|
+
|
|
1100
|
+
const svg = container.querySelector('.sunburst-svg');
|
|
1101
|
+
|
|
1102
|
+
const calculateSize = (node) => {
|
|
1103
|
+
if (node.type === 'file') return 1;
|
|
1104
|
+
if (!node.children?.length) return 1;
|
|
1105
|
+
return node.children.reduce((sum, child) => sum + calculateSize(child), 0);
|
|
1106
|
+
};
|
|
1107
|
+
|
|
1108
|
+
const drawArc = (node, startAngle, endAngle, depth, maxDepth = 4) => {
|
|
1109
|
+
if (depth > maxDepth) return;
|
|
1110
|
+
|
|
1111
|
+
const innerRadius = depth === 0 ? 0 : 60 + (depth - 1) * 50;
|
|
1112
|
+
const outerRadius = depth === 0 ? 60 : innerRadius + 45;
|
|
1113
|
+
const clampedOuter = Math.min(outerRadius, maxRadius);
|
|
1114
|
+
|
|
1115
|
+
if (clampedOuter <= innerRadius) return;
|
|
1116
|
+
|
|
1117
|
+
const isActive = this.isPathActive(node.path);
|
|
1118
|
+
const isDirectlyActive = this.activeFiles.has(node.path);
|
|
1119
|
+
const isChanged = this.isPathChanged(node.path);
|
|
1120
|
+
|
|
1121
|
+
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
1122
|
+
path.setAttribute('d', this.createArcPath(centerX, centerY, innerRadius, clampedOuter, startAngle, endAngle));
|
|
1123
|
+
|
|
1124
|
+
// Color logic:
|
|
1125
|
+
// - Yellow for pending changes (git diff)
|
|
1126
|
+
// - Green for currently editing (active)
|
|
1127
|
+
// - Muted for unchanged
|
|
1128
|
+
if (isChanged) {
|
|
1129
|
+
// YELLOW for pending changes
|
|
1130
|
+
path.setAttribute('fill', 'hsl(45, 93%, 50%)');
|
|
1131
|
+
path.setAttribute('stroke', 'hsl(45, 100%, 60%)');
|
|
1132
|
+
path.setAttribute('stroke-width', '3');
|
|
1133
|
+
path.setAttribute('opacity', '1');
|
|
1134
|
+
} else if (isDirectlyActive) {
|
|
1135
|
+
// GREEN for currently editing
|
|
1136
|
+
path.setAttribute('fill', 'hsl(150, 80%, 45%)');
|
|
1137
|
+
path.setAttribute('stroke', 'hsl(150, 100%, 50%)');
|
|
1138
|
+
path.setAttribute('stroke-width', '4');
|
|
1139
|
+
path.setAttribute('opacity', '1');
|
|
1140
|
+
} else if (isActive) {
|
|
1141
|
+
// Slightly highlighted for parent of active
|
|
1142
|
+
path.setAttribute('fill', this.getArcColor(node));
|
|
1143
|
+
path.setAttribute('stroke', 'hsl(150, 60%, 40%)');
|
|
1144
|
+
path.setAttribute('stroke-width', '2');
|
|
1145
|
+
path.setAttribute('opacity', '0.8');
|
|
1146
|
+
} else {
|
|
1147
|
+
// Muted but visible for unchanged
|
|
1148
|
+
path.setAttribute('fill', this.getMutedColor(node));
|
|
1149
|
+
path.setAttribute('stroke', 'hsl(222, 20%, 25%)');
|
|
1150
|
+
path.setAttribute('stroke-width', '1');
|
|
1151
|
+
path.setAttribute('opacity', '0.5');
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
const classes = ['sunburst-arc'];
|
|
1155
|
+
if (isDirectlyActive) {
|
|
1156
|
+
classes.push('directly-active');
|
|
1157
|
+
path.setAttribute('filter', 'url(#sunburst-glow-green)');
|
|
1158
|
+
} else if (isChanged) {
|
|
1159
|
+
classes.push('changed');
|
|
1160
|
+
path.setAttribute('filter', 'url(#sunburst-glow-yellow)');
|
|
1161
|
+
}
|
|
1162
|
+
path.setAttribute('class', classes.join(' '));
|
|
1163
|
+
|
|
1164
|
+
path.addEventListener('mouseenter', (e) => this.showTooltip(e, node));
|
|
1165
|
+
path.addEventListener('mousemove', (e) => this.moveTooltip(e));
|
|
1166
|
+
path.addEventListener('mouseleave', () => this.hideTooltip());
|
|
1167
|
+
|
|
1168
|
+
// Click to show diff
|
|
1169
|
+
path.addEventListener('click', (e) => {
|
|
1170
|
+
e.stopPropagation();
|
|
1171
|
+
this.showFileDiff(node.path);
|
|
1172
|
+
});
|
|
1173
|
+
|
|
1174
|
+
svg.appendChild(path);
|
|
1175
|
+
|
|
1176
|
+
if (node.children?.length) {
|
|
1177
|
+
const totalSize = node.children.reduce((sum, child) => sum + calculateSize(child), 0);
|
|
1178
|
+
let currentAngle = startAngle;
|
|
1179
|
+
|
|
1180
|
+
node.children.forEach(child => {
|
|
1181
|
+
const childSize = calculateSize(child);
|
|
1182
|
+
const childAngle = (childSize / totalSize) * (endAngle - startAngle);
|
|
1183
|
+
drawArc(child, currentAngle, currentAngle + childAngle, depth + 1, maxDepth);
|
|
1184
|
+
currentAngle += childAngle;
|
|
1185
|
+
});
|
|
1186
|
+
}
|
|
1187
|
+
};
|
|
1188
|
+
|
|
1189
|
+
drawArc(this.structure, 0, Math.PI * 2, 0);
|
|
1190
|
+
|
|
1191
|
+
// Center label
|
|
1192
|
+
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
1193
|
+
text.setAttribute('x', centerX);
|
|
1194
|
+
text.setAttribute('y', centerY);
|
|
1195
|
+
text.setAttribute('text-anchor', 'middle');
|
|
1196
|
+
text.setAttribute('dominant-baseline', 'middle');
|
|
1197
|
+
text.setAttribute('fill', 'white');
|
|
1198
|
+
text.setAttribute('font-size', '14');
|
|
1199
|
+
text.setAttribute('font-weight', '600');
|
|
1200
|
+
text.setAttribute('font-family', 'Orbitron, sans-serif');
|
|
1201
|
+
text.textContent = this.structure.name;
|
|
1202
|
+
svg.appendChild(text);
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
createArcPath(cx, cy, innerRadius, outerRadius, startAngle, endAngle) {
|
|
1206
|
+
const start = startAngle - Math.PI / 2;
|
|
1207
|
+
const end = endAngle - Math.PI / 2;
|
|
1208
|
+
|
|
1209
|
+
const x1 = cx + innerRadius * Math.cos(start);
|
|
1210
|
+
const y1 = cy + innerRadius * Math.sin(start);
|
|
1211
|
+
const x2 = cx + outerRadius * Math.cos(start);
|
|
1212
|
+
const y2 = cy + outerRadius * Math.sin(start);
|
|
1213
|
+
const x3 = cx + outerRadius * Math.cos(end);
|
|
1214
|
+
const y3 = cy + outerRadius * Math.sin(end);
|
|
1215
|
+
const x4 = cx + innerRadius * Math.cos(end);
|
|
1216
|
+
const y4 = cy + innerRadius * Math.sin(end);
|
|
1217
|
+
|
|
1218
|
+
const largeArc = endAngle - startAngle > Math.PI ? 1 : 0;
|
|
1219
|
+
|
|
1220
|
+
if (innerRadius === 0) {
|
|
1221
|
+
return `M ${cx} ${cy} L ${x2} ${y2} A ${outerRadius} ${outerRadius} 0 ${largeArc} 1 ${x3} ${y3} Z`;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
return `M ${x1} ${y1} L ${x2} ${y2} A ${outerRadius} ${outerRadius} 0 ${largeArc} 1 ${x3} ${y3} L ${x4} ${y4} A ${innerRadius} ${innerRadius} 0 ${largeArc} 0 ${x1} ${y1} Z`;
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
getArcColor(node) {
|
|
1228
|
+
if (node.type === 'directory') return 'hsl(217, 91%, 50%)';
|
|
1229
|
+
const colors = {
|
|
1230
|
+
code: 'hsl(142, 76%, 45%)',
|
|
1231
|
+
style: 'hsl(330, 80%, 55%)',
|
|
1232
|
+
config: 'hsl(45, 90%, 50%)',
|
|
1233
|
+
docs: 'hsl(200, 90%, 50%)',
|
|
1234
|
+
markup: 'hsl(280, 70%, 55%)',
|
|
1235
|
+
data: 'hsl(170, 70%, 45%)',
|
|
1236
|
+
image: 'hsl(25, 80%, 50%)'
|
|
1237
|
+
};
|
|
1238
|
+
return colors[node.category] || 'hsl(215, 20%, 45%)';
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
getMutedColor(node) {
|
|
1242
|
+
// Return desaturated but visible versions for unchanged items
|
|
1243
|
+
if (node.type === 'directory') return 'hsl(217, 20%, 28%)';
|
|
1244
|
+
const mutedColors = {
|
|
1245
|
+
code: 'hsl(142, 15%, 28%)',
|
|
1246
|
+
style: 'hsl(330, 15%, 30%)',
|
|
1247
|
+
config: 'hsl(45, 15%, 30%)',
|
|
1248
|
+
docs: 'hsl(200, 15%, 30%)',
|
|
1249
|
+
markup: 'hsl(280, 12%, 30%)',
|
|
1250
|
+
data: 'hsl(170, 12%, 28%)',
|
|
1251
|
+
image: 'hsl(25, 15%, 30%)'
|
|
1252
|
+
};
|
|
1253
|
+
return mutedColors[node.category] || 'hsl(215, 12%, 26%)';
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
showTooltip(e, node) {
|
|
1257
|
+
const type = node.type === 'directory' ? 'Directory' : (node.category || 'File');
|
|
1258
|
+
const childInfo = node.childCount ? ` (${node.childCount} items)` : (node.children?.length ? ` (${node.children.length} items)` : '');
|
|
1259
|
+
|
|
1260
|
+
this.tooltip.innerHTML = `
|
|
1261
|
+
<strong>${node.label || node.name}</strong>
|
|
1262
|
+
<div class="tooltip-type">${type}${childInfo}</div>
|
|
1263
|
+
<div class="tooltip-path">${node.path}</div>
|
|
1264
|
+
`;
|
|
1265
|
+
this.tooltip.style.display = 'block';
|
|
1266
|
+
this.moveTooltip(e);
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
moveTooltip(e) {
|
|
1270
|
+
this.tooltip.style.left = (e.clientX + 15) + 'px';
|
|
1271
|
+
this.tooltip.style.top = (e.clientY + 15) + 'px';
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
hideTooltip() {
|
|
1275
|
+
this.tooltip.style.display = 'none';
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
// Initialize
|
|
1280
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
1281
|
+
window.seeclaude = new SeeClaude();
|
|
1282
|
+
});
|