@fresh-editor/fresh-editor 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/.gitignore +2 -0
  2. package/LICENSE +117 -0
  3. package/README.md +54 -0
  4. package/binary-install.js +212 -0
  5. package/binary.js +126 -0
  6. package/install.js +4 -0
  7. package/npm-shrinkwrap.json +900 -0
  8. package/package.json +100 -0
  9. package/plugins/README.md +121 -0
  10. package/plugins/clangd_support.md +20 -0
  11. package/plugins/clangd_support.ts +323 -0
  12. package/plugins/color_highlighter.ts +302 -0
  13. package/plugins/diagnostics_panel.ts +308 -0
  14. package/plugins/examples/README.md +245 -0
  15. package/plugins/examples/async_demo.ts +165 -0
  16. package/plugins/examples/bookmarks.ts +329 -0
  17. package/plugins/examples/buffer_query_demo.ts +110 -0
  18. package/plugins/examples/git_grep.ts +262 -0
  19. package/plugins/examples/hello_world.ts +93 -0
  20. package/plugins/examples/virtual_buffer_demo.ts +116 -0
  21. package/plugins/find_references.ts +357 -0
  22. package/plugins/git_find_file.ts +298 -0
  23. package/plugins/git_grep.ts +188 -0
  24. package/plugins/git_log.ts +1283 -0
  25. package/plugins/lib/fresh.d.ts +849 -0
  26. package/plugins/lib/index.ts +24 -0
  27. package/plugins/lib/navigation-controller.ts +214 -0
  28. package/plugins/lib/panel-manager.ts +218 -0
  29. package/plugins/lib/types.ts +72 -0
  30. package/plugins/lib/virtual-buffer-factory.ts +158 -0
  31. package/plugins/manual_help.ts +243 -0
  32. package/plugins/markdown_compose.ts +1207 -0
  33. package/plugins/merge_conflict.ts +1811 -0
  34. package/plugins/path_complete.ts +163 -0
  35. package/plugins/search_replace.ts +481 -0
  36. package/plugins/todo_highlighter.ts +204 -0
  37. package/plugins/welcome.ts +74 -0
  38. package/run-fresh.js +4 -0
