@involvex/fresh-editor 0.1.76 → 0.1.78

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