difflicious 0.1.0__py3-none-any.whl
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.
- difflicious/__init__.py +6 -0
- difflicious/app.py +505 -0
- difflicious/cli.py +77 -0
- difflicious/diff_parser.py +525 -0
- difflicious/dummy_data.json +44 -0
- difflicious/git_operations.py +1005 -0
- difflicious/services/__init__.py +1 -0
- difflicious/services/base_service.py +32 -0
- difflicious/services/diff_service.py +403 -0
- difflicious/services/exceptions.py +19 -0
- difflicious/services/git_service.py +135 -0
- difflicious/services/syntax_service.py +162 -0
- difflicious/services/template_service.py +382 -0
- difflicious/static/css/styles.css +885 -0
- difflicious/static/css/tailwind.css +1 -0
- difflicious/static/css/tailwind.input.css +5 -0
- difflicious/static/js/app.js +1002 -0
- difflicious/static/js/diff-interactions.js +1617 -0
- difflicious/templates/base.html +54 -0
- difflicious/templates/diff_file.html +90 -0
- difflicious/templates/diff_groups.html +29 -0
- difflicious/templates/diff_hunk.html +170 -0
- difflicious/templates/index.html +54 -0
- difflicious/templates/partials/empty_state.html +29 -0
- difflicious/templates/partials/global_controls.html +23 -0
- difflicious/templates/partials/loading_state.html +7 -0
- difflicious/templates/partials/toolbar.html +165 -0
- difflicious-0.1.0.dist-info/METADATA +190 -0
- difflicious-0.1.0.dist-info/RECORD +32 -0
- difflicious-0.1.0.dist-info/WHEEL +4 -0
- difflicious-0.1.0.dist-info/entry_points.txt +2 -0
- difflicious-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1002 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Difflicious Alpine.js Application
|
|
3
|
+
* Main application logic for git diff visualization
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
function diffliciousApp() { // eslint-disable-line no-unused-vars
|
|
7
|
+
return {
|
|
8
|
+
// Application state
|
|
9
|
+
loading: false,
|
|
10
|
+
gitStatus: {
|
|
11
|
+
current_branch: '',
|
|
12
|
+
repository_name: '',
|
|
13
|
+
files_changed: 0,
|
|
14
|
+
git_available: false
|
|
15
|
+
},
|
|
16
|
+
groups: {
|
|
17
|
+
untracked: { files: [], count: 0, visible: true },
|
|
18
|
+
unstaged: { files: [], count: 0, visible: true },
|
|
19
|
+
staged: { files: [], count: 0, visible: true }
|
|
20
|
+
},
|
|
21
|
+
branches: {
|
|
22
|
+
all: [],
|
|
23
|
+
current: '',
|
|
24
|
+
default: '',
|
|
25
|
+
others: []
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
// UI state
|
|
29
|
+
showOnlyChanged: true,
|
|
30
|
+
searchFilter: '',
|
|
31
|
+
|
|
32
|
+
// Branch and diff options
|
|
33
|
+
baseBranch: 'main',
|
|
34
|
+
unstaged: true,
|
|
35
|
+
untracked: true,
|
|
36
|
+
|
|
37
|
+
// Saved state for restoration
|
|
38
|
+
savedFileExpansions: {},
|
|
39
|
+
|
|
40
|
+
// Context expansion state tracking
|
|
41
|
+
contextExpansions: {}, // { filePath: { hunkIndex: { beforeExpanded: number, afterExpanded: number } } }
|
|
42
|
+
contextLoading: {}, // { filePath: { hunkIndex: { before: bool, after: bool } } }
|
|
43
|
+
|
|
44
|
+
// LocalStorage utility functions
|
|
45
|
+
getStorageKey() {
|
|
46
|
+
const repoName = this.gitStatus.repository_name || 'unknown';
|
|
47
|
+
return `difflicious.repo.${repoName}`;
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
saveUIState() {
|
|
51
|
+
if (!this.gitStatus.repository_name) return; // Don't save if no repo name yet
|
|
52
|
+
|
|
53
|
+
const state = {
|
|
54
|
+
// UI Controls
|
|
55
|
+
baseBranch: this.baseBranch,
|
|
56
|
+
unstaged: this.unstaged,
|
|
57
|
+
untracked: this.untracked,
|
|
58
|
+
showOnlyChanged: this.showOnlyChanged,
|
|
59
|
+
searchFilter: this.searchFilter,
|
|
60
|
+
|
|
61
|
+
// Group visibility
|
|
62
|
+
groupVisibility: {
|
|
63
|
+
untracked: this.groups.untracked.visible,
|
|
64
|
+
unstaged: this.groups.unstaged.visible,
|
|
65
|
+
staged: this.groups.staged.visible
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
// File expansion states (by file path)
|
|
69
|
+
fileExpansions: this.getFileExpansionStates()
|
|
70
|
+
|
|
71
|
+
// Note: contextExpansions are NOT persisted - they should start fresh each session
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
localStorage.setItem(this.getStorageKey(), JSON.stringify(state));
|
|
76
|
+
} catch (error) {
|
|
77
|
+
console.warn('Failed to save UI state to localStorage:', error);
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
loadUIState() {
|
|
82
|
+
if (!this.gitStatus.repository_name) return; // Don't load if no repo name yet
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const saved = localStorage.getItem(this.getStorageKey());
|
|
86
|
+
if (!saved) return;
|
|
87
|
+
|
|
88
|
+
const state = JSON.parse(saved);
|
|
89
|
+
|
|
90
|
+
// Restore UI controls
|
|
91
|
+
if (state.baseBranch) this.baseBranch = state.baseBranch;
|
|
92
|
+
if (typeof state.unstaged === 'boolean') this.unstaged = state.unstaged;
|
|
93
|
+
if (typeof state.untracked === 'boolean') this.untracked = state.untracked;
|
|
94
|
+
if (typeof state.showOnlyChanged === 'boolean') this.showOnlyChanged = state.showOnlyChanged;
|
|
95
|
+
if (state.searchFilter !== undefined) this.searchFilter = state.searchFilter;
|
|
96
|
+
|
|
97
|
+
// Restore group visibility
|
|
98
|
+
if (state.groupVisibility) {
|
|
99
|
+
if (typeof state.groupVisibility.untracked === 'boolean') {
|
|
100
|
+
this.groups.untracked.visible = state.groupVisibility.untracked;
|
|
101
|
+
}
|
|
102
|
+
if (typeof state.groupVisibility.unstaged === 'boolean') {
|
|
103
|
+
this.groups.unstaged.visible = state.groupVisibility.unstaged;
|
|
104
|
+
}
|
|
105
|
+
if (typeof state.groupVisibility.staged === 'boolean') {
|
|
106
|
+
this.groups.staged.visible = state.groupVisibility.staged;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Restore file expansion states
|
|
111
|
+
this.savedFileExpansions = state.fileExpansions || {};
|
|
112
|
+
|
|
113
|
+
// Note: contextExpansions are NOT restored - they start fresh each session
|
|
114
|
+
if (this.savedFileExpansions) {
|
|
115
|
+
Object.keys(this.groups).forEach(groupKey => {
|
|
116
|
+
this.groups[groupKey].files.forEach(file => {
|
|
117
|
+
if (file.path && Object.prototype.hasOwnProperty.call(this.savedFileExpansions, file.path)) {
|
|
118
|
+
file.expanded = this.savedFileExpansions[file.path];
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
} catch (error) {
|
|
124
|
+
console.warn('Failed to load UI state from localStorage:', error);
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
clearUIState() {
|
|
129
|
+
try {
|
|
130
|
+
localStorage.removeItem(this.getStorageKey());
|
|
131
|
+
} catch (error) {
|
|
132
|
+
console.warn('Failed to clear UI state from localStorage:', error);
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
getFileExpansionStates() {
|
|
137
|
+
// Start with previously saved expansions to preserve files that are no longer visible
|
|
138
|
+
const expansions = { ...this.savedFileExpansions };
|
|
139
|
+
|
|
140
|
+
// Update with current file states
|
|
141
|
+
Object.keys(this.groups).forEach(groupKey => {
|
|
142
|
+
this.groups[groupKey].files.forEach(file => {
|
|
143
|
+
if (file.path && typeof file.expanded === 'boolean') {
|
|
144
|
+
expansions[file.path] = file.expanded;
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
return expansions;
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
// Computed properties
|
|
152
|
+
get visibleGroups() {
|
|
153
|
+
const groups = [];
|
|
154
|
+
|
|
155
|
+
// Special case: if only staged changes are displayed, show files without grouping
|
|
156
|
+
const showingStagedOnly = !this.unstaged && !this.untracked;
|
|
157
|
+
|
|
158
|
+
// Add groups that have content (headers always show, but content may be hidden)
|
|
159
|
+
if (this.groups.untracked.count > 0) {
|
|
160
|
+
groups.push({
|
|
161
|
+
key: 'untracked',
|
|
162
|
+
title: 'Untracked',
|
|
163
|
+
files: this.filterFiles(this.groups.untracked.files),
|
|
164
|
+
visible: this.groups.untracked.visible,
|
|
165
|
+
count: this.groups.untracked.count,
|
|
166
|
+
hideGroupHeader: false
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (this.groups.unstaged.count > 0) {
|
|
171
|
+
groups.push({
|
|
172
|
+
key: 'unstaged',
|
|
173
|
+
title: 'Unstaged',
|
|
174
|
+
files: this.filterFiles(this.groups.unstaged.files),
|
|
175
|
+
visible: this.groups.unstaged.visible,
|
|
176
|
+
count: this.groups.unstaged.count,
|
|
177
|
+
hideGroupHeader: false
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (this.groups.staged.count > 0) {
|
|
182
|
+
groups.push({
|
|
183
|
+
key: 'staged',
|
|
184
|
+
title: 'Staged',
|
|
185
|
+
files: this.filterFiles(this.groups.staged.files),
|
|
186
|
+
visible: this.groups.staged.visible,
|
|
187
|
+
count: this.groups.staged.count,
|
|
188
|
+
hideGroupHeader: showingStagedOnly
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return groups;
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
get totalVisibleFiles() {
|
|
196
|
+
return this.visibleGroups.reduce((total, group) =>
|
|
197
|
+
total + (group.visible ? group.files.length : 0), 0
|
|
198
|
+
);
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
get hasAnyGroups() {
|
|
202
|
+
return this.visibleGroups.length > 0;
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
// Check if all visible files are expanded
|
|
206
|
+
get allExpanded() {
|
|
207
|
+
const allFiles = this.getAllVisibleFiles();
|
|
208
|
+
return allFiles.length > 0 && allFiles.every(file => file.expanded);
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
// Check if all visible files are collapsed
|
|
212
|
+
get allCollapsed() {
|
|
213
|
+
const allFiles = this.getAllVisibleFiles();
|
|
214
|
+
return allFiles.length > 0 && allFiles.every(file => !file.expanded);
|
|
215
|
+
},
|
|
216
|
+
|
|
217
|
+
// Helper methods
|
|
218
|
+
filterFiles(files) {
|
|
219
|
+
let filtered = files.map((file, originalIndex) => ({
|
|
220
|
+
...file,
|
|
221
|
+
originalIndex // Store the original index for toggleFile to use
|
|
222
|
+
}));
|
|
223
|
+
|
|
224
|
+
// Filter by search term
|
|
225
|
+
if (this.searchFilter.trim()) {
|
|
226
|
+
const search = this.searchFilter.toLowerCase();
|
|
227
|
+
filtered = filtered.filter(file =>
|
|
228
|
+
file.path.toLowerCase().includes(search)
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Filter by changed files only
|
|
233
|
+
if (this.showOnlyChanged) {
|
|
234
|
+
filtered = filtered.filter(file =>
|
|
235
|
+
file.additions > 0 || file.deletions > 0 || file.status === 'untracked' || file.status === 'staged'
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return filtered;
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
getAllVisibleFiles() {
|
|
243
|
+
const allFiles = [];
|
|
244
|
+
this.visibleGroups.forEach(group => {
|
|
245
|
+
if (group.visible) {
|
|
246
|
+
allFiles.push(...group.files);
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
return allFiles;
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
toggleGroupVisibility(groupKey) {
|
|
253
|
+
this.groups[groupKey].visible = !this.groups[groupKey].visible;
|
|
254
|
+
this.saveUIState();
|
|
255
|
+
},
|
|
256
|
+
|
|
257
|
+
// Detect language from file extension
|
|
258
|
+
detectLanguage(filePath) {
|
|
259
|
+
const ext = filePath.split('.').pop()?.toLowerCase();
|
|
260
|
+
const languageMap = {
|
|
261
|
+
'js': 'javascript',
|
|
262
|
+
'jsx': 'javascript',
|
|
263
|
+
'ts': 'typescript',
|
|
264
|
+
'tsx': 'typescript',
|
|
265
|
+
'py': 'python',
|
|
266
|
+
'html': 'html',
|
|
267
|
+
'htm': 'html',
|
|
268
|
+
'css': 'css',
|
|
269
|
+
'scss': 'scss',
|
|
270
|
+
'sass': 'sass',
|
|
271
|
+
'less': 'less',
|
|
272
|
+
'json': 'json',
|
|
273
|
+
'xml': 'xml',
|
|
274
|
+
'yaml': 'yaml',
|
|
275
|
+
'yml': 'yaml',
|
|
276
|
+
'md': 'markdown',
|
|
277
|
+
'sh': 'bash',
|
|
278
|
+
'bash': 'bash',
|
|
279
|
+
'zsh': 'bash',
|
|
280
|
+
'php': 'php',
|
|
281
|
+
'rb': 'ruby',
|
|
282
|
+
'go': 'go',
|
|
283
|
+
'rs': 'rust',
|
|
284
|
+
'java': 'java',
|
|
285
|
+
'c': 'c',
|
|
286
|
+
'cpp': 'cpp',
|
|
287
|
+
'cc': 'cpp',
|
|
288
|
+
'cxx': 'cpp',
|
|
289
|
+
'h': 'c',
|
|
290
|
+
'hpp': 'cpp',
|
|
291
|
+
'cs': 'csharp',
|
|
292
|
+
'sql': 'sql',
|
|
293
|
+
'r': 'r',
|
|
294
|
+
'swift': 'swift',
|
|
295
|
+
'kt': 'kotlin',
|
|
296
|
+
'scala': 'scala',
|
|
297
|
+
'clj': 'clojure',
|
|
298
|
+
'ex': 'elixir',
|
|
299
|
+
'exs': 'elixir',
|
|
300
|
+
'dockerfile': 'dockerfile'
|
|
301
|
+
};
|
|
302
|
+
return languageMap[ext] || 'plaintext';
|
|
303
|
+
},
|
|
304
|
+
|
|
305
|
+
// Apply syntax highlighting to code content
|
|
306
|
+
highlightCode(content, filePath) {
|
|
307
|
+
if (!content || !window.hljs) return content;
|
|
308
|
+
|
|
309
|
+
try {
|
|
310
|
+
const language = this.detectLanguage(filePath);
|
|
311
|
+
if (language === 'plaintext') {
|
|
312
|
+
// Try auto-detection for unknown extensions
|
|
313
|
+
const result = hljs.highlightAuto(content);
|
|
314
|
+
return result.value;
|
|
315
|
+
} else {
|
|
316
|
+
// Use detected language
|
|
317
|
+
const result = hljs.highlight(content, { language });
|
|
318
|
+
return result.value;
|
|
319
|
+
}
|
|
320
|
+
} catch (error) {
|
|
321
|
+
// If highlighting fails, return original content
|
|
322
|
+
console.warn('Syntax highlighting failed:', error);
|
|
323
|
+
return content;
|
|
324
|
+
}
|
|
325
|
+
},
|
|
326
|
+
|
|
327
|
+
// Initialize the application
|
|
328
|
+
async init() {
|
|
329
|
+
await this.loadBranches(); // Load branches first
|
|
330
|
+
await this.loadGitStatus();
|
|
331
|
+
this.loadUIState(); // Load saved UI state after we have repository name
|
|
332
|
+
await this.loadDiffs();
|
|
333
|
+
},
|
|
334
|
+
|
|
335
|
+
// Load branch data from API
|
|
336
|
+
async loadBranches() {
|
|
337
|
+
try {
|
|
338
|
+
const response = await fetch('/api/branches');
|
|
339
|
+
const data = await response.json();
|
|
340
|
+
if (data.status === 'ok') {
|
|
341
|
+
this.branches = data.branches;
|
|
342
|
+
// Set baseBranch to default if available, otherwise current
|
|
343
|
+
this.baseBranch = this.branches.default || this.branches.current;
|
|
344
|
+
}
|
|
345
|
+
} catch (error) {
|
|
346
|
+
console.error('Failed to load branches:', error);
|
|
347
|
+
}
|
|
348
|
+
},
|
|
349
|
+
|
|
350
|
+
// Load git status from API
|
|
351
|
+
async loadGitStatus() {
|
|
352
|
+
try {
|
|
353
|
+
const response = await fetch('/api/status');
|
|
354
|
+
const data = await response.json();
|
|
355
|
+
|
|
356
|
+
if (data.status === 'ok') {
|
|
357
|
+
this.gitStatus = {
|
|
358
|
+
current_branch: data.current_branch || 'unknown',
|
|
359
|
+
repository_name: data.repository_name || 'unknown',
|
|
360
|
+
files_changed: data.files_changed || 0,
|
|
361
|
+
git_available: data.git_available || false
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
} catch (error) {
|
|
365
|
+
console.error('Failed to load git status:', error);
|
|
366
|
+
this.gitStatus = {
|
|
367
|
+
current_branch: 'error',
|
|
368
|
+
repository_name: 'error',
|
|
369
|
+
files_changed: 0,
|
|
370
|
+
git_available: false
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
},
|
|
374
|
+
|
|
375
|
+
// Load diff data from API
|
|
376
|
+
async loadDiffs() {
|
|
377
|
+
this.loading = true;
|
|
378
|
+
|
|
379
|
+
// Save current UI state before fetching new diff data
|
|
380
|
+
this.saveUIState();
|
|
381
|
+
|
|
382
|
+
try {
|
|
383
|
+
// Build query parameters based on UI state
|
|
384
|
+
const params = new URLSearchParams();
|
|
385
|
+
|
|
386
|
+
// Handle comparison mode selection
|
|
387
|
+
if (this.baseBranch) {
|
|
388
|
+
// Determine comparison mode based on base_ref
|
|
389
|
+
const isHeadComparison = this.baseBranch === 'HEAD' || this.baseBranch === this.branches.current;
|
|
390
|
+
params.set('use_head', isHeadComparison.toString());
|
|
391
|
+
params.set('base_ref', this.baseBranch);
|
|
392
|
+
params.set('base_commit', this.baseBranch); // legacy compat
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Handle unstaged/untracked options
|
|
396
|
+
params.set('unstaged', this.unstaged.toString());
|
|
397
|
+
params.set('untracked', this.untracked.toString());
|
|
398
|
+
|
|
399
|
+
// Add other filters
|
|
400
|
+
if (this.searchFilter.trim()) {
|
|
401
|
+
params.set('file', this.searchFilter.trim());
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const queryString = params.toString();
|
|
405
|
+
const url = queryString ? `/api/diff?${queryString}` : '/api/diff';
|
|
406
|
+
|
|
407
|
+
const response = await fetch(url);
|
|
408
|
+
const data = await response.json();
|
|
409
|
+
|
|
410
|
+
if (data.status === 'ok') {
|
|
411
|
+
// Update groups with new data
|
|
412
|
+
const groups = data.groups || {};
|
|
413
|
+
|
|
414
|
+
Object.keys(this.groups).forEach(groupKey => {
|
|
415
|
+
const groupData = groups[groupKey] || { files: [], count: 0 };
|
|
416
|
+
this.groups[groupKey].files = (groupData.files || []).map(file => ({
|
|
417
|
+
...file,
|
|
418
|
+
expanded: true // Add UI state for each file - start expanded
|
|
419
|
+
}));
|
|
420
|
+
this.groups[groupKey].count = groupData.count || 0;
|
|
421
|
+
// Keep existing visibility state
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
// Load UI state after processing new diff data (includes file expansion restoration)
|
|
425
|
+
this.loadUIState();
|
|
426
|
+
}
|
|
427
|
+
} catch (error) {
|
|
428
|
+
console.error('Failed to load diffs:', error);
|
|
429
|
+
// Reset all groups to empty on error
|
|
430
|
+
Object.keys(this.groups).forEach(groupKey => {
|
|
431
|
+
this.groups[groupKey].files = [];
|
|
432
|
+
this.groups[groupKey].count = 0;
|
|
433
|
+
});
|
|
434
|
+
} finally {
|
|
435
|
+
this.loading = false;
|
|
436
|
+
}
|
|
437
|
+
},
|
|
438
|
+
|
|
439
|
+
// Refresh all data
|
|
440
|
+
async refreshData() {
|
|
441
|
+
await Promise.all([
|
|
442
|
+
this.loadGitStatus(),
|
|
443
|
+
this.loadDiffs()
|
|
444
|
+
]);
|
|
445
|
+
},
|
|
446
|
+
|
|
447
|
+
// Toggle file expansion
|
|
448
|
+
toggleFile(groupKey, fileIndex) {
|
|
449
|
+
if (this.groups[groupKey] && this.groups[groupKey].files[fileIndex]) {
|
|
450
|
+
this.groups[groupKey].files[fileIndex].expanded = !this.groups[groupKey].files[fileIndex].expanded;
|
|
451
|
+
this.saveUIState();
|
|
452
|
+
}
|
|
453
|
+
},
|
|
454
|
+
|
|
455
|
+
// Expand all files across all groups
|
|
456
|
+
expandAll() {
|
|
457
|
+
Object.keys(this.groups).forEach(groupKey => {
|
|
458
|
+
this.groups[groupKey].files.forEach(file => {
|
|
459
|
+
file.expanded = true;
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
this.saveUIState();
|
|
463
|
+
},
|
|
464
|
+
|
|
465
|
+
// Collapse all files across all groups
|
|
466
|
+
collapseAll() {
|
|
467
|
+
Object.keys(this.groups).forEach(groupKey => {
|
|
468
|
+
this.groups[groupKey].files.forEach(file => {
|
|
469
|
+
file.expanded = false;
|
|
470
|
+
});
|
|
471
|
+
});
|
|
472
|
+
this.saveUIState();
|
|
473
|
+
},
|
|
474
|
+
|
|
475
|
+
// Navigate to previous file
|
|
476
|
+
navigateToPreviousFile(groupKey, fileIndex) {
|
|
477
|
+
const currentGlobalIndex = this.getGlobalFileIndex(groupKey, fileIndex);
|
|
478
|
+
|
|
479
|
+
if (currentGlobalIndex > 0) {
|
|
480
|
+
const previousFileId = `file-${currentGlobalIndex - 1}`;
|
|
481
|
+
document.getElementById(previousFileId)?.scrollIntoView({
|
|
482
|
+
behavior: 'smooth',
|
|
483
|
+
block: 'start'
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
},
|
|
487
|
+
|
|
488
|
+
// Navigate to next file
|
|
489
|
+
navigateToNextFile(groupKey, fileIndex) {
|
|
490
|
+
const totalFiles = this.getTotalVisibleFiles();
|
|
491
|
+
const currentGlobalIndex = this.getGlobalFileIndex(groupKey, fileIndex);
|
|
492
|
+
|
|
493
|
+
if (currentGlobalIndex < totalFiles - 1) {
|
|
494
|
+
const nextFileId = `file-${currentGlobalIndex + 1}`;
|
|
495
|
+
document.getElementById(nextFileId)?.scrollIntoView({
|
|
496
|
+
behavior: 'smooth',
|
|
497
|
+
block: 'start'
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
},
|
|
501
|
+
|
|
502
|
+
// Get total count of visible files across all visible groups
|
|
503
|
+
getTotalVisibleFiles() {
|
|
504
|
+
let total = 0;
|
|
505
|
+
for (const group of this.visibleGroups) {
|
|
506
|
+
if (group.visible) {
|
|
507
|
+
total += group.files.length;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return total;
|
|
511
|
+
},
|
|
512
|
+
|
|
513
|
+
// Get global index of a file across all groups (for navigation)
|
|
514
|
+
getGlobalFileIndex(targetGroupKey, targetFileIndex) {
|
|
515
|
+
let globalIndex = 0;
|
|
516
|
+
|
|
517
|
+
for (const group of this.visibleGroups) {
|
|
518
|
+
if (!group.visible) continue;
|
|
519
|
+
|
|
520
|
+
if (group.key === targetGroupKey) {
|
|
521
|
+
return globalIndex + targetFileIndex;
|
|
522
|
+
}
|
|
523
|
+
globalIndex += group.files.length;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
return -1;
|
|
527
|
+
},
|
|
528
|
+
|
|
529
|
+
// Get unique file ID for DOM (same as global index for consistency)
|
|
530
|
+
getFileId(targetGroupKey, targetFileIndex) {
|
|
531
|
+
return this.getGlobalFileIndex(targetGroupKey, targetFileIndex);
|
|
532
|
+
},
|
|
533
|
+
|
|
534
|
+
// Context expansion methods
|
|
535
|
+
async expandContext(filePath, hunkIndex, direction, contextLines = 10) {
|
|
536
|
+
// Find the target file to determine which hunk to actually expand
|
|
537
|
+
let targetFile = null;
|
|
538
|
+
for (const groupKey of Object.keys(this.groups)) {
|
|
539
|
+
const file = this.groups[groupKey].files.find(f => f.path === filePath);
|
|
540
|
+
if (file) {
|
|
541
|
+
targetFile = file;
|
|
542
|
+
break;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (!targetFile || !targetFile.hunks) {
|
|
547
|
+
console.error('Target file not found');
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
let targetHunkIndex, targetDirection;
|
|
552
|
+
|
|
553
|
+
if (direction === 'before') {
|
|
554
|
+
// "Expand up" - expand the "before" context of the CURRENT hunk
|
|
555
|
+
targetHunkIndex = hunkIndex;
|
|
556
|
+
targetDirection = 'before';
|
|
557
|
+
} else { // direction === 'after'
|
|
558
|
+
// "Expand down" - expand the "after" context of the PREVIOUS hunk
|
|
559
|
+
targetHunkIndex = hunkIndex - 1;
|
|
560
|
+
targetDirection = 'after';
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Validate target hunk exists
|
|
564
|
+
if (!targetFile.hunks[targetHunkIndex]) {
|
|
565
|
+
console.error('Target hunk not found');
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Initialize context state for target hunk
|
|
570
|
+
if (!this.contextExpansions[filePath]) {
|
|
571
|
+
this.contextExpansions[filePath] = {};
|
|
572
|
+
}
|
|
573
|
+
if (!this.contextExpansions[filePath][targetHunkIndex]) {
|
|
574
|
+
this.contextExpansions[filePath][targetHunkIndex] = { beforeExpanded: 0, afterExpanded: 0 };
|
|
575
|
+
}
|
|
576
|
+
if (!this.contextLoading[filePath]) {
|
|
577
|
+
this.contextLoading[filePath] = {};
|
|
578
|
+
}
|
|
579
|
+
if (!this.contextLoading[filePath][targetHunkIndex]) {
|
|
580
|
+
this.contextLoading[filePath][targetHunkIndex] = { before: false, after: false };
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Set loading state for target hunk
|
|
584
|
+
this.contextLoading[filePath][targetHunkIndex][targetDirection] = true;
|
|
585
|
+
|
|
586
|
+
try {
|
|
587
|
+
const targetHunk = targetFile.hunks[targetHunkIndex];
|
|
588
|
+
let startLine, endLine;
|
|
589
|
+
|
|
590
|
+
if (targetDirection === 'before') {
|
|
591
|
+
// Get lines before the target hunk
|
|
592
|
+
endLine = targetHunk.new_start - 1;
|
|
593
|
+
startLine = Math.max(1, endLine - contextLines + 1);
|
|
594
|
+
} else { // targetDirection === 'after'
|
|
595
|
+
// Get lines after the target hunk
|
|
596
|
+
startLine = targetHunk.new_start + targetHunk.new_count;
|
|
597
|
+
endLine = startLine + contextLines - 1;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const response = await this.fetchFileLines(filePath, startLine, endLine);
|
|
601
|
+
if (response && response.status === 'ok' && response.lines) {
|
|
602
|
+
this.insertContextLines(filePath, targetHunkIndex, targetDirection, response.lines, startLine);
|
|
603
|
+
this.contextExpansions[filePath][targetHunkIndex][targetDirection + 'Expanded'] += response.lines.length;
|
|
604
|
+
// Save state after successful expansion
|
|
605
|
+
this.saveUIState();
|
|
606
|
+
}
|
|
607
|
+
} catch (error) {
|
|
608
|
+
console.error('Failed to expand context:', error);
|
|
609
|
+
} finally {
|
|
610
|
+
// Clear loading state for target hunk
|
|
611
|
+
this.contextLoading[filePath][targetHunkIndex][targetDirection] = false;
|
|
612
|
+
}
|
|
613
|
+
},
|
|
614
|
+
|
|
615
|
+
async fetchFileLines(filePath, startLine, endLine) {
|
|
616
|
+
const params = new URLSearchParams();
|
|
617
|
+
params.set('file_path', filePath);
|
|
618
|
+
params.set('start_line', startLine.toString());
|
|
619
|
+
params.set('end_line', endLine.toString());
|
|
620
|
+
|
|
621
|
+
const url = `/api/file/lines?${params.toString()}`;
|
|
622
|
+
const response = await fetch(url);
|
|
623
|
+
return await response.json();
|
|
624
|
+
},
|
|
625
|
+
|
|
626
|
+
insertContextLines(filePath, hunkIndex, direction, lines, startLineNum) {
|
|
627
|
+
// Find the target file and hunk
|
|
628
|
+
let targetFile = null;
|
|
629
|
+
for (const groupKey of Object.keys(this.groups)) {
|
|
630
|
+
const file = this.groups[groupKey].files.find(f => f.path === filePath);
|
|
631
|
+
if (file) {
|
|
632
|
+
targetFile = file;
|
|
633
|
+
break;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
if (!targetFile || !targetFile.hunks || !targetFile.hunks[hunkIndex]) {
|
|
638
|
+
console.warn('Target file or hunk not found for context insertion');
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const currentHunk = targetFile.hunks[hunkIndex];
|
|
643
|
+
|
|
644
|
+
// Convert the raw lines into diff line format
|
|
645
|
+
let newDiffLines;
|
|
646
|
+
|
|
647
|
+
if (direction === 'after') {
|
|
648
|
+
// Special case: expanding down (extending previous hunk's after context)
|
|
649
|
+
// Both sides continue sequentially from where the hunk ended
|
|
650
|
+
const leftStartLineNum = currentHunk.old_start + currentHunk.old_count;
|
|
651
|
+
const rightStartLineNum = currentHunk.new_start + currentHunk.new_count;
|
|
652
|
+
|
|
653
|
+
newDiffLines = lines.map((content, index) => {
|
|
654
|
+
return {
|
|
655
|
+
type: 'context',
|
|
656
|
+
left: {
|
|
657
|
+
content,
|
|
658
|
+
line_num: leftStartLineNum + index
|
|
659
|
+
},
|
|
660
|
+
right: {
|
|
661
|
+
content,
|
|
662
|
+
line_num: rightStartLineNum + index
|
|
663
|
+
}
|
|
664
|
+
};
|
|
665
|
+
});
|
|
666
|
+
} else {
|
|
667
|
+
// Expanding before: calculate separate line numbers for left and right sides
|
|
668
|
+
const leftStartLineNum = currentHunk.old_start - lines.length;
|
|
669
|
+
const rightStartLineNum = currentHunk.new_start - lines.length;
|
|
670
|
+
|
|
671
|
+
newDiffLines = lines.map((content, index) => {
|
|
672
|
+
return {
|
|
673
|
+
type: 'context',
|
|
674
|
+
left: {
|
|
675
|
+
content,
|
|
676
|
+
line_num: leftStartLineNum + index
|
|
677
|
+
},
|
|
678
|
+
right: {
|
|
679
|
+
content,
|
|
680
|
+
line_num: rightStartLineNum + index
|
|
681
|
+
}
|
|
682
|
+
};
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
if (direction === 'before') {
|
|
687
|
+
// Insert at the beginning of the hunk
|
|
688
|
+
currentHunk.lines = [...newDiffLines, ...currentHunk.lines];
|
|
689
|
+
// Update hunk start positions
|
|
690
|
+
currentHunk.old_start = Math.max(1, currentHunk.old_start - lines.length);
|
|
691
|
+
currentHunk.new_start = Math.max(1, currentHunk.new_start - lines.length);
|
|
692
|
+
} else { // direction === 'after'
|
|
693
|
+
// Insert at the end of the hunk
|
|
694
|
+
currentHunk.lines = [...currentHunk.lines, ...newDiffLines];
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// Update hunk counts
|
|
698
|
+
currentHunk.old_count += lines.length;
|
|
699
|
+
currentHunk.new_count += lines.length;
|
|
700
|
+
|
|
701
|
+
// Check if we need to merge hunks after expansion
|
|
702
|
+
if (direction === 'after') {
|
|
703
|
+
this.checkAndMergeHunks(targetFile, hunkIndex);
|
|
704
|
+
} else if (direction === 'before') {
|
|
705
|
+
// When expanding up, check if we can merge with the previous hunk
|
|
706
|
+
this.checkAndMergeHunksReverse(targetFile, hunkIndex);
|
|
707
|
+
}
|
|
708
|
+
},
|
|
709
|
+
|
|
710
|
+
checkAndMergeHunks(targetFile, currentHunkIndex) {
|
|
711
|
+
const nextHunkIndex = currentHunkIndex + 1;
|
|
712
|
+
|
|
713
|
+
// Check if there's a next hunk to potentially merge with
|
|
714
|
+
if (nextHunkIndex >= targetFile.hunks.length) {
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const currentHunk = targetFile.hunks[currentHunkIndex];
|
|
719
|
+
const nextHunk = targetFile.hunks[nextHunkIndex];
|
|
720
|
+
|
|
721
|
+
console.log('🟡 Current hunk:', currentHunk);
|
|
722
|
+
console.log('🟡 Next hunk:', nextHunk);
|
|
723
|
+
|
|
724
|
+
// Calculate where current hunk ends and next hunk starts
|
|
725
|
+
const currentOldEnd = currentHunk.old_start + currentHunk.old_count - 1; // Last line of current hunk
|
|
726
|
+
const currentNewEnd = currentHunk.new_start + currentHunk.new_count - 1; // Last line of current hunk
|
|
727
|
+
|
|
728
|
+
// Check if hunks are now adjacent or overlapping
|
|
729
|
+
const oldGap = nextHunk.old_start - currentOldEnd - 1; // Gap between last line of current and first line of next
|
|
730
|
+
const newGap = nextHunk.new_start - currentNewEnd - 1;
|
|
731
|
+
|
|
732
|
+
console.log('🟡 Old gap, new gap:', oldGap, newGap);
|
|
733
|
+
|
|
734
|
+
// Merge if hunks are adjacent or overlapping (gap <= 1, meaning at most 1 line between them)
|
|
735
|
+
if (oldGap <= 1 && newGap <= 1) {
|
|
736
|
+
// If there's a gap of 1 line, add context lines to bridge it
|
|
737
|
+
if (oldGap === 1 && newGap === 1) {
|
|
738
|
+
// Add the single bridging line as context
|
|
739
|
+
const bridgeLine = {
|
|
740
|
+
type: 'context',
|
|
741
|
+
left: {
|
|
742
|
+
content: '', // Empty placeholder for the bridging line
|
|
743
|
+
line_num: currentOldEnd + 1
|
|
744
|
+
},
|
|
745
|
+
right: {
|
|
746
|
+
content: '',
|
|
747
|
+
line_num: currentNewEnd + 1
|
|
748
|
+
}
|
|
749
|
+
};
|
|
750
|
+
currentHunk.lines.push(bridgeLine);
|
|
751
|
+
currentHunk.old_count += 1;
|
|
752
|
+
currentHunk.new_count += 1;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// Merge the hunks with deduplication
|
|
756
|
+
// Find overlapping lines by comparing line numbers
|
|
757
|
+
const currentLastLeftLine = currentHunk.lines[currentHunk.lines.length - 1]?.left?.line_num || 0;
|
|
758
|
+
const currentLastRightLine = currentHunk.lines[currentHunk.lines.length - 1]?.right?.line_num || 0;
|
|
759
|
+
|
|
760
|
+
// Filter out duplicate lines
|
|
761
|
+
const uniqueNextLines = nextHunk.lines.filter(line => {
|
|
762
|
+
const leftLineNum = line.left?.line_num || 0;
|
|
763
|
+
const rightLineNum = line.right?.line_num || 0;
|
|
764
|
+
return leftLineNum > currentLastLeftLine || rightLineNum > currentLastRightLine;
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
currentHunk.lines = [...currentHunk.lines, ...uniqueNextLines];
|
|
768
|
+
currentHunk.old_count = nextHunk.old_start + nextHunk.old_count - currentHunk.old_start;
|
|
769
|
+
currentHunk.new_count = nextHunk.new_start + nextHunk.new_count - currentHunk.new_start;
|
|
770
|
+
|
|
771
|
+
// Update section header to combine both if they exist
|
|
772
|
+
if (currentHunk.section_header && nextHunk.section_header) {
|
|
773
|
+
currentHunk.section_header = `${currentHunk.section_header} / ${nextHunk.section_header}`;
|
|
774
|
+
} else if (nextHunk.section_header) {
|
|
775
|
+
currentHunk.section_header = nextHunk.section_header;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// Remove the next hunk from the array
|
|
779
|
+
targetFile.hunks.splice(nextHunkIndex, 1);
|
|
780
|
+
}
|
|
781
|
+
},
|
|
782
|
+
|
|
783
|
+
checkAndMergeHunksReverse(targetFile, currentHunkIndex) {
|
|
784
|
+
const previousHunkIndex = currentHunkIndex - 1;
|
|
785
|
+
|
|
786
|
+
// Check if there's a previous hunk to potentially merge with
|
|
787
|
+
if (previousHunkIndex < 0) {
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
const currentHunk = targetFile.hunks[currentHunkIndex];
|
|
792
|
+
const previousHunk = targetFile.hunks[previousHunkIndex];
|
|
793
|
+
|
|
794
|
+
// Calculate where previous hunk ends and current hunk starts
|
|
795
|
+
const previousOldEnd = previousHunk.old_start + previousHunk.old_count - 1;
|
|
796
|
+
const previousNewEnd = previousHunk.new_start + previousHunk.new_count - 1;
|
|
797
|
+
|
|
798
|
+
// Check if hunks are now adjacent or overlapping
|
|
799
|
+
const oldGap = currentHunk.old_start - previousOldEnd - 1;
|
|
800
|
+
const newGap = currentHunk.new_start - previousNewEnd - 1;
|
|
801
|
+
|
|
802
|
+
// Merge if hunks are adjacent or overlapping (gap <= 1, meaning at most 1 line between them)
|
|
803
|
+
if (oldGap <= 1 && newGap <= 1) {
|
|
804
|
+
// If there's a gap of 1 line, add context lines to bridge it
|
|
805
|
+
if (oldGap === 1 && newGap === 1) {
|
|
806
|
+
// Add the single bridging line as context
|
|
807
|
+
const bridgeLine = {
|
|
808
|
+
type: 'context',
|
|
809
|
+
left: {
|
|
810
|
+
content: '', // Empty placeholder for the bridging line
|
|
811
|
+
line_num: previousOldEnd + 1
|
|
812
|
+
},
|
|
813
|
+
right: {
|
|
814
|
+
content: '',
|
|
815
|
+
line_num: previousNewEnd + 1
|
|
816
|
+
}
|
|
817
|
+
};
|
|
818
|
+
previousHunk.lines.push(bridgeLine);
|
|
819
|
+
previousHunk.old_count += 1;
|
|
820
|
+
previousHunk.new_count += 1;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// Find overlapping lines by comparing line numbers
|
|
824
|
+
const previousLastLeftLine = previousHunk.lines[previousHunk.lines.length - 1]?.left?.line_num || 0;
|
|
825
|
+
const previousLastRightLine = previousHunk.lines[previousHunk.lines.length - 1]?.right?.line_num || 0;
|
|
826
|
+
|
|
827
|
+
// Filter out duplicate lines
|
|
828
|
+
const uniqueCurrentLines = currentHunk.lines.filter(line => {
|
|
829
|
+
const leftLineNum = line.left?.line_num || 0;
|
|
830
|
+
const rightLineNum = line.right?.line_num || 0;
|
|
831
|
+
return leftLineNum > previousLastLeftLine || rightLineNum > previousLastRightLine;
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
previousHunk.lines = [...previousHunk.lines, ...uniqueCurrentLines];
|
|
835
|
+
previousHunk.old_count = currentHunk.old_start + currentHunk.old_count - previousHunk.old_start;
|
|
836
|
+
previousHunk.new_count = currentHunk.new_start + currentHunk.new_count - previousHunk.new_start;
|
|
837
|
+
|
|
838
|
+
// Update section header to combine both if they exist
|
|
839
|
+
if (previousHunk.section_header && currentHunk.section_header) {
|
|
840
|
+
previousHunk.section_header = `${previousHunk.section_header} / ${currentHunk.section_header}`;
|
|
841
|
+
} else if (currentHunk.section_header) {
|
|
842
|
+
previousHunk.section_header = currentHunk.section_header;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// Remove the current hunk from the array
|
|
846
|
+
targetFile.hunks.splice(currentHunkIndex, 1);
|
|
847
|
+
}
|
|
848
|
+
},
|
|
849
|
+
|
|
850
|
+
// Check if context can be expanded for a hunk
|
|
851
|
+
canExpandContext(filePath, hunkIndex, direction) {
|
|
852
|
+
// Find the file to get all hunks
|
|
853
|
+
let targetFile = null;
|
|
854
|
+
for (const groupKey of Object.keys(this.groups)) {
|
|
855
|
+
const file = this.groups[groupKey].files.find(f => f.path === filePath);
|
|
856
|
+
if (file) {
|
|
857
|
+
targetFile = file;
|
|
858
|
+
break;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
if (!targetFile || !targetFile.hunks || !targetFile.hunks[hunkIndex]) {
|
|
863
|
+
return false;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
const hunks = targetFile.hunks;
|
|
867
|
+
|
|
868
|
+
if (direction === 'before') {
|
|
869
|
+
// "Expand up" button visibility:
|
|
870
|
+
if (hunkIndex === 0) {
|
|
871
|
+
// First hunk: show if it doesn't start at line 1
|
|
872
|
+
return hunks[0].old_start > 1;
|
|
873
|
+
} else {
|
|
874
|
+
// All other hunks: always show (expands before context of current hunk)
|
|
875
|
+
return true;
|
|
876
|
+
}
|
|
877
|
+
} else if (direction === 'after') {
|
|
878
|
+
// "Expand down" button visibility:
|
|
879
|
+
if (hunkIndex === 0) {
|
|
880
|
+
// First hunk: never show "Expand down"
|
|
881
|
+
return false;
|
|
882
|
+
} else {
|
|
883
|
+
// All other hunks: always show (expands after context of previous hunk)
|
|
884
|
+
return true;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
return false;
|
|
889
|
+
},
|
|
890
|
+
|
|
891
|
+
// Check if context is currently loading
|
|
892
|
+
isContextLoading(filePath, hunkIndex, direction) {
|
|
893
|
+
// Return false if loading state is not set up yet (undefined means not loading)
|
|
894
|
+
return !!(this.contextLoading[filePath] &&
|
|
895
|
+
this.contextLoading[filePath][hunkIndex] &&
|
|
896
|
+
this.contextLoading[filePath][hunkIndex][direction]);
|
|
897
|
+
},
|
|
898
|
+
|
|
899
|
+
// Bottom expand functionality
|
|
900
|
+
shouldShowBottomExpand(file) {
|
|
901
|
+
// Only show if file has line_count and hunks
|
|
902
|
+
if (!file.line_count || !file.hunks || file.hunks.length === 0) {
|
|
903
|
+
return false;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// Find the last line number from the last hunk
|
|
907
|
+
const lastHunk = file.hunks[file.hunks.length - 1];
|
|
908
|
+
if (!lastHunk.lines || lastHunk.lines.length === 0) {
|
|
909
|
+
return false;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
let lastRightLineNum = 0;
|
|
913
|
+
// Look through lines in reverse to find the last line number
|
|
914
|
+
for (let i = lastHunk.lines.length - 1; i >= 0; i--) {
|
|
915
|
+
const line = lastHunk.lines[i];
|
|
916
|
+
if (line.type === 'context' || line.type === 'change') {
|
|
917
|
+
if (line.right && line.right.line_num) {
|
|
918
|
+
lastRightLineNum = line.right.line_num;
|
|
919
|
+
break;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// Show button if file has more lines beyond the last shown line
|
|
925
|
+
return file.line_count > lastRightLineNum;
|
|
926
|
+
},
|
|
927
|
+
|
|
928
|
+
getRemainingLines(file) {
|
|
929
|
+
if (!file.line_count || !file.hunks || file.hunks.length === 0) {
|
|
930
|
+
return 0;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// Find the last line number from the last hunk
|
|
934
|
+
const lastHunk = file.hunks[file.hunks.length - 1];
|
|
935
|
+
if (!lastHunk.lines || lastHunk.lines.length === 0) {
|
|
936
|
+
return 0;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
let lastRightLineNum = 0;
|
|
940
|
+
// Look through lines in reverse to find the last line number
|
|
941
|
+
for (let i = lastHunk.lines.length - 1; i >= 0; i--) {
|
|
942
|
+
const line = lastHunk.lines[i];
|
|
943
|
+
if (line.type === 'context' || line.type === 'change') {
|
|
944
|
+
if (line.right && line.right.line_num) {
|
|
945
|
+
lastRightLineNum = line.right.line_num;
|
|
946
|
+
break;
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
return file.line_count - lastRightLineNum;
|
|
952
|
+
},
|
|
953
|
+
|
|
954
|
+
async expandBottomContext(filePath, contextLines = 10) {
|
|
955
|
+
// Find the target file
|
|
956
|
+
let targetFile = null;
|
|
957
|
+
for (const groupKey of Object.keys(this.groups)) {
|
|
958
|
+
const file = this.groups[groupKey].files.find(f => f.path === filePath);
|
|
959
|
+
if (file) {
|
|
960
|
+
targetFile = file;
|
|
961
|
+
break;
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
if (!targetFile || !targetFile.hunks || targetFile.hunks.length === 0) {
|
|
966
|
+
console.error('Target file not found or has no hunks');
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// Set loading state
|
|
971
|
+
this.setBottomContextLoading(filePath, true);
|
|
972
|
+
|
|
973
|
+
try {
|
|
974
|
+
// For bottom expand, we need to expand after the last hunk
|
|
975
|
+
// But expandContext with direction='after' targets hunkIndex-1
|
|
976
|
+
// So we need to pass lastHunkIndex+1 to target the last hunk
|
|
977
|
+
const lastHunkIndex = targetFile.hunks.length - 1;
|
|
978
|
+
await this.expandContext(filePath, lastHunkIndex + 1, 'after', contextLines);
|
|
979
|
+
} catch (error) {
|
|
980
|
+
console.error('Failed to expand bottom context:', error);
|
|
981
|
+
} finally {
|
|
982
|
+
// Clear loading state
|
|
983
|
+
this.setBottomContextLoading(filePath, false);
|
|
984
|
+
}
|
|
985
|
+
},
|
|
986
|
+
|
|
987
|
+
isBottomContextLoading(filePath) {
|
|
988
|
+
return !!(this.bottomContextLoading && this.bottomContextLoading[filePath]);
|
|
989
|
+
},
|
|
990
|
+
|
|
991
|
+
setBottomContextLoading(filePath, loading) {
|
|
992
|
+
if (!this.bottomContextLoading) {
|
|
993
|
+
this.bottomContextLoading = {};
|
|
994
|
+
}
|
|
995
|
+
if (loading) {
|
|
996
|
+
this.bottomContextLoading[filePath] = true;
|
|
997
|
+
} else {
|
|
998
|
+
delete this.bottomContextLoading[filePath];
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
};
|
|
1002
|
+
}
|