@@ -0,0 +1,1811 @@
1
+ /// <reference path="../types/fresh.d.ts" />
2
+
3
+ /**
4
+ * 3-Way Merge Conflict Resolution Plugin
5
+ *
6
+ * Provides an interactive merge conflict resolution interface with:
7
+ * - Automatic detection of git conflict markers when files are opened
8
+ * - Multi-panel UI showing OURS, THEIRS, and editable RESULT
9
+ * - Keyboard navigation between conflicts
10
+ * - One-key resolution (accept ours, theirs, or both)
11
+ * - git-mediate style auto-resolution for trivial conflicts
12
+ * - Visual highlighting with intra-line diffing
13
+ *
14
+ * Architecture: Plugin-based implementation following the spec in docs/MERGE.md
15
+ */
16
+
17
+ // =============================================================================
18
+ // Types and Interfaces
19
+ // =============================================================================
20
+
21
+ interface ConflictBlock {
22
+ /** Index of this conflict (0-based) */
23
+ index: number;
24
+ /** Byte offset where the conflict starts (<<<<<<< marker) */
25
+ startOffset: number;
26
+ /** Byte offset where the conflict ends (after >>>>>>> marker) */
27
+ endOffset: number;
28
+ /** Content from "ours" side (our branch) */
29
+ ours: string;
30
+ /** Content from "base" (common ancestor) - may be empty if no diff3 */
31
+ base: string;
32
+ /** Content from "theirs" side (incoming changes) */
33
+ theirs: string;
34
+ /** Whether this conflict has been resolved */
35
+ resolved: boolean;
36
+ /** Resolution type if resolved */
37
+ resolution?: "ours" | "theirs" | "both" | "manual";
38
+ /** The resolved content (if resolved) */
39
+ resolvedContent?: string;
40
+ }
41
+
42
+ interface MergeState {
43
+ /** Whether merge mode is active */
44
+ isActive: boolean;
45
+ /** The original buffer ID (file with conflicts) */
46
+ sourceBufferId: number | null;
47
+ /** The original file path */
48
+ sourcePath: string | null;
49
+ /** Original file content (for abort) */
50
+ originalContent: string;
51
+ /** List of detected conflicts */
52
+ conflicts: ConflictBlock[];
53
+ /** Index of currently selected conflict */
54
+ selectedIndex: number;
55
+ /** The OURS panel buffer ID */
56
+ oursPanelId: number | null;
57
+ /** The THEIRS panel buffer ID */
58
+ theirsPanelId: number | null;
59
+ /** The RESULT panel buffer ID (editable) */
60
+ resultPanelId: number | null;
61
+ /** Split IDs for each panel */
62
+ oursSplitId: number | null;
63
+ theirsSplitId: number | null;
64
+ resultSplitId: number | null;
65
+ /** Content for OURS side */
66
+ oursContent: string;
67
+ /** Content for THEIRS side */
68
+ theirsContent: string;
69
+ /** Content for BASE side (common ancestor) */
70
+ baseContent: string;
71
+ /** Current result content */
72
+ resultContent: string;
73
+ }
74
+
75
+ // =============================================================================
76
+ // State Management
77
+ // =============================================================================
78
+
79
+ const mergeState: MergeState = {
80
+ isActive: false,
81
+ sourceBufferId: null,
82
+ sourcePath: null,
83
+ originalContent: "",
84
+ conflicts: [],
85
+ selectedIndex: 0,
86
+ oursPanelId: null,
87
+ theirsPanelId: null,
88
+ resultPanelId: null,
89
+ oursSplitId: null,
90
+ theirsSplitId: null,
91
+ resultSplitId: null,
92
+ oursContent: "",
93
+ theirsContent: "",
94
+ baseContent: "",
95
+ resultContent: "",
96
+ };
97
+
98
+ // =============================================================================
99
+ // Color Definitions
100
+ // =============================================================================
101
+
102
+ const colors = {
103
+ // Panel headers
104
+ oursHeader: [100, 200, 255] as [number, number, number], // Cyan for OURS
105
+ theirsHeader: [255, 180, 100] as [number, number, number], // Orange for THEIRS
106
+ resultHeader: [150, 255, 150] as [number, number, number], // Green for RESULT
107
+
108
+ // Conflict highlighting
109
+ conflictOurs: [50, 80, 100] as [number, number, number], // Blue-tinted background
110
+ conflictTheirs: [100, 70, 50] as [number, number, number], // Orange-tinted background
111
+ conflictBase: [70, 70, 70] as [number, number, number], // Gray for base
112
+
113
+ // Intra-line diff colors
114
+ diffAdd: [50, 100, 50] as [number, number, number], // Green for additions
115
+ diffDel: [100, 50, 50] as [number, number, number], // Red for deletions
116
+ diffMod: [50, 50, 100] as [number, number, number], // Blue for modifications
117
+
118
+ // Selection
119
+ selected: [80, 80, 120] as [number, number, number], // Selection highlight
120
+
121
+ // Buttons/actions
122
+ button: [100, 149, 237] as [number, number, number], // Cornflower blue
123
+ resolved: [100, 200, 100] as [number, number, number], // Green for resolved
124
+ unresolved: [200, 100, 100] as [number, number, number], // Red for unresolved
125
+ };
126
+
127
+ // =============================================================================
128
+ // Mode Definition
129
+ // =============================================================================
130
+
131
+ // Define merge-conflict mode with keybindings
132
+ // Inherits from "normal" so cursor movement (hjkl) works
133
+ // Uses ] and [ for conflict navigation to avoid overriding j/k
134
+ editor.defineMode(
135
+ "merge-conflict",
136
+ "normal", // inherit from normal mode for cursor movement
137
+ [
138
+ // Conflict navigation (use ] and [ to avoid overriding j/k cursor movement)
139
+ ["]", "merge_next_conflict"],
140
+ ["[", "merge_prev_conflict"],
141
+ // Also support n/p for navigation
142
+ ["n", "merge_next_conflict"],
143
+ ["p", "merge_prev_conflict"],
144
+
145
+ // Resolution actions
146
+ ["u", "merge_use_ours"], // Use ours
147
+ ["t", "merge_take_theirs"], // Take theirs
148
+ ["b", "merge_use_both"], // Use both
149
+
150
+ // Completion
151
+ ["s", "merge_save_and_exit"], // Save & exit
152
+ ["q", "merge_abort"], // Abort
153
+
154
+ // Help
155
+ ["?", "merge_show_help"],
156
+ ],
157
+ true // read-only for navigation panels
158
+ );
159
+
160
+ // Define merge-result mode for the editable RESULT panel
161
+ editor.defineMode(
162
+ "merge-result",
163
+ "normal", // inherit from normal mode for editing
164
+ [
165
+ // Navigation - use C-j/C-k to avoid conflicting with C-p (command palette)
166
+ ["C-j", "merge_next_conflict"],
167
+ ["C-k", "merge_prev_conflict"],
168
+
169
+ // Resolution shortcuts
170
+ ["C-u", "merge_use_ours"],
171
+ ["C-t", "merge_take_theirs"],
172
+ ["C-b", "merge_use_both"],
173
+
174
+ // Completion
175
+ ["C-s", "merge_save_and_exit"],
176
+ ["C-q", "merge_abort"],
177
+ ],
178
+ false // editable
179
+ );
180
+
181
+ // =============================================================================
182
+ // Conflict Detection and Parsing
183
+ // =============================================================================
184
+
185
+ /**
186
+ * Check if content contains git conflict markers
187
+ */
188
+ function hasConflictMarkers(content: string): boolean {
189
+ return content.includes("<<<<<<<") &&
190
+ content.includes("=======") &&
191
+ content.includes(">>>>>>>");
192
+ }
193
+
194
+ /**
195
+ * Parse conflict markers from file content
196
+ * Supports both 2-way (no base) and 3-way (with base via diff3) conflicts
197
+ */
198
+ function parseConflicts(content: string): ConflictBlock[] {
199
+ const conflicts: ConflictBlock[] = [];
200
+
201
+ // Regex to match conflict blocks
202
+ // Supports optional base section (||||||| marker)
203
+ // Key: use ^ anchors to ensure markers are at start of lines (multiline mode)
204
+ // Note: use \r?\n to handle both LF and CRLF line endings
205
+ const conflictRegex = /^<<<<<<<[^\r\n]*\r?\n([\s\S]*?)(?:^\|\|\|\|\|\|\|[^\r\n]*\r?\n([\s\S]*?))?^=======\r?\n([\s\S]*?)^>>>>>>>[^\r\n]*$/gm;
206
+
207
+ let match;
208
+ let index = 0;
209
+
210
+ while ((match = conflictRegex.exec(content)) !== null) {
211
+ const startOffset = match.index;
212
+ const endOffset = match.index + match[0].length;
213
+
214
+ conflicts.push({
215
+ index: index++,
216
+ startOffset,
217
+ endOffset,
218
+ ours: match[1] || "",
219
+ base: match[2] || "",
220
+ theirs: match[3] || "",
221
+ resolved: false,
222
+ });
223
+ }
224
+
225
+ return conflicts;
226
+ }
227
+
228
+ /**
229
+ * Extract non-conflict sections and build initial result content
230
+ */
231
+ function buildInitialResult(content: string, conflicts: ConflictBlock[]): string {
232
+ if (conflicts.length === 0) return content;
233
+
234
+ let result = "";
235
+ let lastEnd = 0;
236
+
237
+ for (const conflict of conflicts) {
238
+ // Add non-conflict text before this conflict
239
+ result += content.substring(lastEnd, conflict.startOffset);
240
+
241
+ // Add a placeholder for the conflict
242
+ result += `<<<CONFLICT_${conflict.index}>>>`;
243
+
244
+ lastEnd = conflict.endOffset;
245
+ }
246
+
247
+ // Add remaining text after last conflict
248
+ result += content.substring(lastEnd);
249
+
250
+ return result;
251
+ }
252
+
253
+ // =============================================================================
254
+ // Git Data Fetching
255
+ // =============================================================================
256
+
257
+ /**
258
+ * Fetch the base (common ancestor), ours, and theirs versions from git
259
+ */
260
+ async function fetchGitVersions(filePath: string): Promise<{
261
+ base: string;
262
+ ours: string;
263
+ theirs: string;
264
+ } | null> {
265
+ try {
266
+ // Get the directory of the file for running git commands
267
+ const fileDir = editor.pathDirname(filePath);
268
+
269
+ // Get the git repository root
270
+ const repoRootResult = await editor.spawnProcess("git", [
271
+ "rev-parse", "--show-toplevel"
272
+ ], fileDir);
273
+
274
+ if (repoRootResult.exit_code !== 0) {
275
+ editor.debug(`fetchGitVersions: failed to get repo root`);
276
+ return null;
277
+ }
278
+
279
+ const repoRoot = repoRootResult.stdout.trim();
280
+
281
+ // Compute the relative path from repo root to the file
282
+ // filePath is absolute, repoRoot is absolute
283
+ let relativePath = filePath;
284
+ if (filePath.startsWith(repoRoot + "/")) {
285
+ relativePath = filePath.substring(repoRoot.length + 1);
286
+ } else if (filePath.startsWith(repoRoot)) {
287
+ relativePath = filePath.substring(repoRoot.length);
288
+ if (relativePath.startsWith("/")) {
289
+ relativePath = relativePath.substring(1);
290
+ }
291
+ }
292
+
293
+ editor.debug(`fetchGitVersions: repoRoot=${repoRoot}, relativePath=${relativePath}`);
294
+
295
+ // Get OURS version (--ours or :2:)
296
+ const oursResult = await editor.spawnProcess("git", [
297
+ "show", `:2:${relativePath}`
298
+ ], fileDir);
299
+ editor.debug(`fetchGitVersions: ours exit_code=${oursResult.exit_code}, stdout length=${oursResult.stdout.length}`);
300
+
301
+ // Get THEIRS version (--theirs or :3:)
302
+ const theirsResult = await editor.spawnProcess("git", [
303
+ "show", `:3:${relativePath}`
304
+ ], fileDir);
305
+ editor.debug(`fetchGitVersions: theirs exit_code=${theirsResult.exit_code}, stdout length=${theirsResult.stdout.length}`);
306
+
307
+ // Get BASE version (common ancestor, :1:)
308
+ const baseResult = await editor.spawnProcess("git", [
309
+ "show", `:1:${relativePath}`
310
+ ], fileDir);
311
+ editor.debug(`fetchGitVersions: base exit_code=${baseResult.exit_code}, stdout length=${baseResult.stdout.length}`);
312
+
313
+ return {
314
+ base: baseResult.exit_code === 0 ? baseResult.stdout : "",
315
+ ours: oursResult.exit_code === 0 ? oursResult.stdout : "",
316
+ theirs: theirsResult.exit_code === 0 ? theirsResult.stdout : "",
317
+ };
318
+ } catch (e) {
319
+ editor.debug(`Failed to fetch git versions: ${e}`);
320
+ return null;
321
+ }
322
+ }
323
+
324
+ // =============================================================================
325
+ // Auto-Resolution (git-mediate style)
326
+ // =============================================================================
327
+
328
+ /**
329
+ * Attempt to auto-resolve trivial conflicts using git-mediate logic
330
+ * A conflict is trivially resolvable if only one side changed from base
331
+ */
332
+ function autoResolveConflicts(conflicts: ConflictBlock[]): void {
333
+ for (const conflict of conflicts) {
334
+ if (conflict.resolved) continue;
335
+
336
+ // If we have base content, check for trivial resolution
337
+ if (conflict.base) {
338
+ const oursChanged = conflict.ours.trim() !== conflict.base.trim();
339
+ const theirsChanged = conflict.theirs.trim() !== conflict.base.trim();
340
+
341
+ if (oursChanged && !theirsChanged) {
342
+ // Only ours changed - use ours
343
+ conflict.resolved = true;
344
+ conflict.resolution = "ours";
345
+ conflict.resolvedContent = conflict.ours;
346
+ editor.debug(`Auto-resolved conflict ${conflict.index}: using OURS (theirs unchanged)`);
347
+ } else if (!oursChanged && theirsChanged) {
348
+ // Only theirs changed - use theirs
349
+ conflict.resolved = true;
350
+ conflict.resolution = "theirs";
351
+ conflict.resolvedContent = conflict.theirs;
352
+ editor.debug(`Auto-resolved conflict ${conflict.index}: using THEIRS (ours unchanged)`);
353
+ } else if (!oursChanged && !theirsChanged) {
354
+ // Neither changed (identical) - use either
355
+ conflict.resolved = true;
356
+ conflict.resolution = "ours";
357
+ conflict.resolvedContent = conflict.ours;
358
+ editor.debug(`Auto-resolved conflict ${conflict.index}: both identical to base`);
359
+ }
360
+ // If both changed differently, leave unresolved
361
+ }
362
+
363
+ // Check if ours and theirs are identical
364
+ if (!conflict.resolved && conflict.ours.trim() === conflict.theirs.trim()) {
365
+ conflict.resolved = true;
366
+ conflict.resolution = "ours";
367
+ conflict.resolvedContent = conflict.ours;
368
+ editor.debug(`Auto-resolved conflict ${conflict.index}: ours and theirs identical`);
369
+ }
370
+ }
371
+ }
372
+
373
+ // =============================================================================
374
+ // Word-Level Diff
375
+ // =============================================================================
376
+
377
+ /**
378
+ * Simple word-level diff for intra-line highlighting
379
+ */
380
+ function computeWordDiff(a: string, b: string): Array<{
381
+ type: "same" | "add" | "del" | "mod";
382
+ aStart: number;
383
+ aEnd: number;
384
+ bStart: number;
385
+ bEnd: number;
386
+ }> {
387
+ // Split into words (preserving whitespace positions)
388
+ const aWords = a.split(/(\s+)/);
389
+ const bWords = b.split(/(\s+)/);
390
+
391
+ const diffs: Array<{
392
+ type: "same" | "add" | "del" | "mod";
393
+ aStart: number;
394
+ aEnd: number;
395
+ bStart: number;
396
+ bEnd: number;
397
+ }> = [];
398
+
399
+ let aPos = 0;
400
+ let bPos = 0;
401
+ let aIdx = 0;
402
+ let bIdx = 0;
403
+
404
+ // Simple LCS-based diff (for short texts)
405
+ while (aIdx < aWords.length || bIdx < bWords.length) {
406
+ if (aIdx >= aWords.length) {
407
+ // Rest of b is additions
408
+ const bWord = bWords[bIdx];
409
+ diffs.push({
410
+ type: "add",
411
+ aStart: aPos,
412
+ aEnd: aPos,
413
+ bStart: bPos,
414
+ bEnd: bPos + bWord.length,
415
+ });
416
+ bPos += bWord.length;
417
+ bIdx++;
418
+ } else if (bIdx >= bWords.length) {
419
+ // Rest of a is deletions
420
+ const aWord = aWords[aIdx];
421
+ diffs.push({
422
+ type: "del",
423
+ aStart: aPos,
424
+ aEnd: aPos + aWord.length,
425
+ bStart: bPos,
426
+ bEnd: bPos,
427
+ });
428
+ aPos += aWord.length;
429
+ aIdx++;
430
+ } else if (aWords[aIdx] === bWords[bIdx]) {
431
+ // Same
432
+ const word = aWords[aIdx];
433
+ diffs.push({
434
+ type: "same",
435
+ aStart: aPos,
436
+ aEnd: aPos + word.length,
437
+ bStart: bPos,
438
+ bEnd: bPos + word.length,
439
+ });
440
+ aPos += word.length;
441
+ bPos += word.length;
442
+ aIdx++;
443
+ bIdx++;
444
+ } else {
445
+ // Different - mark as modification
446
+ const aWord = aWords[aIdx];
447
+ const bWord = bWords[bIdx];
448
+ diffs.push({
449
+ type: "mod",
450
+ aStart: aPos,
451
+ aEnd: aPos + aWord.length,
452
+ bStart: bPos,
453
+ bEnd: bPos + bWord.length,
454
+ });
455
+ aPos += aWord.length;
456
+ bPos += bWord.length;
457
+ aIdx++;
458
+ bIdx++;
459
+ }
460
+ }
461
+
462
+ return diffs;
463
+ }
464
+
465
+ // =============================================================================
466
+ // View Rendering - Full File Content (JetBrains-style)
467
+ // =============================================================================
468
+
469
+ /**
470
+ * Build entries showing the full file content for OURS or THEIRS
471
+ * This displays the complete file from git, highlighting conflict regions
472
+ */
473
+ function buildFullFileEntries(side: "ours" | "theirs"): TextPropertyEntry[] {
474
+ const entries: TextPropertyEntry[] = [];
475
+ const content = side === "ours" ? mergeState.oursContent : mergeState.theirsContent;
476
+
477
+ // If we don't have the git version, fall back to showing conflict regions only
478
+ if (!content) {
479
+ entries.push({
480
+ text: `(Git version not available - showing conflict regions only)\n\n`,
481
+ properties: { type: "warning" },
482
+ });
483
+
484
+ // Show conflict regions from parsed conflicts
485
+ for (const conflict of mergeState.conflicts) {
486
+ const conflictContent = side === "ours" ? conflict.ours : conflict.theirs;
487
+ const isSelected = conflict.index === mergeState.selectedIndex;
488
+
489
+ entries.push({
490
+ text: `--- Conflict ${conflict.index + 1} ---\n`,
491
+ properties: {
492
+ type: "conflict-header",
493
+ conflictIndex: conflict.index,
494
+ selected: isSelected,
495
+ },
496
+ });
497
+
498
+ entries.push({
499
+ text: (conflictContent || "(empty)") + "\n",
500
+ properties: {
501
+ type: "conflict-content",
502
+ conflictIndex: conflict.index,
503
+ side: side,
504
+ },
505
+ });
506
+ }
507
+ return entries;
508
+ }
509
+
510
+ // Show full file content with conflict regions highlighted
511
+ // The content from git is the clean version without markers
512
+ const lines = content.split("\n");
513
+ for (let i = 0; i < lines.length; i++) {
514
+ const line = lines[i];
515
+ // Check if this line is in a conflict region
516
+ const inConflict = isLineInConflict(i, side);
517
+
518
+ entries.push({
519
+ text: line + (i < lines.length - 1 ? "\n" : ""),
520
+ properties: {
521
+ type: inConflict ? "conflict-line" : "normal-line",
522
+ lineNumber: i + 1,
523
+ side: side,
524
+ ...(inConflict ? { conflictIndex: getConflictIndexForLine(i, side) } : {}),
525
+ },
526
+ });
527
+ }
528
+
529
+ return entries;
530
+ }
531
+
532
+ /**
533
+ * Check if a line number falls within a conflict region
534
+ */
535
+ function isLineInConflict(_lineNumber: number, _side: "ours" | "theirs"): boolean {
536
+ // For now, we don't have line mapping from git versions to original file
537
+ // This would require proper diff/alignment between versions
538
+ // TODO: Implement proper line-to-conflict mapping
539
+ return false;
540
+ }
541
+
542
+ /**
543
+ * Get the conflict index for a line number
544
+ */
545
+ function getConflictIndexForLine(_lineNumber: number, _side: "ours" | "theirs"): number {
546
+ return 0;
547
+ }
548
+
549
+ /**
550
+ * Build entries showing the merged result content
551
+ * This shows the file with resolved/unresolved conflict regions
552
+ */
553
+ function buildResultFileEntries(): TextPropertyEntry[] {
554
+ const entries: TextPropertyEntry[] = [];
555
+
556
+ // Build the result by combining non-conflict regions with resolved conflicts
557
+ const originalContent = mergeState.originalContent;
558
+ if (!originalContent) {
559
+ entries.push({
560
+ text: "(No content available)\n",
561
+ properties: { type: "error" },
562
+ });
563
+ return entries;
564
+ }
565
+
566
+ // Parse the original content and replace conflict regions with resolutions
567
+ let result = originalContent;
568
+
569
+ // Process conflicts in reverse order to maintain correct positions
570
+ const sortedConflicts = [...mergeState.conflicts].sort((a, b) => b.startOffset - a.startOffset);
571
+
572
+ for (const conflict of sortedConflicts) {
573
+ let replacement: string;
574
+
575
+ if (conflict.resolved && conflict.resolvedContent !== undefined) {
576
+ replacement = conflict.resolvedContent;
577
+ } else {
578
+ // Show unresolved conflict with markers
579
+ replacement = `<<<<<<< OURS\n${conflict.ours || ""}\n=======\n${conflict.theirs || ""}\n>>>>>>> THEIRS`;
580
+ }
581
+
582
+ // Replace the conflict region in the result
583
+ const before = result.substring(0, conflict.startOffset);
584
+ const after = result.substring(conflict.endOffset);
585
+ result = before + replacement + after;
586
+ }
587
+
588
+ // Now display the result content
589
+ const lines = result.split("\n");
590
+ for (let i = 0; i < lines.length; i++) {
591
+ const line = lines[i];
592
+ const isConflictMarker = line.startsWith("<<<<<<<") || line.startsWith("=======") || line.startsWith(">>>>>>>");
593
+
594
+ entries.push({
595
+ text: line + (i < lines.length - 1 ? "\n" : ""),
596
+ properties: {
597
+ type: isConflictMarker ? "conflict-marker" : "result-line",
598
+ lineNumber: i + 1,
599
+ },
600
+ });
601
+ }
602
+
603
+ return entries;
604
+ }
605
+
606
+ // =============================================================================
607
+ // View Rendering - Summary Style (Legacy)
608
+ // =============================================================================
609
+
610
+ /**
611
+ * Build entries for OURS panel (summary style)
612
+ */
613
+ function buildOursEntries(): TextPropertyEntry[] {
614
+ const entries: TextPropertyEntry[] = [];
615
+
616
+ // Header
617
+ entries.push({
618
+ text: "═══════════════════════════════════════════════════════════════════════════════\n",
619
+ properties: { type: "separator" },
620
+ });
621
+ entries.push({
622
+ text: " OURS (Read-only) - Changes from your branch\n",
623
+ properties: { type: "header", panel: "ours" },
624
+ });
625
+ entries.push({
626
+ text: "═══════════════════════════════════════════════════════════════════════════════\n",
627
+ properties: { type: "separator" },
628
+ });
629
+
630
+ // Show each conflict's OURS side
631
+ for (const conflict of mergeState.conflicts) {
632
+ const isSelected = conflict.index === mergeState.selectedIndex;
633
+ const marker = isSelected ? "▶ " : " ";
634
+ const status = conflict.resolved ? "[RESOLVED]" : "[PENDING]";
635
+
636
+ entries.push({
637
+ text: `\n${marker}Conflict ${conflict.index + 1} ${status}\n`,
638
+ properties: {
639
+ type: "conflict-header",
640
+ conflictIndex: conflict.index,
641
+ selected: isSelected,
642
+ resolved: conflict.resolved,
643
+ },
644
+ });
645
+
646
+ entries.push({
647
+ text: "─────────────────────────────────────────────────────────────────────────────\n",
648
+ properties: { type: "separator" },
649
+ });
650
+
651
+ // Content
652
+ const content = conflict.ours || "(empty)";
653
+ for (const line of content.split("\n")) {
654
+ entries.push({
655
+ text: ` ${line}\n`,
656
+ properties: {
657
+ type: "conflict-content",
658
+ conflictIndex: conflict.index,
659
+ side: "ours",
660
+ },
661
+ });
662
+ }
663
+ }
664
+
665
+ return entries;
666
+ }
667
+
668
+ /**
669
+ * Build entries for THEIRS panel
670
+ */
671
+ function buildTheirsEntries(): TextPropertyEntry[] {
672
+ const entries: TextPropertyEntry[] = [];
673
+
674
+ // Header
675
+ entries.push({
676
+ text: "═══════════════════════════════════════════════════════════════════════════════\n",
677
+ properties: { type: "separator" },
678
+ });
679
+ entries.push({
680
+ text: " THEIRS (Read-only) - Incoming changes\n",
681
+ properties: { type: "header", panel: "theirs" },
682
+ });
683
+ entries.push({
684
+ text: "═══════════════════════════════════════════════════════════════════════════════\n",
685
+ properties: { type: "separator" },
686
+ });
687
+
688
+ // Show each conflict's THEIRS side
689
+ for (const conflict of mergeState.conflicts) {
690
+ const isSelected = conflict.index === mergeState.selectedIndex;
691
+ const marker = isSelected ? "▶ " : " ";
692
+ const status = conflict.resolved ? "[RESOLVED]" : "[PENDING]";
693
+
694
+ entries.push({
695
+ text: `\n${marker}Conflict ${conflict.index + 1} ${status}\n`,
696
+ properties: {
697
+ type: "conflict-header",
698
+ conflictIndex: conflict.index,
699
+ selected: isSelected,
700
+ resolved: conflict.resolved,
701
+ },
702
+ });
703
+
704
+ entries.push({
705
+ text: "─────────────────────────────────────────────────────────────────────────────\n",
706
+ properties: { type: "separator" },
707
+ });
708
+
709
+ // Content
710
+ const content = conflict.theirs || "(empty)";
711
+ for (const line of content.split("\n")) {
712
+ entries.push({
713
+ text: ` ${line}\n`,
714
+ properties: {
715
+ type: "conflict-content",
716
+ conflictIndex: conflict.index,
717
+ side: "theirs",
718
+ },
719
+ });
720
+ }
721
+ }
722
+
723
+ return entries;
724
+ }
725
+
726
+ /**
727
+ * Build entries for RESULT panel
728
+ */
729
+ function buildResultEntries(): TextPropertyEntry[] {
730
+ const entries: TextPropertyEntry[] = [];
731
+
732
+ // Header
733
+ entries.push({
734
+ text: "═══════════════════════════════════════════════════════════════════════════════\n",
735
+ properties: { type: "separator" },
736
+ });
737
+ entries.push({
738
+ text: " RESULT (Editable) - Resolved content\n",
739
+ properties: { type: "header", panel: "result" },
740
+ });
741
+ entries.push({
742
+ text: "═══════════════════════════════════════════════════════════════════════════════\n",
743
+ properties: { type: "separator" },
744
+ });
745
+
746
+ // Build result content
747
+ let resultText = "";
748
+ const unresolvedCount = mergeState.conflicts.filter(c => !c.resolved).length;
749
+
750
+ if (unresolvedCount > 0) {
751
+ entries.push({
752
+ text: `\n ⚠ ${unresolvedCount} conflict(s) remaining\n\n`,
753
+ properties: { type: "warning" },
754
+ });
755
+ } else {
756
+ entries.push({
757
+ text: "\n ✓ All conflicts resolved!\n\n",
758
+ properties: { type: "success" },
759
+ });
760
+ }
761
+
762
+ // Show resolved content or action buttons for each conflict
763
+ for (const conflict of mergeState.conflicts) {
764
+ const isSelected = conflict.index === mergeState.selectedIndex;
765
+ const marker = isSelected ? "▶ " : " ";
766
+
767
+ entries.push({
768
+ text: `${marker}Conflict ${conflict.index + 1}:\n`,
769
+ properties: {
770
+ type: "conflict-header",
771
+ conflictIndex: conflict.index,
772
+ selected: isSelected,
773
+ },
774
+ });
775
+
776
+ if (conflict.resolved && conflict.resolvedContent !== undefined) {
777
+ // Show resolved content
778
+ entries.push({
779
+ text: ` [Resolved: ${conflict.resolution}]\n`,
780
+ properties: { type: "resolution-info", resolution: conflict.resolution },
781
+ });
782
+
783
+ for (const line of conflict.resolvedContent.split("\n")) {
784
+ entries.push({
785
+ text: ` ${line}\n`,
786
+ properties: {
787
+ type: "resolved-content",
788
+ conflictIndex: conflict.index,
789
+ },
790
+ });
791
+ }
792
+ } else {
793
+ // Show clickable action buttons
794
+ // Each button is a separate entry with onClick for mouse support
795
+ entries.push({
796
+ text: " << ",
797
+ properties: { type: "action-prefix" },
798
+ });
799
+ entries.push({
800
+ text: "[u] Accept Ours",
801
+ properties: {
802
+ type: "action-button",
803
+ conflictIndex: conflict.index,
804
+ onClick: "merge_use_ours",
805
+ },
806
+ });
807
+ entries.push({
808
+ text: " | ",
809
+ properties: { type: "action-separator" },
810
+ });
811
+ entries.push({
812
+ text: "[t] Accept Theirs",
813
+ properties: {
814
+ type: "action-button",
815
+ conflictIndex: conflict.index,
816
+ onClick: "merge_take_theirs",
817
+ },
818
+ });
819
+ entries.push({
820
+ text: " | ",
821
+ properties: { type: "action-separator" },
822
+ });
823
+ entries.push({
824
+ text: "[b] Both",
825
+ properties: {
826
+ type: "action-button",
827
+ conflictIndex: conflict.index,
828
+ onClick: "merge_use_both",
829
+ },
830
+ });
831
+ entries.push({
832
+ text: " >>\n",
833
+ properties: { type: "action-suffix" },
834
+ });
835
+ }
836
+
837
+ entries.push({
838
+ text: "─────────────────────────────────────────────────────────────────────────────\n",
839
+ properties: { type: "separator" },
840
+ });
841
+ }
842
+
843
+ // Help bar with clickable buttons
844
+ entries.push({
845
+ text: "\n",
846
+ properties: { type: "blank" },
847
+ });
848
+ // Navigation
849
+ entries.push({
850
+ text: "[n] Next",
851
+ properties: { type: "help-button", onClick: "merge_next_conflict" },
852
+ });
853
+ entries.push({
854
+ text: " ",
855
+ properties: { type: "help-separator" },
856
+ });
857
+ entries.push({
858
+ text: "[p] Prev",
859
+ properties: { type: "help-button", onClick: "merge_prev_conflict" },
860
+ });
861
+ entries.push({
862
+ text: " | ",
863
+ properties: { type: "help-separator" },
864
+ });
865
+ // Resolution
866
+ entries.push({
867
+ text: "[u] Use Ours",
868
+ properties: { type: "help-button", onClick: "merge_use_ours" },
869
+ });
870
+ entries.push({
871
+ text: " ",
872
+ properties: { type: "help-separator" },
873
+ });
874
+ entries.push({
875
+ text: "[t] Take Theirs",
876
+ properties: { type: "help-button", onClick: "merge_take_theirs" },
877
+ });
878
+ entries.push({
879
+ text: " ",
880
+ properties: { type: "help-separator" },
881
+ });
882
+ entries.push({
883
+ text: "[b] Both",
884
+ properties: { type: "help-button", onClick: "merge_use_both" },
885
+ });
886
+ entries.push({
887
+ text: " | ",
888
+ properties: { type: "help-separator" },
889
+ });
890
+ // Completion
891
+ entries.push({
892
+ text: "[s] Save & Exit",
893
+ properties: { type: "help-button", onClick: "merge_save_and_exit" },
894
+ });
895
+ entries.push({
896
+ text: " ",
897
+ properties: { type: "help-separator" },
898
+ });
899
+ entries.push({
900
+ text: "[q] Abort",
901
+ properties: { type: "help-button", onClick: "merge_abort" },
902
+ });
903
+ entries.push({
904
+ text: "\n",
905
+ properties: { type: "help-newline" },
906
+ });
907
+
908
+ return entries;
909
+ }
910
+
911
+ /**
912
+ * Apply visual highlighting to panels
913
+ */
914
+ function applyHighlighting(): void {
915
+ // Highlight OURS panel
916
+ if (mergeState.oursPanelId !== null) {
917
+ editor.removeOverlaysByPrefix(mergeState.oursPanelId, "merge-");
918
+ highlightPanel(mergeState.oursPanelId, "ours");
919
+ }
920
+
921
+ // Highlight THEIRS panel
922
+ if (mergeState.theirsPanelId !== null) {
923
+ editor.removeOverlaysByPrefix(mergeState.theirsPanelId, "merge-");
924
+ highlightPanel(mergeState.theirsPanelId, "theirs");
925
+ }
926
+
927
+ // Highlight RESULT panel
928
+ if (mergeState.resultPanelId !== null) {
929
+ editor.removeOverlaysByPrefix(mergeState.resultPanelId, "merge-");
930
+ highlightResultPanel(mergeState.resultPanelId);
931
+ }
932
+ }
933
+
934
+ /**
935
+ * Highlight a side panel (OURS or THEIRS)
936
+ * Note: We compute content from our entries since getBufferText was removed
937
+ *
938
+ * TODO: Implement proper conflict region highlighting:
939
+ * - Find actual conflict regions in git content by searching for conflict.ours/conflict.theirs text
940
+ * - Highlight each conflict region with appropriate color (conflictOurs/conflictTheirs)
941
+ * - Use different highlight for selected conflict vs unselected
942
+ * - Consider using line-based highlighting for better visual effect
943
+ */
944
+ function highlightPanel(bufferId: number, side: "ours" | "theirs"): void {
945
+ // Build content from entries (same as what we set on the buffer)
946
+ const entries = buildFullFileEntries(side);
947
+ const content = entries.map(e => e.text).join("");
948
+ const lines = content.split("\n");
949
+
950
+ let byteOffset = 0;
951
+ const conflictColor = side === "ours" ? colors.conflictOurs : colors.conflictTheirs;
952
+
953
+ for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
954
+ const line = lines[lineIdx];
955
+ const lineStart = byteOffset;
956
+ const lineEnd = byteOffset + line.length;
957
+
958
+ // Highlight conflict header lines
959
+ if (line.includes("--- Conflict")) {
960
+ editor.addOverlay(
961
+ bufferId,
962
+ `merge-conflict-header-${lineIdx}`,
963
+ lineStart,
964
+ lineEnd,
965
+ conflictColor[0],
966
+ conflictColor[1],
967
+ conflictColor[2],
968
+ true // underline
969
+ );
970
+ }
971
+
972
+ byteOffset = lineEnd + 1;
973
+ }
974
+ }
975
+
976
+ /**
977
+ * Highlight the RESULT panel
978
+ * Note: We compute content from our entries since getBufferText was removed
979
+ */
980
+ function highlightResultPanel(bufferId: number): void {
981
+ // Build content from entries (same as what we set on the buffer)
982
+ const entries = buildResultFileEntries();
983
+ const content = entries.map(e => e.text).join("");
984
+ const lines = content.split("\n");
985
+
986
+ let byteOffset = 0;
987
+
988
+ for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
989
+ const line = lines[lineIdx];
990
+ const lineStart = byteOffset;
991
+ const lineEnd = byteOffset + line.length;
992
+
993
+ // Highlight conflict markers
994
+ if (line.startsWith("<<<<<<<") || line.startsWith("=======") || line.startsWith(">>>>>>>")) {
995
+ editor.addOverlay(
996
+ bufferId,
997
+ `merge-marker-${lineIdx}`,
998
+ lineStart,
999
+ lineEnd,
1000
+ colors.unresolved[0],
1001
+ colors.unresolved[1],
1002
+ colors.unresolved[2],
1003
+ true // underline
1004
+ );
1005
+ }
1006
+
1007
+ byteOffset = lineEnd + 1;
1008
+ }
1009
+ }
1010
+
1011
+ /**
1012
+ * Update all panel views
1013
+ */
1014
+ function updateViews(): void {
1015
+ if (mergeState.oursPanelId !== null) {
1016
+ editor.setVirtualBufferContent(mergeState.oursPanelId, buildFullFileEntries("ours"));
1017
+ }
1018
+
1019
+ if (mergeState.theirsPanelId !== null) {
1020
+ editor.setVirtualBufferContent(mergeState.theirsPanelId, buildFullFileEntries("theirs"));
1021
+ }
1022
+
1023
+ if (mergeState.resultPanelId !== null) {
1024
+ editor.setVirtualBufferContent(mergeState.resultPanelId, buildResultFileEntries());
1025
+ }
1026
+
1027
+ applyHighlighting();
1028
+ updateStatusBar();
1029
+ }
1030
+
1031
+ /**
1032
+ * Update status bar with merge progress
1033
+ */
1034
+ function updateStatusBar(): void {
1035
+ const total = mergeState.conflicts.length;
1036
+ const resolved = mergeState.conflicts.filter(c => c.resolved).length;
1037
+ const remaining = total - resolved;
1038
+
1039
+ if (remaining > 0) {
1040
+ editor.setStatus(`Merge: ${remaining} of ${total} conflicts remaining | Current: ${mergeState.selectedIndex + 1}`);
1041
+ } else {
1042
+ editor.setStatus(`Merge: All ${total} conflicts resolved! Press 's' to save`);
1043
+ }
1044
+ }
1045
+
1046
+ /**
1047
+ * Scroll all three panels to show the selected conflict
1048
+ * This computes the byte offset where the conflict appears in each panel's content
1049
+ * and uses setBufferCursor to scroll the viewport.
1050
+ */
1051
+ function scrollToSelectedConflict(): void {
1052
+ const conflict = mergeState.conflicts[mergeState.selectedIndex];
1053
+ if (!conflict) return;
1054
+
1055
+ // Scroll OURS panel
1056
+ if (mergeState.oursPanelId !== null) {
1057
+ const oursOffset = computeConflictOffset("ours", conflict.index);
1058
+ if (oursOffset >= 0) {
1059
+ editor.setBufferCursor(mergeState.oursPanelId, oursOffset);
1060
+ }
1061
+ }
1062
+
1063
+ // Scroll THEIRS panel
1064
+ if (mergeState.theirsPanelId !== null) {
1065
+ const theirsOffset = computeConflictOffset("theirs", conflict.index);
1066
+ if (theirsOffset >= 0) {
1067
+ editor.setBufferCursor(mergeState.theirsPanelId, theirsOffset);
1068
+ }
1069
+ }
1070
+
1071
+ // Scroll RESULT panel
1072
+ if (mergeState.resultPanelId !== null) {
1073
+ const resultOffset = computeResultConflictOffset(conflict.index);
1074
+ if (resultOffset >= 0) {
1075
+ editor.setBufferCursor(mergeState.resultPanelId, resultOffset);
1076
+ }
1077
+ }
1078
+ }
1079
+
1080
+ /**
1081
+ * Compute the byte offset where a conflict appears in the OURS or THEIRS panel content.
1082
+ * We search for the actual conflict text (conflict.ours or conflict.theirs) in the
1083
+ * git content to find the exact position.
1084
+ */
1085
+ function computeConflictOffset(side: "ours" | "theirs", conflictIndex: number): number {
1086
+ const gitContent = side === "ours" ? mergeState.oursContent : mergeState.theirsContent;
1087
+ const conflict = mergeState.conflicts[conflictIndex];
1088
+
1089
+ if (!conflict) return 0;
1090
+
1091
+ // Get the conflict text for this side
1092
+ const conflictText = side === "ours" ? conflict.ours : conflict.theirs;
1093
+
1094
+ if (gitContent && conflictText) {
1095
+ // Strategy 1: Search for the exact conflict text (trimmed)
1096
+ const trimmedText = conflictText.trim();
1097
+ if (trimmedText.length > 0) {
1098
+ const pos = gitContent.indexOf(trimmedText);
1099
+ if (pos >= 0) {
1100
+ return pos;
1101
+ }
1102
+ }
1103
+
1104
+ // Strategy 2: Search for the first line of the conflict
1105
+ const firstLine = conflictText.split("\n")[0]?.trim();
1106
+ if (firstLine && firstLine.length > 5) {
1107
+ const pos = gitContent.indexOf(firstLine);
1108
+ if (pos >= 0) {
1109
+ return pos;
1110
+ }
1111
+ }
1112
+
1113
+ // Strategy 3: Ratio-based fallback
1114
+ const originalLength = mergeState.originalContent.length;
1115
+ if (originalLength > 0) {
1116
+ const ratio = conflict.startOffset / originalLength;
1117
+ return Math.floor(ratio * gitContent.length);
1118
+ }
1119
+ }
1120
+
1121
+ // If no git content, we built entries manually - find "--- Conflict N ---"
1122
+ const entries = buildFullFileEntries(side);
1123
+ let offset = 0;
1124
+ for (const entry of entries) {
1125
+ if (entry.text.includes(`--- Conflict ${conflictIndex + 1} ---`)) {
1126
+ return offset;
1127
+ }
1128
+ offset += entry.text.length;
1129
+ }
1130
+
1131
+ return 0;
1132
+ }
1133
+
1134
+ /**
1135
+ * Compute the byte offset where a conflict appears in the RESULT panel content.
1136
+ * The RESULT panel shows the original file with conflict markers (<<<<<<< OURS, etc.)
1137
+ * We need to find the Nth <<<<<<< marker.
1138
+ */
1139
+ function computeResultConflictOffset(conflictIndex: number): number {
1140
+ const entries = buildResultFileEntries();
1141
+ const content = entries.map(e => e.text).join("");
1142
+
1143
+ // Find the Nth occurrence of <<<<<<< marker
1144
+ let searchPos = 0;
1145
+ let conflictCount = 0;
1146
+
1147
+ while (searchPos < content.length) {
1148
+ const markerPos = content.indexOf("<<<<<<<", searchPos);
1149
+ if (markerPos === -1) break;
1150
+
1151
+ if (conflictCount === conflictIndex) {
1152
+ return markerPos;
1153
+ }
1154
+
1155
+ conflictCount++;
1156
+ searchPos = markerPos + 7; // Skip past "<<<<<<<" to continue searching
1157
+ }
1158
+
1159
+ // Fallback: use ratio-based estimation like we do for OURS/THEIRS
1160
+ const conflict = mergeState.conflicts[conflictIndex];
1161
+ if (conflict && mergeState.originalContent.length > 0) {
1162
+ const ratio = conflict.startOffset / mergeState.originalContent.length;
1163
+ return Math.floor(ratio * content.length);
1164
+ }
1165
+ return 0;
1166
+ }
1167
+
1168
+ // =============================================================================
1169
+ // Public Commands - Activation
1170
+ // =============================================================================
1171
+
1172
+ /**
1173
+ * Start merge conflict resolution for current buffer
1174
+ */
1175
+ globalThis.start_merge_conflict = async function(): Promise<void> {
1176
+ if (mergeState.isActive) {
1177
+ editor.setStatus("Merge mode already active");
1178
+ return;
1179
+ }
1180
+
1181
+ const bufferId = editor.getActiveBufferId();
1182
+ const info = editor.getBufferInfo(bufferId);
1183
+
1184
+ if (!info || !info.path) {
1185
+ editor.setStatus("No file open");
1186
+ return;
1187
+ }
1188
+
1189
+ editor.debug(`Merge: starting for ${info.path}`);
1190
+
1191
+ // Get the directory of the file for running git commands
1192
+ const fileDir = editor.pathDirname(info.path);
1193
+ editor.debug(`Merge: file directory is ${fileDir}`);
1194
+
1195
+ // Check if we're in a git repo (run from file's directory)
1196
+ const gitCheck = await editor.spawnProcess("git", ["rev-parse", "--is-inside-work-tree"], fileDir);
1197
+ editor.debug(`Merge: git rev-parse exit_code=${gitCheck.exit_code}, stdout=${gitCheck.stdout.trim()}`);
1198
+
1199
+ if (gitCheck.exit_code !== 0 || gitCheck.stdout.trim() !== "true") {
1200
+ editor.setStatus("Not in a git repository - merge conflict resolution requires git");
1201
+ return;
1202
+ }
1203
+
1204
+ // Check if file has unmerged entries using git (run from file's directory)
1205
+ const lsFilesResult = await editor.spawnProcess("git", ["ls-files", "-u", info.path], fileDir);
1206
+ editor.debug(`Merge: git ls-files -u exit_code=${lsFilesResult.exit_code}, stdout length=${lsFilesResult.stdout.length}, stderr=${lsFilesResult.stderr}`);
1207
+
1208
+ const hasUnmergedEntries = lsFilesResult.exit_code === 0 && lsFilesResult.stdout.trim().length > 0;
1209
+
1210
+ if (!hasUnmergedEntries) {
1211
+ editor.setStatus("No unmerged entries - file is not in a merge conflict state");
1212
+ return;
1213
+ }
1214
+
1215
+ // Get file content from git's working tree (has conflict markers)
1216
+ const catFileResult = await editor.spawnProcess("git", ["show", `:0:${info.path}`]);
1217
+
1218
+ // If :0: doesn't exist, read the working tree file directly
1219
+ let content: string;
1220
+ if (catFileResult.exit_code !== 0) {
1221
+ editor.debug(`Merge: git show :0: failed, reading working tree file`);
1222
+ const fileContent = await editor.readFile(info.path);
1223
+ if (!fileContent) {
1224
+ editor.setStatus("Failed to read file content");
1225
+ return;
1226
+ }
1227
+ content = fileContent;
1228
+ } else {
1229
+ // The staged version shouldn't have conflict markers, use working tree
1230
+ const fileContent = await editor.readFile(info.path);
1231
+ if (!fileContent) {
1232
+ editor.setStatus("Failed to read file content");
1233
+ return;
1234
+ }
1235
+ content = fileContent;
1236
+ }
1237
+
1238
+ // Check for conflict markers in content
1239
+ const hasMarkers = hasConflictMarkers(content);
1240
+ editor.debug(`Merge: file has conflict markers: ${hasMarkers}, content length: ${content.length}`);
1241
+
1242
+ if (!hasMarkers) {
1243
+ editor.setStatus("No conflict markers found in file content");
1244
+ return;
1245
+ }
1246
+
1247
+ editor.setStatus("Starting merge conflict resolution...");
1248
+
1249
+ // Store original state
1250
+ mergeState.sourceBufferId = bufferId;
1251
+ mergeState.sourcePath = info.path;
1252
+ mergeState.originalContent = content;
1253
+
1254
+ // Parse conflicts
1255
+ mergeState.conflicts = parseConflicts(content);
1256
+
1257
+ // Debug: log parse results
1258
+ editor.debug(`Merge: parseConflicts found ${mergeState.conflicts.length} conflicts`);
1259
+
1260
+ if (mergeState.conflicts.length === 0) {
1261
+ editor.setStatus("Failed to parse conflict markers");
1262
+ // Log more detail for debugging
1263
+ editor.debug(`Merge: regex failed, content has <<<<<<< at index ${content.indexOf("<<<<<<<")}`);
1264
+ editor.debug(`Merge: content around <<<<<<< : ${content.substring(content.indexOf("<<<<<<<") - 20, content.indexOf("<<<<<<<") + 100)}`);
1265
+ return;
1266
+ }
1267
+
1268
+ editor.debug(`Found ${mergeState.conflicts.length} conflicts`);
1269
+
1270
+ // Fetch git versions for auto-resolution
1271
+ const versions = await fetchGitVersions(info.path);
1272
+ if (versions) {
1273
+ mergeState.baseContent = versions.base;
1274
+ mergeState.oursContent = versions.ours;
1275
+ mergeState.theirsContent = versions.theirs;
1276
+ editor.debug("Fetched git versions for auto-resolution");
1277
+ }
1278
+
1279
+ // Attempt auto-resolution
1280
+ autoResolveConflicts(mergeState.conflicts);
1281
+
1282
+ const autoResolved = mergeState.conflicts.filter(c => c.resolved).length;
1283
+ if (autoResolved > 0) {
1284
+ editor.debug(`Auto-resolved ${autoResolved} trivial conflicts`);
1285
+ }
1286
+
1287
+ // Find first unresolved conflict
1288
+ mergeState.selectedIndex = 0;
1289
+ for (let i = 0; i < mergeState.conflicts.length; i++) {
1290
+ if (!mergeState.conflicts[i].resolved) {
1291
+ mergeState.selectedIndex = i;
1292
+ break;
1293
+ }
1294
+ }
1295
+
1296
+ // Create the merge UI panels
1297
+ await createMergePanels();
1298
+
1299
+ mergeState.isActive = true;
1300
+
1301
+ // Register merge-mode commands now that we're active
1302
+ registerMergeModeCommands();
1303
+
1304
+ updateViews();
1305
+
1306
+ // Scroll all panels to show the first conflict
1307
+ scrollToSelectedConflict();
1308
+
1309
+ const remaining = mergeState.conflicts.length - autoResolved;
1310
+ if (remaining > 0) {
1311
+ editor.setStatus(`Merge: ${remaining} conflicts to resolve (${autoResolved} auto-resolved)`);
1312
+ } else {
1313
+ editor.setStatus(`Merge: All ${mergeState.conflicts.length} conflicts auto-resolved! Press 's' to save`);
1314
+ }
1315
+ };
1316
+
1317
+ /**
1318
+ * Create the multi-panel merge UI (JetBrains-style: OURS | RESULT | THEIRS)
1319
+ *
1320
+ * Creates three vertical splits and then calls distributeSplitsEvenly()
1321
+ * to ensure all panels get equal width.
1322
+ */
1323
+ async function createMergePanels(): Promise<void> {
1324
+ // Get the source file's extension for syntax highlighting
1325
+ // Tree-sitter uses filename extension to determine language
1326
+ const sourceExt = mergeState.sourcePath
1327
+ ? mergeState.sourcePath.substring(mergeState.sourcePath.lastIndexOf("."))
1328
+ : "";
1329
+
1330
+ editor.debug(`Merge: source extension '${sourceExt}' for syntax highlighting`);
1331
+
1332
+ // Create OURS panel first (takes over current view)
1333
+ // Include extension in name so tree-sitter can apply highlighting
1334
+ const oursId = await editor.createVirtualBuffer({
1335
+ name: `*OURS*${sourceExt}`,
1336
+ mode: "merge-conflict",
1337
+ read_only: true,
1338
+ entries: buildFullFileEntries("ours"),
1339
+ panel_id: "merge-ours",
1340
+ show_line_numbers: true,
1341
+ show_cursors: true,
1342
+ editing_disabled: true,
1343
+ });
1344
+
1345
+ if (oursId !== null) {
1346
+ mergeState.oursPanelId = oursId;
1347
+ mergeState.oursSplitId = editor.getActiveSplitId();
1348
+ }
1349
+
1350
+ // Create THEIRS panel to the right (vertical split)
1351
+ const theirsId = await editor.createVirtualBufferInSplit({
1352
+ name: `*THEIRS*${sourceExt}`,
1353
+ mode: "merge-conflict",
1354
+ read_only: true,
1355
+ entries: buildFullFileEntries("theirs"),
1356
+ ratio: 0.5, // Will be equalized by distributeSplitsEvenly
1357
+ direction: "vertical",
1358
+ panel_id: "merge-theirs",
1359
+ show_line_numbers: true,
1360
+ show_cursors: true,
1361
+ editing_disabled: true,
1362
+ });
1363
+
1364
+ if (theirsId !== null) {
1365
+ mergeState.theirsPanelId = theirsId;
1366
+ mergeState.theirsSplitId = editor.getActiveSplitId();
1367
+ }
1368
+
1369
+ // Focus back on OURS and create RESULT in the middle
1370
+ if (mergeState.oursSplitId !== null) {
1371
+ editor.focusSplit(mergeState.oursSplitId);
1372
+ }
1373
+
1374
+ const resultId = await editor.createVirtualBufferInSplit({
1375
+ name: `*RESULT*${sourceExt}`,
1376
+ mode: "merge-result",
1377
+ read_only: false,
1378
+ entries: buildResultFileEntries(),
1379
+ ratio: 0.5, // Will be equalized by distributeSplitsEvenly
1380
+ direction: "vertical",
1381
+ panel_id: "merge-result",
1382
+ show_line_numbers: true,
1383
+ show_cursors: true,
1384
+ editing_disabled: false,
1385
+ });
1386
+
1387
+ if (resultId !== null) {
1388
+ mergeState.resultPanelId = resultId;
1389
+ mergeState.resultSplitId = editor.getActiveSplitId();
1390
+ }
1391
+
1392
+ // Distribute splits evenly so all three panels get equal width
1393
+ editor.distributeSplitsEvenly();
1394
+
1395
+ // Focus the RESULT panel since that's where the user will resolve conflicts
1396
+ if (mergeState.resultSplitId !== null) {
1397
+ editor.focusSplit(mergeState.resultSplitId);
1398
+ }
1399
+ }
1400
+
1401
+ // =============================================================================
1402
+ // Public Commands - Navigation
1403
+ // =============================================================================
1404
+
1405
+ globalThis.merge_next_conflict = function(): void {
1406
+ editor.debug(`merge_next_conflict called, isActive=${mergeState.isActive}, conflicts=${mergeState.conflicts.length}`);
1407
+
1408
+ if (!mergeState.isActive) {
1409
+ editor.setStatus("No active merge - use 'Merge: Start Resolution' first");
1410
+ return;
1411
+ }
1412
+ if (mergeState.conflicts.length === 0) {
1413
+ editor.setStatus("No conflicts to navigate");
1414
+ return;
1415
+ }
1416
+ if (mergeState.conflicts.length === 1) {
1417
+ // Single conflict: just re-scroll to it (useful for re-focusing)
1418
+ editor.setStatus("Conflict 1 of 1 (re-focused)");
1419
+ scrollToSelectedConflict();
1420
+ return;
1421
+ }
1422
+
1423
+ // Find next unresolved conflict (or wrap around)
1424
+ let startIndex = mergeState.selectedIndex;
1425
+ let index = (startIndex + 1) % mergeState.conflicts.length;
1426
+
1427
+ // First try to find next unresolved
1428
+ while (index !== startIndex) {
1429
+ if (!mergeState.conflicts[index].resolved) {
1430
+ mergeState.selectedIndex = index;
1431
+ editor.setStatus(`Conflict ${index + 1} of ${mergeState.conflicts.length}`);
1432
+ updateViews();
1433
+ scrollToSelectedConflict();
1434
+ return;
1435
+ }
1436
+ index = (index + 1) % mergeState.conflicts.length;
1437
+ }
1438
+
1439
+ // If all resolved, just move to next
1440
+ mergeState.selectedIndex = (mergeState.selectedIndex + 1) % mergeState.conflicts.length;
1441
+ editor.setStatus(`Conflict ${mergeState.selectedIndex + 1} of ${mergeState.conflicts.length} (all resolved)`);
1442
+ updateViews();
1443
+ scrollToSelectedConflict();
1444
+ };
1445
+
1446
+ globalThis.merge_prev_conflict = function(): void {
1447
+ editor.debug(`merge_prev_conflict called, isActive=${mergeState.isActive}, conflicts=${mergeState.conflicts.length}`);
1448
+
1449
+ if (!mergeState.isActive) {
1450
+ editor.setStatus("No active merge - use 'Merge: Start Resolution' first");
1451
+ return;
1452
+ }
1453
+ if (mergeState.conflicts.length === 0) {
1454
+ editor.setStatus("No conflicts to navigate");
1455
+ return;
1456
+ }
1457
+ if (mergeState.conflicts.length === 1) {
1458
+ // Single conflict: just re-scroll to it (useful for re-focusing)
1459
+ editor.setStatus("Conflict 1 of 1 (re-focused)");
1460
+ scrollToSelectedConflict();
1461
+ return;
1462
+ }
1463
+
1464
+ // Find previous unresolved conflict (or wrap around)
1465
+ let startIndex = mergeState.selectedIndex;
1466
+ let index = (startIndex - 1 + mergeState.conflicts.length) % mergeState.conflicts.length;
1467
+
1468
+ // First try to find previous unresolved
1469
+ while (index !== startIndex) {
1470
+ if (!mergeState.conflicts[index].resolved) {
1471
+ mergeState.selectedIndex = index;
1472
+ editor.setStatus(`Conflict ${index + 1} of ${mergeState.conflicts.length}`);
1473
+ updateViews();
1474
+ scrollToSelectedConflict();
1475
+ return;
1476
+ }
1477
+ index = (index - 1 + mergeState.conflicts.length) % mergeState.conflicts.length;
1478
+ }
1479
+
1480
+ // If all resolved, just move to previous
1481
+ mergeState.selectedIndex = (mergeState.selectedIndex - 1 + mergeState.conflicts.length) % mergeState.conflicts.length;
1482
+ editor.setStatus(`Conflict ${mergeState.selectedIndex + 1} of ${mergeState.conflicts.length} (all resolved)`);
1483
+ updateViews();
1484
+ scrollToSelectedConflict();
1485
+ };
1486
+
1487
+ // =============================================================================
1488
+ // Public Commands - Resolution
1489
+ // =============================================================================
1490
+
1491
+ globalThis.merge_use_ours = function(): void {
1492
+ if (!mergeState.isActive) {
1493
+ editor.setStatus("No active merge - use 'Merge: Start Resolution' first");
1494
+ return;
1495
+ }
1496
+
1497
+ const conflict = mergeState.conflicts[mergeState.selectedIndex];
1498
+ if (!conflict) return;
1499
+
1500
+ conflict.resolved = true;
1501
+ conflict.resolution = "ours";
1502
+ conflict.resolvedContent = conflict.ours;
1503
+
1504
+ editor.debug(`Resolved conflict ${conflict.index} with OURS`);
1505
+
1506
+ // Move to next unresolved conflict
1507
+ moveToNextUnresolved();
1508
+ updateViews();
1509
+ };
1510
+
1511
+ globalThis.merge_take_theirs = function(): void {
1512
+ if (!mergeState.isActive) {
1513
+ editor.setStatus("No active merge - use 'Merge: Start Resolution' first");
1514
+ return;
1515
+ }
1516
+
1517
+ const conflict = mergeState.conflicts[mergeState.selectedIndex];
1518
+ if (!conflict) return;
1519
+
1520
+ conflict.resolved = true;
1521
+ conflict.resolution = "theirs";
1522
+ conflict.resolvedContent = conflict.theirs;
1523
+
1524
+ editor.debug(`Resolved conflict ${conflict.index} with THEIRS`);
1525
+
1526
+ // Move to next unresolved conflict
1527
+ moveToNextUnresolved();
1528
+ updateViews();
1529
+ };
1530
+
1531
+ globalThis.merge_use_both = function(): void {
1532
+ if (!mergeState.isActive) {
1533
+ editor.setStatus("No active merge - use 'Merge: Start Resolution' first");
1534
+ return;
1535
+ }
1536
+
1537
+ const conflict = mergeState.conflicts[mergeState.selectedIndex];
1538
+ if (!conflict) return;
1539
+
1540
+ conflict.resolved = true;
1541
+ conflict.resolution = "both";
1542
+ conflict.resolvedContent = conflict.ours + conflict.theirs;
1543
+
1544
+ editor.debug(`Resolved conflict ${conflict.index} with BOTH`);
1545
+
1546
+ // Move to next unresolved conflict
1547
+ moveToNextUnresolved();
1548
+ updateViews();
1549
+ };
1550
+
1551
+ /**
1552
+ * Move selection to the next unresolved conflict
1553
+ */
1554
+ function moveToNextUnresolved(): void {
1555
+ const startIndex = mergeState.selectedIndex;
1556
+ let index = (startIndex + 1) % mergeState.conflicts.length;
1557
+
1558
+ while (index !== startIndex) {
1559
+ if (!mergeState.conflicts[index].resolved) {
1560
+ mergeState.selectedIndex = index;
1561
+ return;
1562
+ }
1563
+ index = (index + 1) % mergeState.conflicts.length;
1564
+ }
1565
+
1566
+ // All resolved, stay where we are
1567
+ }
1568
+
1569
+ // =============================================================================
1570
+ // Public Commands - Completion
1571
+ // =============================================================================
1572
+
1573
+ globalThis.merge_save_and_exit = async function(): Promise<void> {
1574
+ if (!mergeState.isActive) {
1575
+ editor.setStatus("No active merge - use 'Merge: Start Resolution' first");
1576
+ return;
1577
+ }
1578
+
1579
+ const unresolvedCount = mergeState.conflicts.filter(c => !c.resolved).length;
1580
+
1581
+ if (unresolvedCount > 0) {
1582
+ // TODO: Add confirmation prompt
1583
+ editor.setStatus(`Cannot save: ${unresolvedCount} unresolved conflicts remaining`);
1584
+ return;
1585
+ }
1586
+
1587
+ // Build final content by replacing conflict markers with resolved content
1588
+ let finalContent = mergeState.originalContent;
1589
+
1590
+ // Process conflicts in reverse order to preserve offsets
1591
+ const sortedConflicts = [...mergeState.conflicts].sort((a, b) => b.startOffset - a.startOffset);
1592
+
1593
+ for (const conflict of sortedConflicts) {
1594
+ if (conflict.resolvedContent !== undefined) {
1595
+ finalContent =
1596
+ finalContent.substring(0, conflict.startOffset) +
1597
+ conflict.resolvedContent +
1598
+ finalContent.substring(conflict.endOffset);
1599
+ }
1600
+ }
1601
+
1602
+ // Update the original buffer with resolved content
1603
+ if (mergeState.sourceBufferId !== null) {
1604
+ const bufferLength = editor.getBufferLength(mergeState.sourceBufferId);
1605
+
1606
+ // Delete all content
1607
+ if (bufferLength > 0) {
1608
+ editor.deleteRange(mergeState.sourceBufferId, { start: 0, end: bufferLength });
1609
+ }
1610
+
1611
+ // Insert resolved content
1612
+ editor.insertText(mergeState.sourceBufferId, 0, finalContent);
1613
+
1614
+ editor.debug("Applied resolved content to source buffer");
1615
+ }
1616
+
1617
+ // Close merge panels
1618
+ closeMergePanels();
1619
+
1620
+ editor.setStatus("Merge complete! File updated with resolved content");
1621
+ };
1622
+
1623
+ globalThis.merge_abort = function(): void {
1624
+ if (!mergeState.isActive) {
1625
+ editor.setStatus("No active merge - nothing to abort");
1626
+ return;
1627
+ }
1628
+
1629
+ // TODO: Add confirmation prompt if there are resolutions
1630
+
1631
+ // Close merge panels without saving
1632
+ closeMergePanels();
1633
+
1634
+ editor.setStatus("Merge aborted - no changes made");
1635
+ };
1636
+
1637
+ /**
1638
+ * Close all merge panels and reset state
1639
+ */
1640
+ function closeMergePanels(): void {
1641
+ // Close buffers
1642
+ if (mergeState.oursPanelId !== null) {
1643
+ editor.closeBuffer(mergeState.oursPanelId);
1644
+ }
1645
+ if (mergeState.theirsPanelId !== null) {
1646
+ editor.closeBuffer(mergeState.theirsPanelId);
1647
+ }
1648
+ if (mergeState.resultPanelId !== null) {
1649
+ editor.closeBuffer(mergeState.resultPanelId);
1650
+ }
1651
+
1652
+ // Close splits
1653
+ if (mergeState.oursSplitId !== null) {
1654
+ editor.closeSplit(mergeState.oursSplitId);
1655
+ }
1656
+ if (mergeState.theirsSplitId !== null) {
1657
+ editor.closeSplit(mergeState.theirsSplitId);
1658
+ }
1659
+ if (mergeState.resultSplitId !== null) {
1660
+ editor.closeSplit(mergeState.resultSplitId);
1661
+ }
1662
+
1663
+ // Focus back on source buffer if it exists
1664
+ if (mergeState.sourceBufferId !== null) {
1665
+ editor.showBuffer(mergeState.sourceBufferId);
1666
+ }
1667
+
1668
+ // Unregister merge-mode commands
1669
+ unregisterMergeModeCommands();
1670
+
1671
+ // Reset state
1672
+ mergeState.isActive = false;
1673
+ mergeState.sourceBufferId = null;
1674
+ mergeState.sourcePath = null;
1675
+ mergeState.originalContent = "";
1676
+ mergeState.conflicts = [];
1677
+ mergeState.selectedIndex = 0;
1678
+ mergeState.oursPanelId = null;
1679
+ mergeState.theirsPanelId = null;
1680
+ mergeState.resultPanelId = null;
1681
+ mergeState.oursSplitId = null;
1682
+ mergeState.theirsSplitId = null;
1683
+ mergeState.resultSplitId = null;
1684
+ mergeState.oursContent = "";
1685
+ mergeState.theirsContent = "";
1686
+ mergeState.baseContent = "";
1687
+ mergeState.resultContent = "";
1688
+ }
1689
+
1690
+ // =============================================================================
1691
+ // Public Commands - Help
1692
+ // =============================================================================
1693
+
1694
+ globalThis.merge_show_help = function(): void {
1695
+ editor.setStatus(
1696
+ "Merge: [n/p] Navigate | [u] Ours [t] Theirs [b] Both | [s] Save [q] Abort"
1697
+ );
1698
+ };
1699
+
1700
+ // =============================================================================
1701
+ // Hook Handlers - Auto-Detection
1702
+ // =============================================================================
1703
+
1704
+ /**
1705
+ * Handle buffer activation - check for conflict markers
1706
+ */
1707
+ globalThis.onMergeBufferActivated = async function(data: { buffer_id: number }): Promise<void> {
1708
+ // Don't trigger if already in merge mode
1709
+ if (mergeState.isActive) return;
1710
+
1711
+ // Don't trigger for virtual buffers
1712
+ const info = editor.getBufferInfo(data.buffer_id);
1713
+ if (!info || !info.path) return;
1714
+
1715
+ // Get the directory of the file for running git commands
1716
+ const fileDir = editor.pathDirname(info.path);
1717
+
1718
+ // Check if we're in a git repo first
1719
+ try {
1720
+ const gitCheck = await editor.spawnProcess("git", ["rev-parse", "--is-inside-work-tree"], fileDir);
1721
+ if (gitCheck.exit_code !== 0) return;
1722
+
1723
+ // Check for unmerged entries
1724
+ const lsFiles = await editor.spawnProcess("git", ["ls-files", "-u", info.path], fileDir);
1725
+ if (lsFiles.exit_code === 0 && lsFiles.stdout.trim().length > 0) {
1726
+ editor.setStatus(`Conflicts detected! Use 'Merge: Start Resolution' or run start_merge_conflict`);
1727
+ }
1728
+ } catch (e) {
1729
+ // Not in git repo or other error, ignore
1730
+ }
1731
+ };
1732
+
1733
+ /**
1734
+ * Handle file open - check for conflict markers
1735
+ */
1736
+ globalThis.onMergeAfterFileOpen = async function(data: { buffer_id: number; path: string }): Promise<void> {
1737
+ // Don't trigger if already in merge mode
1738
+ if (mergeState.isActive) return;
1739
+
1740
+ // Get the directory of the file for running git commands
1741
+ const fileDir = editor.pathDirname(data.path);
1742
+
1743
+ // Check if we're in a git repo first
1744
+ try {
1745
+ const gitCheck = await editor.spawnProcess("git", ["rev-parse", "--is-inside-work-tree"], fileDir);
1746
+ if (gitCheck.exit_code !== 0) return;
1747
+
1748
+ // Check for unmerged entries
1749
+ const lsFiles = await editor.spawnProcess("git", ["ls-files", "-u", data.path], fileDir);
1750
+ if (lsFiles.exit_code === 0 && lsFiles.stdout.trim().length > 0) {
1751
+ editor.setStatus(`⚠ Merge conflicts detected in ${data.path} - Use 'Merge: Start Resolution'`);
1752
+ }
1753
+ } catch (e) {
1754
+ // Not in git repo or other error, ignore
1755
+ }
1756
+ };
1757
+
1758
+ // =============================================================================
1759
+ // Hook Registration
1760
+ // =============================================================================
1761
+
1762
+ editor.on("buffer_activated", "onMergeBufferActivated");
1763
+ editor.on("after-file-open", "onMergeAfterFileOpen");
1764
+
1765
+ // =============================================================================
1766
+ // Command Registration - Dynamic based on merge mode state
1767
+ // =============================================================================
1768
+
1769
+ // Commands that are only available during active merge mode
1770
+ const MERGE_MODE_COMMANDS = [
1771
+ { name: "Merge: Next Conflict", desc: "Jump to next unresolved conflict", action: "merge_next_conflict" },
1772
+ { name: "Merge: Previous Conflict", desc: "Jump to previous unresolved conflict", action: "merge_prev_conflict" },
1773
+ { name: "Merge: Use Ours", desc: "Accept our version for current conflict", action: "merge_use_ours" },
1774
+ { name: "Merge: Take Theirs", desc: "Accept their version for current conflict", action: "merge_take_theirs" },
1775
+ { name: "Merge: Use Both", desc: "Accept both versions for current conflict", action: "merge_use_both" },
1776
+ { name: "Merge: Save & Exit", desc: "Save resolved content and exit merge mode", action: "merge_save_and_exit" },
1777
+ { name: "Merge: Abort", desc: "Abort merge resolution without saving", action: "merge_abort" },
1778
+ ];
1779
+
1780
+ /**
1781
+ * Register merge-mode specific commands (called when merge mode starts)
1782
+ */
1783
+ function registerMergeModeCommands(): void {
1784
+ for (const cmd of MERGE_MODE_COMMANDS) {
1785
+ editor.registerCommand(cmd.name, cmd.desc, cmd.action, "normal");
1786
+ }
1787
+ }
1788
+
1789
+ /**
1790
+ * Unregister merge-mode specific commands (called when merge mode ends)
1791
+ */
1792
+ function unregisterMergeModeCommands(): void {
1793
+ for (const cmd of MERGE_MODE_COMMANDS) {
1794
+ editor.unregisterCommand(cmd.name);
1795
+ }
1796
+ }
1797
+
1798
+ // Only register "Start Resolution" at plugin load - other commands are registered dynamically
1799
+ editor.registerCommand(
1800
+ "Merge: Start Resolution",
1801
+ "Start 3-way merge conflict resolution for current file",
1802
+ "start_merge_conflict",
1803
+ "normal"
1804
+ );
1805
+
1806
+ // =============================================================================
1807
+ // Plugin Initialization
1808
+ // =============================================================================
1809
+
1810
+ editor.setStatus("Merge Conflict Resolution plugin loaded");
1811
+ editor.debug("Merge plugin initialized - Use 'Merge: Start Resolution' for files with conflicts");