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.
@@ -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
+ }