n2b 0.7.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/n2b/merge_cli.rb CHANGED
@@ -1,5 +1,14 @@
1
1
  require 'shellwords'
2
2
  require 'rbconfig'
3
+ require 'optparse'
4
+ require 'fileutils'
5
+ require 'stringio'
6
+ require 'cgi'
7
+ require_relative 'base'
8
+ require_relative 'merge_conflict_parser'
9
+ require_relative 'jira_client'
10
+ require_relative 'github_client'
11
+ require_relative 'message_utils'
3
12
 
4
13
  module N2B
5
14
  class MergeCLI < Base
@@ -17,23 +26,30 @@ module N2B
17
26
  def initialize(args)
18
27
  @args = args
19
28
  @options = parse_options
20
- @file_path = @args.shift
29
+ # @file_path = @args.shift # Moved to execute based on mode
21
30
  end
22
31
 
23
32
  def execute
24
- if @file_path.nil?
25
- show_usage_and_unresolved
26
- exit 1
27
- end
33
+ config = get_config(reconfigure: false, advanced_flow: false)
28
34
 
29
- unless File.exist?(@file_path)
30
- puts "File not found: #{@file_path}"
31
- exit 1
32
- end
35
+ if @options[:analyze]
36
+ # In analyze mode, @args might be used for custom prompt additions later,
37
+ # similar to how original cli.rb handled it.
38
+ # For now, custom_message option is the primary way.
39
+ handle_diff_analysis(config)
40
+ else
41
+ @file_path = @args.shift
42
+ if @file_path.nil?
43
+ show_help_and_status # Renamed from show_usage_and_unresolved
44
+ exit 1
45
+ end
33
46
 
34
- config = get_config(reconfigure: false, advanced_flow: false)
47
+ unless File.exist?(@file_path)
48
+ puts "File not found: #{@file_path}"
49
+ exit 1
50
+ end
35
51
 
36
- parser = MergeConflictParser.new(context_lines: @options[:context_lines])
52
+ parser = MergeConflictParser.new(context_lines: @options[:context_lines])
37
53
  blocks = parser.parse(@file_path)
38
54
  if blocks.empty?
39
55
  puts "No merge conflicts found."
@@ -50,7 +66,14 @@ module N2B
50
66
  base_content: block.base_content,
51
67
  incoming_content: block.incoming_content,
52
68
  base_label: block.base_label,
53
- incoming_label: block.incoming_label
69
+ incoming_label: block.incoming_label,
70
+ start_line: block.start_line,
71
+ end_line: block.end_line,
72
+ resolved_content: result[:merged_code],
73
+ llm_suggestion: result[:reason],
74
+ resolution_method: determine_resolution_method(result),
75
+ action: determine_action(result),
76
+ timestamp: Time.now.strftime('%Y-%m-%d %H:%M:%S UTC')
54
77
  })
55
78
  if result[:abort]
56
79
  aborted = true
@@ -90,25 +113,784 @@ module N2B
90
113
  dir = '.n2b_merge_log'
91
114
  FileUtils.mkdir_p(dir)
92
115
  timestamp = Time.now.strftime('%Y-%m-%d-%H%M%S')
93
- log_path = File.join(dir, "#{timestamp}.json")
94
- File.write(log_path, JSON.pretty_generate({file: @file_path, timestamp: Time.now, entries: log_entries}))
116
+ log_path = File.join(dir, "#{timestamp}.html")
117
+ html_content = generate_merge_log_html(log_entries, timestamp)
118
+ File.write(log_path, html_content)
95
119
  puts "#{COLOR_GRAY}📝 Merge log saved to #{log_path}#{COLOR_RESET}"
96
120
  end
121
+ end
97
122
  end
98
123
 
99
124
  private
100
125
 
101
126
  def parse_options
102
- options = { context_lines: MergeConflictParser::DEFAULT_CONTEXT_LINES }
127
+ options = {
128
+ context_lines: MergeConflictParser::DEFAULT_CONTEXT_LINES,
129
+ analyze: false,
130
+ branch: nil,
131
+ jira_ticket: nil,
132
+ github_issue: nil,
133
+ requirements_file: nil,
134
+ update_issue: nil, # nil means ask, true means update, false means no update
135
+ custom_message: nil
136
+ }
103
137
  parser = OptionParser.new do |opts|
104
- opts.banner = 'Usage: n2b-diff FILE [options]'
105
- opts.on('--context N', Integer, 'Context lines (default: 10)') { |v| options[:context_lines] = v }
106
- opts.on('-h', '--help', 'Show this help') { puts opts; exit }
138
+ opts.banner = "Usage: n2b-diff FILE [options] OR n2b-diff --analyze [options]"
139
+ opts.separator ""
140
+ opts.separator "Merge Conflict Options:"
141
+ opts.on('--context N', Integer, 'Context lines for merge conflict analysis (default: 10, affects LLM context)') { |v| options[:context_lines] = v }
142
+
143
+ opts.separator ""
144
+ opts.separator "Diff Analysis Options:"
145
+ opts.on('-a', '--analyze', 'Analyze git/hg diff with AI') { options[:analyze] = true }
146
+ opts.on('--branch [BRANCH]', 'Branch to compare against for analysis (default: auto-detect main/master)') do |branch|
147
+ options[:branch] = branch || 'auto' # 'auto' will trigger detection logic
148
+ end
149
+ opts.on('-j', '--jira JIRA_ID_OR_URL', 'Jira ticket ID or URL for context or update') do |jira|
150
+ options[:jira_ticket] = jira
151
+ end
152
+ opts.on('--github GITHUB_ISSUE_ID_OR_URL', 'GitHub issue ID or URL for context or update (e.g., owner/repo/issues/123)') do |gh|
153
+ options[:github_issue] = gh
154
+ end
155
+ opts.on('-r', '--requirements FILEPATH', 'Requirements file for diff analysis context') do |file|
156
+ options[:requirements_file] = file
157
+ end
158
+ opts.on('--update', 'Attempt to update the linked Jira/GitHub issue with the analysis result (will ask for confirmation by default)') do
159
+ options[:update_issue] = true # Explicitly true
160
+ end
161
+ opts.on('--no-update', 'Do not attempt to update the linked Jira/GitHub issue') do
162
+ options[:update_issue] = false # Explicitly false
163
+ end
164
+ opts.on('-m', '--message MESSAGE', '--msg MESSAGE', String, 'Custom instructions for AI analysis (max 500 chars)') do |raw_msg|
165
+ validated_msg = N2B::MessageUtils.validate_message(raw_msg)
166
+ # Sanitization happens after validation (e.g. truncation)
167
+ # No need to log here, will log after all options parsed if message exists
168
+ options[:custom_message] = validated_msg # Store potentially truncated message
169
+ end
170
+
171
+ opts.separator ""
172
+ opts.separator "Common Options:"
173
+ opts.on_tail('-h', '--help', 'Show this help message') { puts opts; exit }
174
+ opts.on_tail('-v', '--version', 'Show version') { puts N2B::VERSION; exit } # Assuming VERSION is defined
175
+
176
+ opts.separator ""
177
+ opts.separator "Examples:"
178
+ opts.separator " n2b-diff path/to/your/file_with_conflicts.rb"
179
+ opts.separator " n2b-diff --analyze"
180
+ opts.separator " n2b-diff --analyze --branch main"
181
+ opts.separator " n2b-diff --analyze --jira PROJ-123 -m \"Focus on data validation.\""
182
+ opts.separator " n2b-diff --analyze --github org/repo/issues/42 --update"
183
+ end
184
+
185
+ begin
186
+ parser.parse!(@args)
187
+ rescue OptionParser::InvalidOption => e
188
+ puts "#{COLOR_RED}Error: #{e.message}#{COLOR_RESET}"
189
+ puts parser.help
190
+ exit 1
191
+ end
192
+
193
+ # --- Option Validations ---
194
+ if options[:analyze]
195
+ # Options that only make sense with analyze
196
+ else
197
+ # Options that don't make sense without analyze (if any)
198
+ if options[:branch] || options[:jira_ticket] || options[:github_issue] || options[:requirements_file] || options[:custom_message] || !options[:update_issue].nil?
199
+ puts "#{COLOR_RED}Error: Options like --branch, --jira, --github, --requirements, --message, --update/--no-update are only for --analyze mode.#{COLOR_RESET}"
200
+ puts parser.help
201
+ exit 1
202
+ end
203
+ end
204
+
205
+ if options[:update_issue] == true && options[:jira_ticket].nil? && options[:github_issue].nil?
206
+ puts "#{COLOR_RED}Error: --update option requires --jira or --github to be specified for context.#{COLOR_RESET}"
207
+ puts parser.help
208
+ exit 1
209
+ end
210
+
211
+ # If --no-update is used without specifying a ticket, it's not an error, just has no effect.
212
+ # It's fine for --jira or --github to be specified without --update or --no-update (implies ask user)
213
+
214
+ # Perform sanitization and logging for custom_message after all options are parsed.
215
+ if options[:custom_message]
216
+ # Sanitize the already validated (potentially truncated) message
217
+ sanitized_message = N2B::MessageUtils.sanitize_message(options[:custom_message])
218
+ options[:custom_message] = sanitized_message # Store the sanitized version
219
+
220
+ # Log the final message that will be used
221
+ N2B::MessageUtils.log_message("Using custom analysis message: \"#{options[:custom_message]}\"", :info) unless options[:custom_message].strip.empty?
107
222
  end
108
- parser.parse!(@args)
223
+
109
224
  options
110
225
  end
111
226
 
227
+ # --- Methods moved and adapted from original N2B::CLI for diff analysis ---
228
+ def handle_diff_analysis(config)
229
+ vcs_type = get_vcs_type
230
+ if vcs_type == :none
231
+ puts "Error: Not a git or hg repository. Diff analysis requires a VCS."
232
+ exit 1
233
+ end
234
+
235
+ requirements_filepath = @options[:requirements_file] # Adapted option name
236
+ # user_prompt_addition now directly uses the processed @options[:custom_message]
237
+ user_prompt_addition = @options[:custom_message] || ""
238
+
239
+ # Logging of custom_message is now handled in parse_options by MessageUtils.log_message
240
+ # So, the specific puts here can be removed or changed to debug level if needed.
241
+ # For now, removing:
242
+ # if @options[:custom_message] && !@options[:custom_message].strip.empty?
243
+ # puts "DEBUG: Custom message for analysis: #{@options[:custom_message]}" # Or use MessageUtils.log_message if preferred for debug
244
+ # end
245
+
246
+ # Ticket / Issue Information - Standardize to use jira_ticket and github_issue
247
+ # The original cli.rb used @options[:jira_ticket] for both. Here we differentiate.
248
+ ticket_input = @options[:jira_ticket] || @options[:github_issue]
249
+ ticket_type = @options[:jira_ticket] ? 'jira' : (@options[:github_issue] ? 'github' : nil)
250
+ # ticket_update_flag from @options[:update_issue] (true, false, or nil)
251
+ # nil means ask, true means update, false means no update.
252
+ ticket_update_flag = @options[:update_issue]
253
+
254
+
255
+ requirements_content = nil
256
+
257
+ if ticket_input && ticket_type
258
+ # Determine which issue tracker based on which option was provided
259
+ # Default to 'jira' if somehow ticket_type is nil but ticket_input exists (should not happen)
260
+ tracker_service_name = ticket_type || (config['issue_tracker'] || 'jira')
261
+
262
+ case tracker_service_name
263
+ when 'github'
264
+ puts "GitHub issue specified: #{ticket_input}"
265
+ if config['github'] && config['github']['repo'] && config['github']['access_token']
266
+ begin
267
+ github_client = N2B::GitHubClient.new(config) # Ensure N2B::GitHubClient is available
268
+ puts "Fetching GitHub issue details..."
269
+ requirements_content = github_client.fetch_issue(ticket_input) # ticket_input here is ID/URL
270
+ puts "Successfully fetched GitHub issue details."
271
+ rescue StandardError => e
272
+ puts "Error fetching GitHub issue: #{e.message}"
273
+ puts "Proceeding with diff analysis without GitHub issue details."
274
+ end
275
+ else
276
+ puts "GitHub configuration is missing or incomplete in N2B settings."
277
+ puts "Please configure GitHub using 'n2b -c' (or main n2b config) to fetch issue details."
278
+ puts "Proceeding with diff analysis without GitHub issue details."
279
+ end
280
+ when 'jira'
281
+ puts "Jira ticket specified: #{ticket_input}"
282
+ if config['jira'] && config['jira']['domain'] && config['jira']['email'] && config['jira']['api_key']
283
+ begin
284
+ jira_client = N2B::JiraClient.new(config) # Ensure N2B::JiraClient is available
285
+ puts "Fetching Jira ticket details..."
286
+ requirements_content = jira_client.fetch_ticket(ticket_input)
287
+ puts "Successfully fetched Jira ticket details."
288
+ rescue N2B::JiraClient::JiraApiError => e
289
+ puts "Error fetching Jira ticket: #{e.message}"
290
+ puts "Proceeding with diff analysis without Jira ticket details."
291
+ rescue ArgumentError => e # Catches config errors from JiraClient init
292
+ puts "Jira configuration error: #{e.message}"
293
+ puts "Please ensure Jira is configured correctly."
294
+ puts "Proceeding with diff analysis without Jira ticket details."
295
+ rescue StandardError => e
296
+ puts "An unexpected error occurred while fetching Jira ticket: #{e.message}"
297
+ puts "Proceeding with diff analysis without Jira ticket details."
298
+ end
299
+ else
300
+ puts "Jira configuration is missing or incomplete in N2B settings."
301
+ puts "Please configure Jira using 'n2b -c' (or main n2b config) to fetch ticket details."
302
+ puts "Proceeding with diff analysis without Jira ticket details."
303
+ end
304
+ end
305
+ # Common message for ticket update status based on new flag
306
+ if ticket_update_flag == true
307
+ puts "Note: Issue update is flagged (--update)."
308
+ elsif ticket_update_flag == false
309
+ puts "Note: Issue will not be updated (--no-update)."
310
+ else # nil case
311
+ puts "Note: You will be prompted whether to update the issue after analysis."
312
+ end
313
+ end
314
+
315
+
316
+ if requirements_content.nil? && requirements_filepath
317
+ if File.exist?(requirements_filepath)
318
+ puts "Loading requirements from file: #{requirements_filepath}"
319
+ requirements_content = File.read(requirements_filepath)
320
+ else
321
+ puts "Error: Requirements file not found: #{requirements_filepath}"
322
+ puts "Proceeding with diff analysis without file-based requirements."
323
+ end
324
+ elsif requirements_content && requirements_filepath
325
+ puts "Note: Both issue details and a requirements file were provided. Using issue details for analysis context."
326
+ end
327
+
328
+ diff_output = execute_vcs_diff(vcs_type, @options[:branch])
329
+ if diff_output.nil? || diff_output.strip.empty?
330
+ puts "No differences found to analyze."
331
+ exit 0
332
+ end
333
+
334
+ # Pass user_prompt_addition (custom_message) to analyze_diff
335
+ analysis_result = analyze_diff(diff_output, config, user_prompt_addition, requirements_content)
336
+
337
+ # --- Ticket Update Logic (adapted) ---
338
+ if ticket_input && ticket_type && analysis_result && !analysis_result.empty?
339
+ tracker_service_name = ticket_type
340
+
341
+ # Determine if we should proceed with update based on ticket_update_flag
342
+ proceed_with_update_action = false
343
+ if ticket_update_flag == true # --update
344
+ proceed_with_update_action = true
345
+ elsif ticket_update_flag.nil? # Ask user
346
+ puts "\nWould you like to update #{tracker_service_name.capitalize} issue #{ticket_input} with this analysis? (y/n)"
347
+ user_choice = $stdin.gets.chomp.downcase
348
+ proceed_with_update_action = user_choice == 'y'
349
+ end # If ticket_update_flag is false, proceed_with_update_action remains false
350
+
351
+ if proceed_with_update_action
352
+ case tracker_service_name
353
+ when 'github'
354
+ if config['github'] && config['github']['repo'] && config['github']['access_token']
355
+ # Pass custom_message to formatting function
356
+ comment_data = format_analysis_for_github(analysis_result, @options[:custom_message])
357
+ begin
358
+ update_client = N2B::GitHubClient.new(config)
359
+ puts "Updating GitHub issue #{ticket_input}..."
360
+ if update_client.update_issue(ticket_input, comment_data) # ticket_input is ID/URL
361
+ puts "GitHub issue #{ticket_input} updated successfully."
362
+ else
363
+ puts "Failed to update GitHub issue #{ticket_input}."
364
+ end
365
+ rescue StandardError => e
366
+ puts "Error updating GitHub issue: #{e.message}"
367
+ end
368
+ else
369
+ puts "GitHub configuration is missing. Cannot update GitHub issue."
370
+ end
371
+ when 'jira'
372
+ if config['jira'] && config['jira']['domain'] && config['jira']['email'] && config['jira']['api_key']
373
+ # Pass custom_message to formatting function
374
+ jira_comment_data = format_analysis_for_jira(analysis_result, @options[:custom_message])
375
+ begin
376
+ update_jira_client = N2B::JiraClient.new(config)
377
+ puts "Updating Jira ticket #{ticket_input}..."
378
+ if update_jira_client.update_ticket(ticket_input, jira_comment_data)
379
+ puts "Jira ticket #{ticket_input} updated successfully."
380
+ else
381
+ puts "Failed to update Jira ticket #{ticket_input}."
382
+ end
383
+ rescue N2B::JiraClient::JiraApiError => e
384
+ puts "Error updating Jira ticket: #{e.message}"
385
+ rescue ArgumentError => e
386
+ puts "Jira configuration error for update: #{e.message}"
387
+ rescue StandardError => e
388
+ puts "An unexpected error occurred while updating Jira ticket: #{e.message}"
389
+ end
390
+ else
391
+ puts "Jira configuration is missing. Cannot update Jira ticket."
392
+ end
393
+ end
394
+ else
395
+ puts "Issue/Ticket update skipped."
396
+ end
397
+ elsif ticket_input && (analysis_result.nil? || analysis_result.empty?)
398
+ puts "Skipping ticket update as analysis result was empty or not generated."
399
+ end
400
+ # --- End of Ticket Update Logic ---
401
+ analysis_result # Return for potential further use or testing
402
+ end
403
+
404
+ def get_vcs_type
405
+ if Dir.exist?(File.join(Dir.pwd, '.git'))
406
+ :git
407
+ elsif Dir.exist?(File.join(Dir.pwd, '.hg'))
408
+ :hg
409
+ else
410
+ :none
411
+ end
412
+ end
413
+
414
+ def execute_vcs_diff(vcs_type, branch_option = nil)
415
+ # Ensure shell commands are properly escaped if branch_option comes from user input
416
+ # Though OptionParser should handle this for fixed options.
417
+ # For this method, branch_option is generally safe as it's from parsed options.
418
+
419
+ case vcs_type
420
+ when :git
421
+ target_branch_name = branch_option == 'auto' ? detect_git_default_branch : branch_option
422
+ if target_branch_name
423
+ unless validate_git_branch_exists(target_branch_name)
424
+ puts "Error: Git branch '#{target_branch_name}' does not exist or is not accessible."
425
+ # Suggest available branches if possible (might be noisy, consider a debug flag)
426
+ # puts "Available local branches:\n`git branch`"
427
+ # puts "Available remote branches for 'origin':\n`git branch -r`"
428
+ exit 1
429
+ end
430
+ puts "Comparing current HEAD against git branch '#{target_branch_name}'..."
431
+ # Use three dots for "changes on your branch since target_branch_name diverged from it"
432
+ # Or two dots for "changes between target_branch_name and your current HEAD"
433
+ # Three dots is common for PR-like diffs.
434
+ `git diff #{Shellwords.escape(target_branch_name)}...HEAD`
435
+ else
436
+ puts "Could not automatically detect default git branch and no branch specified. Falling back to 'git diff HEAD' (staged changes)..."
437
+ `git diff HEAD` # This shows staged changes. `git diff` shows unstaged.
438
+ end
439
+ when :hg
440
+ target_branch_name = branch_option == 'auto' ? detect_hg_default_branch : branch_option
441
+ if target_branch_name
442
+ unless validate_hg_branch_exists(target_branch_name)
443
+ puts "Error: Mercurial branch '#{target_branch_name}' does not exist."
444
+ exit 1
445
+ end
446
+ puts "Comparing current working directory against hg branch '#{target_branch_name}'..."
447
+ # For hg, diff against the revision of the branch tip
448
+ `hg diff -r #{Shellwords.escape(target_branch_name)}`
449
+ else
450
+ puts "Could not automatically detect default hg branch and no branch specified. Falling back to 'hg diff' (uncommitted changes)..."
451
+ `hg diff` # Shows uncommitted changes.
452
+ end
453
+ else
454
+ puts "Error: Unsupported VCS type." # Should be caught by get_vcs_type check earlier
455
+ ""
456
+ end
457
+ end
458
+
459
+ def detect_git_default_branch
460
+ # Method 1: Check origin/HEAD symbolic ref (most reliable for remotes)
461
+ result = `git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null`.strip
462
+ return result.split('/').last if $?.success? && !result.empty?
463
+
464
+ # Method 2: Check local 'main' or 'master' if they track a remote branch
465
+ ['main', 'master'].each do |branch|
466
+ local_tracking = `git rev-parse --abbrev-ref #{branch}@{upstream} 2>/dev/null`.strip
467
+ return branch if $?.success? && !local_tracking.empty?
468
+ end
469
+
470
+ # Method 3: Check remote show origin for HEAD branch (less direct but good fallback)
471
+ result = `git remote show origin 2>/dev/null | grep "HEAD branch"`.strip
472
+ if $?.success? && !result.empty?
473
+ match = result.match(/HEAD branch:\s*(\S+)/)
474
+ return match[1] if match && match[1] != '(unknown)'
475
+ end
476
+
477
+ # Method 4: Last resort, check for local main/master even if not tracking
478
+ ['main', 'master'].each do |branch|
479
+ return branch if system("git show-ref --verify --quiet refs/heads/#{branch}")
480
+ end
481
+ nil # No default branch found
482
+ end
483
+
484
+ def detect_hg_default_branch
485
+ # Mercurial typically uses 'default' as the main branch.
486
+ # `hg branch` shows the current branch. If it's 'default', that's a strong candidate.
487
+ current_branch = `hg branch 2>/dev/null`.strip
488
+ return current_branch if $?.success? && current_branch == 'default'
489
+
490
+ # Check if 'default' branch exists in the list of branches
491
+ branches_output = `hg branches 2>/dev/null`
492
+ if $?.success? && branches_output.lines.any? { |line| line.strip.start_with?('default ') }
493
+ return 'default'
494
+ end
495
+
496
+ # Fallback if 'default' isn't found but there's only one branch (common in simple repos)
497
+ if $?.success?
498
+ active_branches = branches_output.lines.reject { |line| line.include?('(inactive)') }
499
+ if active_branches.size == 1
500
+ return active_branches.first.split.first
501
+ end
502
+ end
503
+
504
+ # If current branch is not 'default' but exists, it might be the one being used as main.
505
+ return current_branch if $?.success? && !current_branch.empty?
506
+
507
+ 'default' # Default assumption for Mercurial
508
+ end
509
+
510
+ def validate_git_branch_exists(branch)
511
+ # Check local branches
512
+ return true if system("git show-ref --verify --quiet refs/heads/#{Shellwords.escape(branch)}")
513
+ # Check remote branches (assuming 'origin' remote)
514
+ return true if system("git show-ref --verify --quiet refs/remotes/origin/#{Shellwords.escape(branch)}")
515
+ false
516
+ end
517
+
518
+ def validate_hg_branch_exists(branch)
519
+ # `hg branches` lists all branches. Check if the branch is in the list.
520
+ # `hg id -r branchname` would fail if branch doesn't exist.
521
+ system("hg id -r #{Shellwords.escape(branch)} > /dev/null 2>&1")
522
+ end
523
+
524
+ def build_diff_analysis_prompt(diff_output, user_prompt_addition = "", requirements_content = nil, config = {})
525
+ default_system_prompt_path = resolve_template_path('diff_system_prompt', config) # Ensure this template exists
526
+ default_system_prompt = File.read(default_system_prompt_path).strip rescue "Analyze this diff." # Fallback
527
+
528
+ # user_prompt_addition is @options[:custom_message], already validated and sanitized.
529
+ user_instructions_section = ""
530
+ if user_prompt_addition && !user_prompt_addition.to_s.strip.empty?
531
+ # No need to call .strip here again as sanitize_message in parse_options already does it.
532
+ user_instructions_section = "User's Custom Instructions:\n#{user_prompt_addition}\n\n---\n\n"
533
+ end
534
+
535
+ requirements_section = ""
536
+ if requirements_content && !requirements_content.to_s.strip.empty?
537
+ requirements_section = <<-REQUIREMENTS_BLOCK
538
+ CRITICAL REQUIREMENTS EVALUATION:
539
+ Based on the provided context, evaluate if the code changes meet these requirements.
540
+ For each requirement, explicitly state whether it is:
541
+ - ✅ IMPLEMENTED
542
+ - ⚠️ PARTIALLY IMPLEMENTED
543
+ - ❌ NOT IMPLEMENTED
544
+ - 🔍 UNCLEAR
545
+
546
+ --- BEGIN REQUIREMENTS ---
547
+ #{requirements_content.strip}
548
+ --- END REQUIREMENTS ---
549
+
550
+ REQUIREMENTS_BLOCK
551
+ end
552
+
553
+ analysis_intro = "Analyze the following code diff. Focus on identifying potential bugs, suggesting improvements, and assessing test coverage. If requirements are provided, evaluate against them."
554
+
555
+ # Context extraction (can be intensive, consider making optional or smarter)
556
+ # context_sections = extract_code_context_from_diff(diff_output) # This can be slow
557
+ # context_info = context_sections.empty? ? "" : "\n\nRelevant Code Context:\n#{format_context_for_prompt(context_sections)}"
558
+ # For now, let's keep context extraction simpler or rely on LLM's ability with just the diff.
559
+ # If extract_code_context_from_diff is too complex or slow, it might be omitted or simplified.
560
+ # For this integration, assuming extract_code_context_from_diff is efficient enough.
561
+ context_sections = extract_code_context_from_diff(diff_output)
562
+ context_info = ""
563
+ unless context_sections.empty?
564
+ context_info = "\n\nCurrent Code Context (for better analysis):\n"
565
+ context_sections.each do |file_path, sections|
566
+ context_info += "\n--- Context from: #{file_path} ---\n"
567
+ sections.each do |section|
568
+ context_info += "Lines approx #{section[:start_line]}-#{section[:end_line]}:\n" # Approximate lines
569
+ context_info += "```\n#{section[:content]}\n```\n\n"
570
+ end
571
+ end
572
+ end
573
+
574
+
575
+ json_instruction_path = resolve_template_path('diff_json_instruction', config) # Ensure this template exists
576
+ json_instruction = File.read(json_instruction_path).strip rescue 'Respond in JSON format with keys: "summary", "errors", "improvements", "test_coverage", "requirements_evaluation", "ticket_implementation_summary".' # Fallback
577
+
578
+ full_prompt = [
579
+ user_instructions_section, # Custom instructions first
580
+ default_system_prompt,
581
+ requirements_section,
582
+ analysis_intro,
583
+ "Diff:\n```diff\n#{diff_output}\n```", # Ensure diff is marked as diff language
584
+ context_info,
585
+ json_instruction
586
+ ].select { |s| s && !s.strip.empty? }.join("\n\n")
587
+
588
+ full_prompt
589
+ end
590
+
591
+ def analyze_diff(diff_output, config, user_prompt_addition = "", requirements_content = nil)
592
+ # user_prompt_addition here IS the custom_message from options
593
+ prompt = build_diff_analysis_prompt(diff_output, user_prompt_addition, requirements_content, config)
594
+
595
+ # Use analyze_diff_with_spinner for this call
596
+ analysis_json_str = analyze_diff_with_spinner(config) do |llm| # Pass config to spinner wrapper
597
+ llm.analyze_code_diff(prompt) # This is the actual call the spinner will make
598
+ end
599
+ # Fallback if spinner itself has an issue or if LLM call within spinner fails before LlmApiError
600
+ analysis_json_str ||= '{"summary": "Error: Failed to get analysis from LLM.", "errors": [], "improvements": []}'
601
+
602
+
603
+ begin
604
+ json_content = extract_json_from_response(analysis_json_str)
605
+ analysis_result = JSON.parse(json_content)
606
+
607
+ # Output formatting (consider making this a separate method or class)
608
+ puts "\n#{COLOR_BLUE}📊 Code Diff Analysis:#{COLOR_RESET}"
609
+ puts "-------------------"
610
+ puts "#{COLOR_GREEN}Summary:#{COLOR_RESET}"
611
+ puts analysis_result['summary'] || "No summary provided."
612
+
613
+ puts "\n#{COLOR_RED}Potential Issues/Errors:#{COLOR_RESET}"
614
+ errors_list = [analysis_result['errors']].flatten.compact.reject(&:empty?)
615
+ puts errors_list.any? ? errors_list.map { |err| "- #{err}" }.join("\n") : " No specific errors identified."
616
+
617
+ puts "\n#{COLOR_YELLOW}Suggested Improvements:#{COLOR_RESET}"
618
+ improvements_list = [analysis_result['improvements']].flatten.compact.reject(&:empty?)
619
+ puts improvements_list.any? ? improvements_list.map { |imp| "- #{imp}" }.join("\n") : " No specific improvements suggested."
620
+
621
+ puts "\n#{COLOR_BLUE}Test Coverage Assessment:#{COLOR_RESET}"
622
+ test_coverage = analysis_result['test_coverage']
623
+ puts test_coverage && !test_coverage.to_s.strip.empty? ? test_coverage : " No test coverage assessment provided."
624
+
625
+ if requirements_content && !requirements_content.to_s.strip.empty?
626
+ puts "\n#{COLOR_GREEN}Requirements Evaluation:#{COLOR_RESET}"
627
+ eval_text = analysis_result['requirements_evaluation']
628
+ puts eval_text && !eval_text.to_s.strip.empty? ? eval_text : " No requirements evaluation provided."
629
+ end
630
+
631
+ # This field is often more specific for tickets
632
+ ticket_summary = analysis_result['ticket_implementation_summary']
633
+ if ticket_summary && !ticket_summary.to_s.strip.empty?
634
+ puts "\n#{COLOR_GREEN}Ticket Implementation Summary:#{COLOR_RESET}"
635
+ puts ticket_summary
636
+ end
637
+
638
+ puts "-------------------"
639
+ return analysis_result
640
+ rescue JSON::ParserError => e
641
+ puts "#{COLOR_RED}Critical Error: Failed to parse JSON response for diff analysis: #{e.message}#{COLOR_RESET}"
642
+ puts "Raw response was: #{analysis_json_str}"
643
+ # Fallback if parsing fails
644
+ return {"summary" => "Failed to parse analysis.", "errors" => ["JSON parsing error."], "improvements" => [], "test_coverage" => "Unknown", "requirements_evaluation" => "Unknown", "ticket_implementation_summary" => "Unknown"}
645
+ end
646
+ end
647
+
648
+ # Extracted from original N2B::CLI, might need slight path adjustments if templates are structured differently
649
+ # def resolve_template_path(template_key, config)
650
+ # user_path = config.dig('templates', template_key) if config.is_a?(Hash)
651
+ # return user_path if user_path && File.exist?(user_path)
652
+ # # Assuming templates are in a subdir relative to THIS file (merge_cli.rb)
653
+ # File.expand_path(File.join(__dir__, 'templates', "#{template_key}.txt"))
654
+ # end
655
+
656
+
657
+ def format_analysis_for_jira(analysis_result, custom_message = nil)
658
+ # Basic check, ensure analysis_result is a hash
659
+ return { body: "Error: Analysis result is not in the expected format." } unless analysis_result.is_a?(Hash)
660
+
661
+ summary = analysis_result['ticket_implementation_summary']&.strip || analysis_result['summary']&.strip || "No summary."
662
+
663
+ details_parts = []
664
+ details_parts << "*Custom Analysis Focus:*\n#{custom_message}\n" if custom_message && !custom_message.empty?
665
+ details_parts << "*Technical Summary:*\n#{analysis_result['summary']&.strip}\n" if analysis_result['summary']
666
+
667
+ issues = format_issues_for_adf(analysis_result['errors'])
668
+ improvements = format_improvements_for_adf(analysis_result['improvements'])
669
+
670
+ details_parts << "*Potential Issues:*\n" + (issues.empty? ? "None identified.\n" : issues.map { |i| "- #{i}\n" }.join)
671
+ details_parts << "*Suggested Improvements:*\n" + (improvements.empty? ? "None identified.\n" : improvements.map { |i| "- #{i}\n" }.join)
672
+ details_parts << "*Test Coverage Assessment:*\n#{analysis_result['test_coverage']&.strip || "Not assessed."}\n"
673
+
674
+ req_eval = analysis_result['requirements_evaluation']&.strip
675
+ details_parts << "*Requirements Evaluation:*\n#{req_eval}\n" if req_eval && !req_eval.empty?
676
+
677
+ # ADF structure for Jira
678
+ # This is a simplified version. Real ADF is more complex.
679
+ # N2B::JiraClient would need to construct the actual ADF document.
680
+ # Here, we are returning a hash that the client can use.
681
+ {
682
+ implementation_summary: summary, # Often used as a primary comment or field
683
+ technical_summary: analysis_result['summary']&.strip,
684
+ issues: issues, # Array of strings
685
+ improvements: improvements, # Array of strings
686
+ test_coverage: analysis_result['test_coverage']&.strip,
687
+ requirements_evaluation: req_eval,
688
+ custom_analysis_focus: custom_message # Added new key
689
+ }
690
+ end
691
+
692
+ def format_issues_for_adf(errors) # Helper for Jira/GitHub formatting
693
+ return [] unless errors.is_a?(Array) && errors.any?
694
+ errors.map(&:strip).reject(&:empty?)
695
+ end
696
+
697
+ def format_improvements_for_adf(improvements) # Helper for Jira/GitHub formatting
698
+ return [] unless improvements.is_a?(Array) && improvements.any?
699
+ improvements.map(&:strip).reject(&:empty?)
700
+ end
701
+
702
+ def format_analysis_for_github(analysis_result, custom_message = nil)
703
+ # Basic check
704
+ return "Error: Analysis result is not in the expected format." unless analysis_result.is_a?(Hash)
705
+
706
+ title = "### Code Diff Analysis Summary 🤖\n"
707
+ summary_section = "**Implementation/Overall Summary:**\n#{analysis_result['ticket_implementation_summary']&.strip || analysis_result['summary']&.strip || "No summary provided."}\n\n"
708
+
709
+ details_parts = []
710
+ details_parts << "**Custom Analysis Focus:**\n#{custom_message}\n" if custom_message && !custom_message.empty?
711
+ details_parts << "**Technical Detail Summary:**\n#{analysis_result['summary']&.strip}\n" if analysis_result['summary'] && analysis_result['summary'] != analysis_result['ticket_implementation_summary']
712
+
713
+
714
+ errors_list = [analysis_result['errors']].flatten.compact.reject(&:empty?)
715
+ details_parts << "**Potential Issues/Errors:**\n" + (errors_list.empty? ? "_None identified._\n" : errors_list.map { |err| "- [ ] #{err}\n" }.join) # Added checkbox
716
+
717
+ improvements_list = [analysis_result['improvements']].flatten.compact.reject(&:empty?)
718
+ details_parts << "**Suggested Improvements:**\n" + (improvements_list.empty? ? "_None identified._\n" : improvements_list.map { |imp| "- [ ] #{imp}\n" }.join) # Added checkbox
719
+
720
+ details_parts << "**Test Coverage Assessment:**\n#{analysis_result['test_coverage']&.strip || "_Not assessed._"}\n"
721
+
722
+ req_eval = analysis_result['requirements_evaluation']&.strip
723
+ details_parts << "**Requirements Evaluation:**\n#{req_eval.empty? ? "_Not applicable or not assessed._" : req_eval}\n" if req_eval
724
+
725
+ # For GitHub, typically a Markdown string is returned for the comment body.
726
+ # The N2B::GitHubClient would take this string.
727
+ # Returning a hash that the client can use to build the comment.
728
+ {
729
+ title: "Code Diff Analysis Summary 🤖",
730
+ implementation_summary: analysis_result['ticket_implementation_summary']&.strip || analysis_result['summary']&.strip,
731
+ technical_summary: analysis_result['summary']&.strip,
732
+ issues: errors_list, # Array of strings
733
+ improvements: improvements_list, # Array of strings
734
+ test_coverage: analysis_result['test_coverage']&.strip,
735
+ requirements_evaluation: req_eval,
736
+ custom_analysis_focus: custom_message, # Added new key
737
+ # Construct a body for convenience, client can override
738
+ body: title + summary_section + details_parts.join("\n")
739
+ }
740
+ end
741
+
742
+ def extract_json_from_response(response)
743
+ # This is a robust way to extract JSON that might be embedded in other text.
744
+ # First, try to parse as-is, in case the LLM behaves perfectly.
745
+ begin
746
+ JSON.parse(response)
747
+ return response # It's already valid JSON
748
+ rescue JSON::ParserError
749
+ # If not, search for the first '{' and last '}'
750
+ end
751
+
752
+ json_start = response.index('{')
753
+ json_end = response.rindex('}') # Use rindex for the last occurrence
754
+
755
+ if json_start && json_end && json_end > json_start
756
+ potential_json = response[json_start..json_end]
757
+ begin
758
+ JSON.parse(potential_json) # Check if this substring is valid JSON
759
+ return potential_json
760
+ rescue JSON::ParserError
761
+ # Fallback: if the strict extraction fails, return the original response
762
+ # and let the caller deal with a more comprehensive repair or error.
763
+ # This can happen if there are '{' or '}' in string literals within the JSON.
764
+ # A more sophisticated parser would be needed for those cases.
765
+ # For now, this is a common heuristic.
766
+ return response # Or perhaps an error string/nil
767
+ end
768
+ else
769
+ # No clear JSON structure found, return original response for error handling
770
+ return response
771
+ end
772
+ end
773
+
774
+ def extract_code_context_from_diff(diff_output, lines_of_context: 5)
775
+ # Simplified context extraction focusing on lines around changes.
776
+ # This is a placeholder for potentially more sophisticated context extraction.
777
+ # A full AST parse or more complex diff parsing could be used for richer context.
778
+ context_sections = {}
779
+ current_file = nil
780
+ file_lines_buffer = {} # Cache for file lines
781
+
782
+ # First pass: identify files and changed line numbers from hunk headers
783
+ # @@ -old_start,old_lines +new_start,new_lines
784
+ hunks_by_file = {}
785
+ diff_output.each_line do |line|
786
+ line.chomp!
787
+ if line.start_with?('diff --git')
788
+ # Extracts 'b_file_path' from "diff --git a/a_file_path b/b_file_path"
789
+ match = line.match(/diff --git a\/(.+?) b\/(.+)/)
790
+ current_file = match[2] if match && match[2] != '/dev/null'
791
+ elsif line.start_with?('+++ b/')
792
+ # Extracts file path from "+++ b/file_path"
793
+ current_file = line[6..].strip unless line.include?('/dev/null')
794
+ elsif line.start_with?('@@') && current_file
795
+ match = line.match(/@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/)
796
+ next unless match
797
+ start_line = match[1].to_i
798
+ # hunk_lines = match[2]&.to_i || 1 # Number of lines in the hunk in the new file
799
+ # For context, we care more about the range of lines around the start_line of the change.
800
+ hunks_by_file[current_file] ||= []
801
+ hunks_by_file[current_file] << { new_start: start_line }
802
+ end
803
+ end
804
+
805
+ # Second pass: extract context for each hunk
806
+ hunks_by_file.each do |file_path, hunks|
807
+ next unless File.exist?(file_path) # Ensure file exists to read context from
808
+
809
+ file_lines_buffer[file_path] ||= File.readlines(file_path, chomp: true)
810
+ all_lines = file_lines_buffer[file_path]
811
+
812
+ hunks.each do |hunk|
813
+ context_start_line = [1, hunk[:new_start] - lines_of_context].max
814
+ # Estimate end of change based on typical hunk sizes or simplify
815
+ # For simplicity, take fixed lines after start, or up to next hunk.
816
+ # This is a very rough heuristic.
817
+ context_end_line = [all_lines.length, hunk[:new_start] + lines_of_context + 5].min # +5 for some change content
818
+
819
+ actual_start_index = context_start_line - 1
820
+ actual_end_index = context_end_line - 1
821
+
822
+ if actual_start_index < all_lines.length && actual_start_index <= actual_end_index
823
+ section_content = all_lines[actual_start_index..actual_end_index].join("\n")
824
+ context_sections[file_path] ||= []
825
+ # Avoid duplicate sections if hunks are very close
826
+ unless context_sections[file_path].any? { |s| s[:content] == section_content }
827
+ context_sections[file_path] << {
828
+ # Store actual line numbers for reference, not just indices
829
+ start_line: context_start_line,
830
+ end_line: context_end_line,
831
+ content: section_content
832
+ }
833
+ end
834
+ end
835
+ end
836
+ end
837
+ context_sections
838
+ end
839
+
840
+ # Spinner method specifically for diff analysis
841
+ def analyze_diff_with_spinner(config) # Takes config to initialize LLM
842
+ llm_service_name = config['llm']
843
+ llm = case llm_service_name # Initialize LLM based on config
844
+ when 'openai' then N2B::Llm::OpenAi.new(config)
845
+ when 'claude' then N2B::Llm::Claude.new(config)
846
+ when 'gemini' then N2B::Llm::Gemini.new(config)
847
+ when 'vertexai' then N2B::Llm::VertexAi.new(config)
848
+ when 'openrouter' then N2B::Llm::OpenRouter.new(config)
849
+ when 'ollama' then N2B::Llm::Ollama.new(config)
850
+ else raise N2B::Error, "Unsupported LLM service for analysis: #{llm_service_name}"
851
+ end
852
+
853
+ spinner_chars = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
854
+ spinner_thread = Thread.new do
855
+ i = 0
856
+ loop do
857
+ print "\r#{COLOR_BLUE}🔍 #{spinner_chars[i % spinner_chars.length]} Analyzing diff...#{COLOR_RESET}"
858
+ $stdout.flush
859
+ sleep(0.1)
860
+ i += 1
861
+ end
862
+ end
863
+
864
+ begin
865
+ # The block passed to this method contains the actual LLM call
866
+ result = yield(llm) if block_given?
867
+ spinner_thread.kill
868
+ spinner_thread.join # Ensure thread is fully cleaned up
869
+ print "\r#{' ' * 35}\r" # Clear spinner line
870
+ puts "#{COLOR_GREEN}✅ Diff analysis complete!#{COLOR_RESET}"
871
+ result
872
+ rescue N2B::LlmApiError => e
873
+ spinner_thread.kill
874
+ spinner_thread.join
875
+ print "\r#{' ' * 35}\r"
876
+ puts "#{COLOR_RED}LLM API Error during diff analysis: #{e.message}#{COLOR_RESET}"
877
+ # Provide specific model error guidance
878
+ if e.message.match?(/model|invalid|not found/i)
879
+ puts "#{COLOR_YELLOW}This might be due to an invalid or unsupported model in your config. Run 'n2b -c' to reconfigure.#{COLOR_RESET}"
880
+ end
881
+ # Return a structured error JSON
882
+ '{"summary": "Error: Could not analyze diff due to LLM API error.", "errors": ["#{e.message}"], "improvements": []}'
883
+ rescue StandardError => e # Catch other unexpected errors during the yield or LLM call
884
+ spinner_thread.kill
885
+ spinner_thread.join
886
+ print "\r#{' ' * 35}\r"
887
+ puts "#{COLOR_RED}Unexpected error during diff analysis: #{e.message}#{COLOR_RESET}"
888
+ '{"summary": "Error: Unexpected failure during diff analysis.", "errors": ["#{e.message}"], "improvements": []}'
889
+ end
890
+ end
891
+ # --- End of moved methods ---
892
+
893
+
112
894
  def resolve_block(block, config, full_file_content)
113
895
  comment = nil
114
896
 
@@ -121,44 +903,107 @@ module N2B
121
903
  suggestion = request_merge_with_spinner(block, config, comment, full_file_content)
122
904
  puts "#{COLOR_GREEN}✅ Initial suggestion ready!#{COLOR_RESET}\n"
123
905
 
124
- loop do
125
- print_conflict(block)
126
- print_suggestion(suggestion)
127
- print "#{COLOR_YELLOW}Accept [y], Skip [n], Comment [c], Edit [e], Abort [a] (explicit choice required): #{COLOR_RESET}"
128
- choice = $stdin.gets&.strip&.downcase
906
+ vcs_type = get_vcs_type_for_file_operations
129
907
 
130
- case choice
131
- when 'y'
132
- return {accepted: true, merged_code: suggestion['merged_code'], reason: suggestion['reason'], comment: comment}
133
- when 'n'
134
- return {accepted: false, merged_code: suggestion['merged_code'], reason: suggestion['reason'], comment: comment}
135
- when 'c'
136
- puts 'Enter comment (end with blank line):'
137
- comment = read_multiline_input
138
- puts "#{COLOR_YELLOW}🤖 AI is analyzing your comment and generating new suggestion...#{COLOR_RESET}"
139
- # Re-read file content in case it was edited previously
140
- fresh_file_content = File.read(@file_path)
141
- suggestion = request_merge_with_spinner(block, config, comment, fresh_file_content)
142
- puts "#{COLOR_GREEN}✅ New suggestion ready!#{COLOR_RESET}\n"
143
- when 'e'
144
- edit_result = handle_editor_workflow(block, config, full_file_content)
145
- if edit_result[:resolved]
146
- return {accepted: true, merged_code: edit_result[:merged_code], reason: edit_result[:reason], comment: comment}
147
- elsif edit_result[:updated_content]
148
- # File was changed but conflict not resolved, update content for future LLM calls
149
- full_file_content = edit_result[:updated_content]
908
+ # Convert conflict labels to actual VCS revision identifiers
909
+ base_revision = convert_label_to_revision(block.base_label, vcs_type, :base)
910
+ incoming_revision = convert_label_to_revision(block.incoming_label, vcs_type, :incoming)
911
+
912
+ base_content_full = get_file_content_from_vcs(base_revision, @file_path, vcs_type) || difficulté_to_load_content_placeholder("base content from #{block.base_label}")
913
+ incoming_content_full = get_file_content_from_vcs(incoming_revision, @file_path, vcs_type) || difficulté_to_load_content_placeholder("incoming content from #{block.incoming_label}")
914
+
915
+ generated_html_path = nil
916
+
917
+ begin
918
+ loop do
919
+ current_resolution_content_full = apply_hunk_to_full_content(full_file_content, block, suggestion['merged_code'])
920
+
921
+ # Don't delete the HTML file immediately - keep it available for user preview
922
+
923
+ generated_html_path = generate_conflict_preview_html(
924
+ block,
925
+ base_content_full,
926
+ incoming_content_full,
927
+ current_resolution_content_full,
928
+ block.base_label,
929
+ block.incoming_label,
930
+ @file_path,
931
+ suggestion
932
+ )
933
+
934
+ preview_link_message = ""
935
+ if generated_html_path && File.exist?(generated_html_path)
936
+ preview_link_message = "🌐 #{COLOR_BLUE}Preview: file://#{generated_html_path}#{COLOR_RESET}"
937
+ else
938
+ preview_link_message = "#{COLOR_YELLOW}⚠️ Could not generate HTML preview.#{COLOR_RESET}"
939
+ end
940
+ puts preview_link_message
941
+
942
+ print_conflict(block)
943
+ print_suggestion(suggestion)
944
+
945
+ prompt_message = <<~PROMPT
946
+ #{COLOR_YELLOW}Actions: [y] Accept, [n] Skip, [c] Comment, [e] Edit, [p] Preview, [s] Refresh, [a] Abort#{COLOR_RESET}
947
+ #{COLOR_GRAY}(Preview link above can be cmd/ctrl+clicked if your terminal supports it){COLOR_RESET}
948
+ #{COLOR_YELLOW}Your choice: #{COLOR_RESET}
949
+ PROMPT
950
+ print prompt_message
951
+ choice = $stdin.gets&.strip&.downcase
952
+
953
+ case choice
954
+ when 'y'
955
+ return {accepted: true, merged_code: suggestion['merged_code'], reason: suggestion['reason'], comment: comment}
956
+ when 'n'
957
+ return {accepted: false, merged_code: suggestion['merged_code'], reason: suggestion['reason'], comment: comment}
958
+ when 'c'
959
+ puts 'Enter comment (end with blank line):'
960
+ comment = read_multiline_input
961
+ puts "#{COLOR_YELLOW}🤖 AI is analyzing your comment and generating new suggestion...#{COLOR_RESET}"
962
+ current_file_on_disk = File.exist?(@file_path) ? File.read(@file_path) : full_file_content
963
+ full_file_content = current_file_on_disk
964
+ suggestion = request_merge_with_spinner(block, config, comment, full_file_content)
965
+ puts "#{COLOR_GREEN}✅ New suggestion ready!#{COLOR_RESET}\n"
966
+ # Loop continues, will regenerate preview
967
+ when 'e'
968
+ edit_result = handle_editor_workflow(block, config, full_file_content)
969
+ if edit_result[:resolved]
970
+ return {accepted: true, merged_code: edit_result[:merged_code], reason: edit_result[:reason], comment: comment}
971
+ elsif edit_result[:updated_content]
972
+ full_file_content = edit_result[:updated_content]
973
+ puts "#{COLOR_YELLOW}🤖 Content changed by editor. Re-analyzing for new suggestion...#{COLOR_RESET}"
974
+ suggestion = request_merge_with_spinner(block, config, comment, full_file_content)
975
+ end
976
+ # Loop continues, will regenerate preview
977
+ when 'p' # Open Preview in Browser
978
+ if generated_html_path && File.exist?(generated_html_path)
979
+ puts "#{COLOR_BLUE}🌐 Opening preview in browser...#{COLOR_RESET}"
980
+ open_html_in_browser(generated_html_path)
981
+ else
982
+ puts "#{COLOR_YELLOW}⚠️ No preview available to open.#{COLOR_RESET}"
983
+ end
984
+ # Loop continues, no changes to suggestion
985
+ when 's' # Refresh Preview
986
+ puts "#{COLOR_BLUE}🔄 Refreshing suggestion and preview...#{COLOR_RESET}"
987
+ suggestion = request_merge_with_spinner(block, config, comment, full_file_content)
988
+ # Loop continues, preview will be regenerated
989
+ when 'a'
990
+ return {abort: true, merged_code: suggestion['merged_code'], reason: suggestion['reason'], comment: comment}
991
+ when '', nil
992
+ puts "#{COLOR_RED}Please enter a valid choice.#{COLOR_RESET}"
993
+ else
994
+ puts "#{COLOR_RED}Invalid option. Please choose from the available actions.#{COLOR_RESET}"
150
995
  end
151
- # Continue the loop with potentially updated content
152
- when 'a'
153
- return {abort: true, merged_code: suggestion['merged_code'], reason: suggestion['reason'], comment: comment}
154
- when '', nil
155
- puts "#{COLOR_RED}Please enter a valid choice: y/n/c/e/a#{COLOR_RESET}"
156
- else
157
- puts "#{COLOR_RED}Invalid option. Please enter: y (accept), n (skip), c (comment), e (edit), or a (abort)#{COLOR_RESET}"
158
996
  end
997
+ ensure
998
+ FileUtils.rm_f(generated_html_path) if generated_html_path && File.exist?(generated_html_path)
159
999
  end
160
1000
  end
161
1001
 
1002
+ def difficulté_to_load_content_placeholder(description)
1003
+ # Helper to return a placeholder if VCS content fails, aiding debug in preview
1004
+ "N2B: Could not load #{description}. Displaying this placeholder."
1005
+ end
1006
+
162
1007
  def request_merge(block, config, comment, full_file_content)
163
1008
  prompt = build_merge_prompt(block, comment, full_file_content)
164
1009
  json_str = call_llm_for_merge(prompt, config)
@@ -219,6 +1064,8 @@ module N2B
219
1064
  user_comment_text = comment && !comment.empty? ? "User comment: #{comment}" : ""
220
1065
 
221
1066
  template.gsub('{full_file_content}', full_file_content.to_s)
1067
+ .gsub('{start_line}', block.start_line.to_s)
1068
+ .gsub('{end_line}', block.end_line.to_s)
222
1069
  .gsub('{context_before}', block.context_before.to_s)
223
1070
  .gsub('{base_label}', block.base_label.to_s)
224
1071
  .gsub('{base_content}', block.base_content.to_s)
@@ -232,15 +1079,17 @@ module N2B
232
1079
  llm_service_name = config['llm']
233
1080
  llm = case llm_service_name
234
1081
  when 'openai'
235
- N2M::Llm::OpenAi.new(config)
1082
+ N2B::Llm::OpenAi.new(config)
236
1083
  when 'claude'
237
- N2M::Llm::Claude.new(config)
1084
+ N2B::Llm::Claude.new(config)
238
1085
  when 'gemini'
239
- N2M::Llm::Gemini.new(config)
1086
+ N2B::Llm::Gemini.new(config)
1087
+ when 'vertexai'
1088
+ N2B::Llm::VertexAi.new(config)
240
1089
  when 'openrouter'
241
- N2M::Llm::OpenRouter.new(config)
1090
+ N2B::Llm::OpenRouter.new(config)
242
1091
  when 'ollama'
243
- N2M::Llm::Ollama.new(config)
1092
+ N2B::Llm::Ollama.new(config)
244
1093
  else
245
1094
  raise N2B::Error, "Unsupported LLM service: #{llm_service_name}"
246
1095
  end
@@ -408,11 +1257,25 @@ module N2B
408
1257
  end
409
1258
 
410
1259
  def print_conflict(block)
1260
+ # Show context before conflict for better understanding
1261
+ if block.context_before && !block.context_before.empty?
1262
+ puts "#{COLOR_GRAY}... context before ...#{COLOR_RESET}"
1263
+ context_lines = block.context_before.split("\n").last(3) # Show last 3 lines of context
1264
+ context_lines.each { |line| puts "#{COLOR_GRAY}#{line}#{COLOR_RESET}" }
1265
+ end
1266
+
411
1267
  puts "#{COLOR_RED}<<<<<<< #{block.base_label} (lines #{block.start_line}-#{block.end_line})#{COLOR_RESET}"
412
1268
  puts "#{COLOR_RED}#{block.base_content}#{COLOR_RESET}"
413
1269
  puts "#{COLOR_YELLOW}=======#{COLOR_RESET}"
414
1270
  puts "#{COLOR_GREEN}#{block.incoming_content}#{COLOR_RESET}"
415
1271
  puts "#{COLOR_YELLOW}>>>>>>> #{block.incoming_label}#{COLOR_RESET}"
1272
+
1273
+ # Show context after conflict for better understanding
1274
+ if block.context_after && !block.context_after.empty?
1275
+ context_lines = block.context_after.split("\n").first(3) # Show first 3 lines of context
1276
+ context_lines.each { |line| puts "#{COLOR_GRAY}#{line}#{COLOR_RESET}" }
1277
+ puts "#{COLOR_GRAY}... context after ...#{COLOR_RESET}"
1278
+ end
416
1279
  end
417
1280
 
418
1281
  def print_suggestion(sug)
@@ -493,50 +1356,68 @@ module N2B
493
1356
  end
494
1357
  end
495
1358
 
496
- def show_usage_and_unresolved
497
- puts "Usage: n2b-diff FILE [--context N]"
498
- puts ""
499
-
500
- # Show unresolved conflicts if in a VCS repository
501
- if File.exist?('.hg')
502
- puts "#{COLOR_BLUE}📋 Unresolved conflicts in Mercurial:#{COLOR_RESET}"
503
- result = execute_vcs_command_with_timeout("hg resolve --list", 5)
1359
+ def show_help_and_status
1360
+ # This method will be invoked by OptionParser for -h/--help,
1361
+ # or when n2b-diff is run without arguments in non-analyze mode.
1362
+ # The OptionParser instance itself will print most of the help text.
1363
+ # We just add any extra status info here.
1364
+
1365
+ puts "" # Extra newline for spacing after OptionParser's output if it called this.
1366
+ # If running not due to -h, but due to missing file_path in merge mode:
1367
+ if !@options[:analyze] && @file_path.nil? && !@args.include?('-h') && !@args.include?('--help')
1368
+ puts "#{COLOR_RED}Error: No file path provided for merge conflict resolution."
1369
+ puts "Run with -h or --help for detailed usage."
1370
+ puts ""
1371
+ end
504
1372
 
505
- if result[:success]
506
- unresolved_files = result[:stdout].lines.select { |line| line.start_with?('U ') }
507
1373
 
508
- if unresolved_files.any?
509
- unresolved_files.each do |line|
510
- file = line.strip.sub(/^U /, '')
511
- puts " #{COLOR_RED} #{file}#{COLOR_RESET}"
1374
+ # Show unresolved conflicts if in a VCS repository and not in analyze mode
1375
+ # (or if specifically requested, but for now, tied to non-analyze mode)
1376
+ if !@options[:analyze] && (File.exist?('.hg') || File.exist?('.git'))
1377
+ puts "#{COLOR_BLUE}📋 Unresolved Conflicts Status:#{COLOR_RESET}"
1378
+ if File.exist?('.hg')
1379
+ puts "#{COLOR_GRAY}Checking Mercurial...#{COLOR_RESET}"
1380
+ result = execute_vcs_command_with_timeout("hg resolve --list", 5)
1381
+ if result[:success]
1382
+ unresolved_files = result[:stdout].lines.select { |line| line.start_with?('U ') }
1383
+ if unresolved_files.any?
1384
+ unresolved_files.each { |line| puts " #{COLOR_RED}❌ #{line.strip.sub(/^U /, '')} (Mercurial)#{COLOR_RESET}" }
1385
+ puts "\n#{COLOR_YELLOW}💡 Use: n2b-diff <filename> to resolve listed conflicts.#{COLOR_RESET}"
1386
+ else
1387
+ puts " #{COLOR_GREEN}✅ No unresolved Mercurial conflicts.#{COLOR_RESET}"
512
1388
  end
513
- puts ""
514
- puts "#{COLOR_YELLOW}💡 Use: n2b-diff <filename> to resolve conflicts#{COLOR_RESET}"
515
1389
  else
516
- puts " #{COLOR_GREEN} No unresolved conflicts#{COLOR_RESET}"
1390
+ puts " #{COLOR_YELLOW}⚠️ Could not check Mercurial status: #{result[:error]}#{COLOR_RESET}"
517
1391
  end
518
- else
519
- puts " #{COLOR_YELLOW}⚠️ Could not check Mercurial status: #{result[:error]}#{COLOR_RESET}"
520
1392
  end
521
- elsif File.exist?('.git')
522
- puts "#{COLOR_BLUE}📋 Unresolved conflicts in Git:#{COLOR_RESET}"
523
- result = execute_vcs_command_with_timeout("git diff --name-only --diff-filter=U", 5)
524
-
525
- if result[:success]
526
- unresolved_files = result[:stdout].lines
527
1393
 
528
- if unresolved_files.any?
529
- unresolved_files.each do |file|
530
- puts " #{COLOR_RED}❌ #{file.strip}#{COLOR_RESET}"
1394
+ if File.exist?('.git')
1395
+ puts "#{COLOR_GRAY}Checking Git...#{COLOR_RESET}"
1396
+ # For Git, `git status --porcelain` is better as `git diff --name-only --diff-filter=U` only shows unmerged paths.
1397
+ # We want to show files with conflict markers.
1398
+ # `git status --porcelain=v1` shows "UU" for unmerged files.
1399
+ result = execute_vcs_command_with_timeout("git status --porcelain=v1", 5)
1400
+ if result[:success]
1401
+ unresolved_files = result[:stdout].lines.select{|line| line.start_with?('UU ')}.map{|line| line.sub(/^UU /, '').strip}
1402
+ if unresolved_files.any?
1403
+ unresolved_files.each { |file| puts " #{COLOR_RED}❌ #{file} (Git)#{COLOR_RESET}" }
1404
+ puts "\n#{COLOR_YELLOW}💡 Use: n2b-diff <filename> to resolve listed conflicts.#{COLOR_RESET}"
1405
+ else
1406
+ puts " #{COLOR_GREEN}✅ No unresolved Git conflicts.#{COLOR_RESET}"
531
1407
  end
532
- puts ""
533
- puts "#{COLOR_YELLOW}💡 Use: n2b-diff <filename> to resolve conflicts#{COLOR_RESET}"
534
1408
  else
535
- puts " #{COLOR_GREEN} No unresolved conflicts#{COLOR_RESET}"
1409
+ puts " #{COLOR_YELLOW}⚠️ Could not check Git status: #{result[:error]}#{COLOR_RESET}"
536
1410
  end
537
- else
538
- puts " #{COLOR_YELLOW}⚠️ Could not check Git status: #{result[:error]}#{COLOR_RESET}"
539
1411
  end
1412
+ elsif @options[:analyze]
1413
+ # This part might not be reached if -h is used, as OptionParser exits.
1414
+ # But if called for other reasons in analyze mode:
1415
+ puts "#{COLOR_BLUE}ℹ️ Running in analysis mode. VCS conflict status check is skipped.#{COLOR_RESET}"
1416
+ end
1417
+ # Ensure exit if we got here due to an operational error (like no file in merge mode)
1418
+ # but not if it was just -h (OptionParser handles exit for -h)
1419
+ if !@options[:analyze] && @file_path.nil? && !@args.include?('-h') && !@args.include?('--help')
1420
+ exit 1
540
1421
  end
541
1422
  end
542
1423
 
@@ -584,76 +1465,278 @@ module N2B
584
1465
  end
585
1466
 
586
1467
  def handle_editor_workflow(block, config, full_file_content)
587
- original_content = File.read(@file_path)
1468
+ editor_command = config.dig('editor', 'command')
1469
+ editor_type = config.dig('editor', 'type')
1470
+ editor_configured = config.dig('editor', 'configured')
588
1471
 
589
- puts "#{COLOR_BLUE}🔧 Opening #{@file_path} in editor...#{COLOR_RESET}"
590
- open_file_in_editor(@file_path)
591
- puts "#{COLOR_BLUE}📁 Editor closed. Checking for changes...#{COLOR_RESET}"
1472
+ lines = File.readlines(@file_path, chomp: true)
1473
+ current_block_content_with_markers = lines[(block.start_line - 1)...block.end_line].join("\n")
1474
+
1475
+ if editor_configured && editor_command && editor_type == 'diff_tool'
1476
+ require 'tmpdir'
1477
+ Dir.mktmpdir("n2b_diff_") do |tmpdir|
1478
+ base_file_path = File.join(tmpdir, "base_#{File.basename(@file_path)}")
1479
+ remote_file_path = File.join(tmpdir, "remote_#{File.basename(@file_path)}")
1480
+ merged_file_path = File.join(tmpdir, "merged_#{File.basename(@file_path)}")
1481
+
1482
+ File.write(base_file_path, block.base_content)
1483
+ File.write(remote_file_path, block.incoming_content)
1484
+
1485
+ # Initial content for the merged file: LLM suggestion or current block with markers
1486
+ initial_merged_content = block.suggestion&.dig('merged_code')
1487
+ if initial_merged_content.nil? || initial_merged_content.strip.empty?
1488
+ initial_merged_content = current_block_content_with_markers
1489
+ end
1490
+ File.write(merged_file_path, initial_merged_content)
1491
+
1492
+ # Common pattern: tool base merged remote. Some tools might vary.
1493
+ # Example: meld uses local remote base --output output_file
1494
+ # For now, using a common sequence. Needs documentation for custom tools.
1495
+ # We assume the tool edits the `merged_file_path` (second argument) in place or uses it as output.
1496
+ full_diff_command = "#{editor_command} #{Shellwords.escape(base_file_path)} #{Shellwords.escape(merged_file_path)} #{Shellwords.escape(remote_file_path)}"
1497
+ puts "#{COLOR_BLUE}🔧 Launching diff tool: #{editor_command}...#{COLOR_RESET}"
1498
+ puts "#{COLOR_GRAY} Base: #{base_file_path}#{COLOR_RESET}"
1499
+ puts "#{COLOR_GRAY} Remote: #{remote_file_path}#{COLOR_RESET}"
1500
+ puts "#{COLOR_GRAY} Merged: #{merged_file_path} (edit this one)#{COLOR_RESET}"
1501
+
1502
+ system(full_diff_command)
1503
+
1504
+ puts "#{COLOR_BLUE}📁 Diff tool closed.#{COLOR_RESET}"
1505
+ merged_code_from_editor = File.read(merged_file_path)
1506
+
1507
+ # Check if the merged content is different from the initial content with markers
1508
+ # to avoid considering unchanged initial conflict markers as a resolution.
1509
+ if merged_code_from_editor.strip == current_block_content_with_markers.strip && merged_code_from_editor.include?('<<<<<<<')
1510
+ puts "#{COLOR_YELLOW}⚠️ It seems the conflict markers are still present. Did you resolve the conflict?#{COLOR_RESET}"
1511
+ end
592
1512
 
593
- current_content = File.read(@file_path)
1513
+ print "#{COLOR_YELLOW}Did you resolve this conflict using the diff tool? [y/n]: #{COLOR_RESET}"
1514
+ response = $stdin.gets&.strip&.downcase
1515
+ if response == 'y'
1516
+ puts "#{COLOR_GREEN}✅ Conflict marked as resolved by user via diff tool#{COLOR_RESET}"
1517
+ return {
1518
+ resolved: true,
1519
+ merged_code: merged_code_from_editor,
1520
+ reason: "User resolved conflict with diff tool: #{editor_command}"
1521
+ }
1522
+ else
1523
+ puts "#{COLOR_BLUE}🔄 Conflict not marked as resolved. Continuing with AI assistance...#{COLOR_RESET}"
1524
+ # If user says 'n', we don't use the content from the diff tool as a resolution.
1525
+ # We might need to re-fetch LLM suggestion or just go back to menu.
1526
+ # For now, return resolved: false. The updated_content is not from the main file.
1527
+ return { resolved: false, updated_content: full_file_content } # original full_file_content
1528
+ end
1529
+ end # Tempdir is automatically removed
1530
+ else
1531
+ # Fallback to text editor or if editor is 'text_editor'
1532
+ editor_to_use = editor_command || detect_system_editor # Use configured or system editor
1533
+
1534
+ original_file_content_for_block_check = File.read(@file_path) # Before text editor opens it
1535
+
1536
+ puts "#{COLOR_BLUE}🔧 Opening #{@file_path} in editor (#{editor_to_use})...#{COLOR_RESET}"
1537
+ open_file_in_editor(@file_path, editor_to_use) # Pass specific editor
1538
+ puts "#{COLOR_BLUE}📁 Editor closed. Checking for changes...#{COLOR_RESET}"
1539
+
1540
+ current_file_content_after_edit = File.read(@file_path)
1541
+
1542
+ if file_changed?(original_file_content_for_block_check, current_file_content_after_edit)
1543
+ puts "#{COLOR_YELLOW}📝 File has been modified.#{COLOR_RESET}"
1544
+ print "#{COLOR_YELLOW}Did you resolve this conflict yourself in the editor? [y/n]: #{COLOR_RESET}"
1545
+ response = $stdin.gets&.strip&.downcase
1546
+
1547
+ if response == 'y'
1548
+ puts "#{COLOR_GREEN}✅ Conflict marked as resolved by user in text editor#{COLOR_RESET}"
1549
+ # Extract the changed block content
1550
+ # Re-read lines as they might have changed in number
1551
+ edited_lines = File.readlines(@file_path, chomp: true)
1552
+ # Heuristic: if lines were added/removed, block boundaries might shift.
1553
+ # For simplicity, we'll use original block's line numbers to extract,
1554
+ # but this might be inaccurate if user adds/removes many lines *outside* the conflict block.
1555
+ # A more robust way would be to re-parse or use markers if they exist.
1556
+ # For now, assume user edits primarily *within* the original start/end lines.
1557
+ # The number of lines in the resolved code could be different.
1558
+ # We need to ask the user to ensure the markers are gone.
1559
+
1560
+ # Let's get the content of the lines that corresponded to the original block.
1561
+ # This isn't perfect if the user adds/deletes lines *within* the block,
1562
+ # changing its length. The LLM's suggestion is for a block of a certain size.
1563
+ # For user resolution, they define the new block.
1564
+ # We need to get the content from start_line to (potentially new) end_line.
1565
+ # This is tricky. The simplest is to take the whole file, but that's not what merge tools do.
1566
+ # The contract is that the user removed the conflict markers.
1567
+
1568
+ # We will return the content of the file from the original start line
1569
+ # to an end line that reflects the number of lines in the manually merged code.
1570
+ # This is still tricky. Let's assume the user edited the block and the surrounding lines are stable.
1571
+ # The `resolve_block` method replaces `lines[(block.start_line-1)...block.end_line]`
1572
+ # So, the returned `merged_code` should be what replaces that segment.
1573
+
1574
+ # Simplest approach: user confirms resolution, we assume the relevant part of the file is the resolution.
1575
+ # We need to extract the content of the resolved block from current_file_content_after_edit
1576
+ # based on block.start_line and the *new* end_line of the resolved conflict.
1577
+ # This is hard without re-parsing.
1578
+ # A practical approach: The user resolved it. The file is now correct *at those lines*.
1579
+ # The `resolve_block` method will write the *entire* `lines` array back to the file.
1580
+ # If the user resolved it, the `lines` array (after their edit) IS the resolution for that part.
1581
+ # So, we need to give `resolve_block` the lines from the file that correspond to the original block markers.
1582
+ # This means the `merged_code` should be the content of the file from `block.start_line`
1583
+ # up to where the `block.end_line` *would* be after their edits.
1584
+
1585
+ # Let's refine: the user has edited the file. The section of the file
1586
+ # that previously contained the conflict markers (block.start_line to block.end_line)
1587
+ # now contains their resolution. We need to extract this segment.
1588
+ # The number of lines might have changed.
1589
+ # The `resolve_block` function will replace `lines[original_start_idx..original_end_idx]` with the new content.
1590
+ # So we must provide the exact lines that should go into that slice.
1591
+
1592
+ # We need to ask the user to confirm the new end line if it changed, or trust they know.
1593
+ # The simplest is to return the segment from the file from original start_line to original end_line,
1594
+ # assuming the user's changes fit there. This is too naive.
1595
+
1596
+ # If the user says 'y', the file is considered resolved in that region.
1597
+ # The `resolve_block` will then write the `lines` array (which is `current_file_content_after_edit.split("\n")`)
1598
+ # back to the file. The key is that `resolve_block` *already has* the full `lines` from the modified file
1599
+ # when it reconstructs the file if result[:accepted] is true.
1600
+ # So, the `merged_code` we return here is more for logging/consistency.
1601
+ # The critical part is that `lines` in `resolve_block` needs to be updated if the file was changed by the editor.
1602
+
1603
+ # The `resolve_block` method reads `lines = File.readlines(@file_path, chomp: true)` at the beginning.
1604
+ # If we edit the file here, `lines` in `resolve_block` becomes stale.
1605
+ # This means `handle_editor_workflow` must return the *new* full file content if it changed.
1606
+ # And the `merged_code` for the log should be the segment of the new file.
1607
+
1608
+ # Let's re-read the file and extract the relevant segment for the log.
1609
+ # The actual application of changes happens because `resolve_block` will use the modified `lines` array.
1610
+ # We need to estimate the new end_line. This is complex.
1611
+ # For now, let's just say "user resolved". The actual diff applied will be based on the whole file change.
1612
+ # The `merged_code` for logging can be a placeholder or the new content of the block.
1613
+ # Let's assume the user ensures the markers are gone.
1614
+ # The content of lines from block.start_line to block.end_line in the *new file* is their resolution.
1615
+ # The number of lines in this resolution can be different from the original block.
1616
+ # This is fine, as the `replacement` in `resolve_block` handles this.
1617
+
1618
+ # Simplification: if user says 'y', the code that will be used is the content
1619
+ # of the file from block.start_line to some new end_line.
1620
+ # The crucial part is that `resolve_block` needs to operate on the *modified* file content.
1621
+ # So, we should pass `current_file_content_after_edit` back up.
1622
+ # And for logging, extract the lines from `block.start_line` to `block.end_line` from this new content.
1623
+ # This assumes the user's resolution fits within the original line numbers, which is not always true.
1624
+
1625
+ # The most robust is to re-parse the file for conflict markers. If none are found in this region, it's resolved.
1626
+ # The "merged_code" would be the lines from the edited file that replaced the original conflict.
1627
+
1628
+ # Let's assume the user has resolved the conflict markers from `block.start_line` to `block.end_line`.
1629
+ # The content of these lines in `current_file_content_after_edit` is the resolution.
1630
+ # The number of lines of this resolution might be different.
1631
+ # The `resolve_block` needs to replace `lines[(block.start_line-1)...block.end_line]`
1632
+ # The `merged_code` should be this new segment.
1633
+
1634
+ # For now, let `merged_code` be a conceptual value. The `resolve_block` loop needs to use the new file content.
1635
+ # The key is `updated_content` for the main loop, and `merged_code` for logging.
1636
+
1637
+ # The `resolve_block` method needs to use the content of the file *after* the edit.
1638
+ # The current structure of `resolve_block` re-reads `lines` only if `request_merge_with_spinner` is called again.
1639
+ # This needs adjustment.
1640
+
1641
+ # For now, if user says 'y':
1642
+ # 1. The `merged_code` will be what's in the file from `block.start_line` to `block.end_line` (original numbering).
1643
+ # This is imperfect for logging if lines were added/removed.
1644
+ # 2. The `resolve_block` loop must use `current_file_content_after_edit` for its `lines` variable.
1645
+ # This is the most important part for correctness.
1646
+
1647
+ # Let's return the segment from the modified file for `merged_code`.
1648
+ # This is still tricky because `block.end_line` is from the original parse.
1649
+ # If user deleted lines, `block.end_line` might be out of bounds for `edited_lines`.
1650
+ # If user added lines, we wouldn't capture all of it.
1651
+
1652
+ # Simplest for now: the user resolved it. The merged_code for logging can be a placeholder.
1653
+ # The main thing is that `resolve_block` now operates on `current_file_content_after_edit`.
1654
+ # The subtask asks for `merged_code` to be `<content_from_editor_or_file>`.
1655
+ # This means the content of the resolved block.
1656
+
1657
+ # Let's try to extract the content from the edited file using original line numbers as a guide.
1658
+ # This is a known limitation. A better way would be for user to indicate new block end.
1659
+ resolved_segment = edited_lines[(block.start_line - 1)..[block.end_line - 1]].join("\n") rescue "User resolved - content not easily extracted due to line changes"
1660
+ if edited_lines.slice((block.start_line-1)...(block.end_line)).join("\n").include?("<<<<<<<")
1661
+ puts "#{COLOR_YELLOW}⚠️ Conflict markers seem to still be present in the edited file. Please ensure they are removed for proper resolution.#{COLOR_RESET}"
1662
+ end
594
1663
 
595
- if file_changed?(original_content, current_content)
596
- puts "#{COLOR_YELLOW}📝 File has been modified.#{COLOR_RESET}"
597
- print "#{COLOR_YELLOW}Did you resolve this conflict yourself? [y/n]: #{COLOR_RESET}"
598
- response = $stdin.gets&.strip&.downcase
599
1664
 
600
- if response == 'y'
601
- puts "#{COLOR_GREEN}✅ Conflict marked as resolved by user#{COLOR_RESET}"
602
- return {
603
- resolved: true,
604
- merged_code: "user_resolved",
605
- reason: "User resolved conflict manually in editor"
606
- }
1665
+ return {
1666
+ resolved: true,
1667
+ merged_code: resolved_segment, # Content from the file for the resolved block
1668
+ reason: "User resolved conflict in text editor: #{editor_to_use}",
1669
+ updated_content: current_file_content_after_edit # Pass back the full content
1670
+ }
1671
+ else
1672
+ puts "#{COLOR_BLUE}🔄 Conflict not marked as resolved. Continuing with AI assistance...#{COLOR_RESET}"
1673
+ return {
1674
+ resolved: false,
1675
+ updated_content: current_file_content_after_edit # Pass back the full content
1676
+ }
1677
+ end
607
1678
  else
608
- puts "#{COLOR_BLUE}🔄 Continuing with AI assistance...#{COLOR_RESET}"
609
- return {
610
- resolved: false,
611
- updated_content: current_content
612
- }
1679
+ puts "#{COLOR_GRAY}📋 No changes detected in the file. Continuing...#{COLOR_RESET}"
1680
+ return { resolved: false, updated_content: nil } # No changes, so original full_file_content is still valid
613
1681
  end
614
- else
615
- puts "#{COLOR_GRAY}📋 No changes detected. Continuing...#{COLOR_RESET}"
616
- return {resolved: false, updated_content: nil}
617
1682
  end
618
1683
  end
619
1684
 
620
- def detect_editor
621
- ENV['EDITOR'] || ENV['VISUAL'] || detect_system_editor
622
- end
623
-
624
1685
  def detect_system_editor
1686
+ # This is the ultimate fallback if no configuration is set.
1687
+ # The ENV['EDITOR'] || ENV['VISUAL'] check should be done by the caller if preferred before this.
625
1688
  case RbConfig::CONFIG['host_os']
626
1689
  when /darwin|mac os/
627
- 'open'
1690
+ 'open' # Typically non-blocking, might need different handling or user awareness.
628
1691
  when /linux/
629
- 'nano'
1692
+ # Prefer common user-friendly editors if available, then vi as fallback.
1693
+ # This simple version just picks one. `command_exists?` could be used here.
1694
+ ENV['EDITOR'] || ENV['VISUAL'] || 'nano' # or 'vi'
630
1695
  when /mswin|mingw/
631
- 'notepad'
1696
+ ENV['EDITOR'] || ENV['VISUAL'] || 'notepad'
632
1697
  else
633
- 'vi'
1698
+ ENV['EDITOR'] || ENV['VISUAL'] || 'vi' # vi is a common default on Unix-like systems
634
1699
  end
635
1700
  end
636
1701
 
637
- def open_file_in_editor(file_path)
638
- editor = detect_editor
1702
+ def open_file_in_editor(file_path, editor_command = nil)
1703
+ # If no specific editor_command is passed, try configured editor, then system fallbacks.
1704
+ # This method is now simplified as the decision of *which* editor (configured vs fallback)
1705
+ # is made in handle_editor_workflow. This method just executes it.
1706
+ # However, the original call from resolve_block (before this change) did not pass editor_command.
1707
+ # So, if editor_command is nil, we should still try to get it from config or fallback.
639
1708
 
1709
+ effective_editor = editor_command # Use passed command if available
1710
+
1711
+ if effective_editor.nil?
1712
+ config = get_config(reconfigure: false, advanced_flow: false) # Ensure config is loaded
1713
+ effective_editor = config.dig('editor', 'command') if config.dig('editor', 'configured')
1714
+ effective_editor ||= detect_system_editor # Ultimate fallback
1715
+ end
1716
+
1717
+ puts "#{COLOR_GRAY}Attempting to open with: #{effective_editor} #{Shellwords.escape(file_path)}#{COLOR_RESET}"
640
1718
  begin
641
- case editor
642
- when 'open'
643
- # macOS: open with default application (non-blocking)
644
- result = execute_vcs_command_with_timeout("open #{Shellwords.escape(file_path)}", 5)
645
- unless result[:success]
646
- puts "#{COLOR_YELLOW}⚠️ Could not open with 'open' command: #{result[:error]}#{COLOR_RESET}"
647
- puts "#{COLOR_BLUE}💡 Please open #{file_path} manually in your editor#{COLOR_RESET}"
1719
+ if RbConfig::CONFIG['host_os'] =~ /darwin|mac os/ && effective_editor == 'open'
1720
+ # 'open' is non-blocking. This is fine.
1721
+ result = system("open #{Shellwords.escape(file_path)}")
1722
+ unless result
1723
+ # `system` returns true if command found and exited with 0, false otherwise for `open`.
1724
+ # It returns nil if command execution fails.
1725
+ puts "#{COLOR_YELLOW}⚠️ 'open' command might have failed or file opened in background. Please check manually.#{COLOR_RESET}"
648
1726
  end
1727
+ # For 'open', we don't wait. User needs to manually come back.
1728
+ # Consider adding a prompt "Press Enter after closing the editor..." for non-blocking editors.
1729
+ # For now, keeping it simple.
649
1730
  else
650
- # Other editors: open directly (blocking)
651
- puts "#{COLOR_BLUE}🔧 Opening with #{editor}...#{COLOR_RESET}"
652
- system("#{editor} #{Shellwords.escape(file_path)}")
1731
+ # For most terminal editors, system() will block until the editor is closed.
1732
+ system("#{effective_editor} #{Shellwords.escape(file_path)}")
653
1733
  end
654
- rescue => e
655
- puts "#{COLOR_RED}❌ Failed to open editor: #{e.message}#{COLOR_RESET}"
656
- puts "#{COLOR_YELLOW}💡 Try setting EDITOR environment variable to your preferred editor#{COLOR_RESET}"
1734
+ rescue StandardError => e
1735
+ puts "#{COLOR_RED}❌ Failed to open editor '#{effective_editor}': #{e.message}#{COLOR_RESET}"
1736
+ puts "#{COLOR_YELLOW}💡 Please ensure your configured editor is correct or set your EDITOR environment variable.#{COLOR_RESET}"
1737
+ puts "#{COLOR_BLUE}You may need to open #{file_path} manually in your preferred editor to make changes.#{COLOR_RESET}"
1738
+ print "#{COLOR_YELLOW}Press Enter to continue after manually editing (if you choose to do so)...#{COLOR_RESET}"
1739
+ $stdin.gets # Pause to allow manual editing
657
1740
  end
658
1741
  end
659
1742
 
@@ -690,5 +1773,560 @@ module N2B
690
1773
  puts "#{COLOR_GRAY}⚠️ Could not save debug info: #{e.message}#{COLOR_RESET}"
691
1774
  end
692
1775
  end
1776
+
1777
+ def generate_merge_log_html(log_entries, timestamp)
1778
+ git_info = extract_git_info
1779
+
1780
+ html = <<~HTML
1781
+ <!DOCTYPE html>
1782
+ <html lang="en">
1783
+ <head>
1784
+ <meta charset="UTF-8">
1785
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1786
+ <title>N2B Merge Log - #{@file_path} - #{timestamp}</title>
1787
+ <style>
1788
+ body {
1789
+ font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
1790
+ margin: 0;
1791
+ padding: 20px;
1792
+ background-color: #f8f9fa;
1793
+ color: #333;
1794
+ line-height: 1.6;
1795
+ }
1796
+ .header {
1797
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
1798
+ color: white;
1799
+ padding: 20px;
1800
+ border-radius: 8px;
1801
+ margin-bottom: 20px;
1802
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
1803
+ }
1804
+ .header h1 {
1805
+ margin: 0 0 10px 0;
1806
+ font-size: 24px;
1807
+ }
1808
+ .header .meta {
1809
+ opacity: 0.9;
1810
+ font-size: 14px;
1811
+ }
1812
+ .conflict-container {
1813
+ background: white;
1814
+ border-radius: 8px;
1815
+ margin-bottom: 20px;
1816
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
1817
+ overflow: hidden;
1818
+ }
1819
+ .conflict-header {
1820
+ background-color: #e9ecef;
1821
+ padding: 15px;
1822
+ border-bottom: 1px solid #dee2e6;
1823
+ font-weight: bold;
1824
+ color: #495057;
1825
+ }
1826
+ .conflict-table {
1827
+ width: 100%;
1828
+ border-collapse: collapse;
1829
+ }
1830
+ .conflict-table th {
1831
+ background-color: #f8f9fa;
1832
+ padding: 12px;
1833
+ text-align: left;
1834
+ font-weight: 600;
1835
+ border-bottom: 2px solid #dee2e6;
1836
+ color: #495057;
1837
+ }
1838
+ .conflict-table td {
1839
+ padding: 12px;
1840
+ border-bottom: 1px solid #e9ecef;
1841
+ vertical-align: top;
1842
+ }
1843
+ .code-block {
1844
+ background-color: #f8f9fa;
1845
+ border: 1px solid #e9ecef;
1846
+ border-radius: 4px;
1847
+ padding: 10px;
1848
+ font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
1849
+ font-size: 13px;
1850
+ white-space: pre-wrap;
1851
+ overflow-x: auto;
1852
+ max-height: 300px;
1853
+ overflow-y: auto;
1854
+ }
1855
+ .base-code { border-left: 4px solid #dc3545; }
1856
+ .incoming-code { border-left: 4px solid #007bff; }
1857
+ .resolution-code { border-left: 4px solid #28a745; }
1858
+ .method-badge {
1859
+ display: inline-block;
1860
+ padding: 4px 8px;
1861
+ border-radius: 12px;
1862
+ font-size: 12px;
1863
+ font-weight: 500;
1864
+ text-transform: uppercase;
1865
+ }
1866
+ .method-llm { background-color: #e3f2fd; color: #1976d2; }
1867
+ .method-manual { background-color: #fff3e0; color: #f57c00; }
1868
+ .method-skip { background-color: #fce4ec; color: #c2185b; }
1869
+ .method-abort { background-color: #ffebee; color: #d32f2f; }
1870
+ .footer {
1871
+ text-align: center;
1872
+ margin-top: 30px;
1873
+ padding: 20px;
1874
+ color: #6c757d;
1875
+ font-size: 14px;
1876
+ }
1877
+ .stats {
1878
+ display: flex;
1879
+ gap: 20px;
1880
+ margin-top: 10px;
1881
+ }
1882
+ .stat-item {
1883
+ background: rgba(255, 255, 255, 0.2);
1884
+ padding: 8px 12px;
1885
+ border-radius: 4px;
1886
+ }
1887
+ </style>
1888
+ </head>
1889
+ <body>
1890
+ <div class="header">
1891
+ <h1>🔀 N2B Merge Resolution Log</h1>
1892
+ <div class="meta">
1893
+ <strong>File:</strong> #{@file_path}<br>
1894
+ <strong>Timestamp:</strong> #{Time.now.strftime('%Y-%m-%d %H:%M:%S UTC')}<br>
1895
+ <strong>Branch:</strong> #{git_info[:branch]}<br>
1896
+ <strong>Total Conflicts:</strong> #{log_entries.length}
1897
+ </div>
1898
+ <div class="stats">
1899
+ <div class="stat-item">
1900
+ <strong>Resolved:</strong> #{log_entries.count { |e| e[:action] == 'accepted' }}
1901
+ </div>
1902
+ <div class="stat-item">
1903
+ <strong>Skipped:</strong> #{log_entries.count { |e| e[:action] == 'skipped' }}
1904
+ </div>
1905
+ <div class="stat-item">
1906
+ <strong>Aborted:</strong> #{log_entries.count { |e| e[:action] == 'aborted' }}
1907
+ </div>
1908
+ </div>
1909
+ </div>
1910
+ HTML
1911
+
1912
+ log_entries.each_with_index do |entry, index|
1913
+ conflict_number = index + 1
1914
+ method_class = case entry[:resolution_method]
1915
+ when /llm|ai|suggested/i then 'method-llm'
1916
+ when /manual|user|edit/i then 'method-manual'
1917
+ when /skip/i then 'method-skip'
1918
+ when /abort/i then 'method-abort'
1919
+ else 'method-llm'
1920
+ end
1921
+
1922
+ html += <<~CONFLICT_HTML
1923
+ <div class="conflict-container">
1924
+ <div class="conflict-header">
1925
+ Conflict ##{conflict_number} - Lines #{entry[:start_line]}-#{entry[:end_line]}
1926
+ <span class="method-badge #{method_class}">#{entry[:resolution_method]}</span>
1927
+ </div>
1928
+ <table class="conflict-table">
1929
+ <thead>
1930
+ <tr>
1931
+ <th style="width: 25%">Base Branch Code</th>
1932
+ <th style="width: 25%">Incoming Branch Code</th>
1933
+ <th style="width: 25%">Final Resolution</th>
1934
+ <th style="width: 25%">Resolution Details</th>
1935
+ </tr>
1936
+ </thead>
1937
+ <tbody>
1938
+ <tr>
1939
+ <td>
1940
+ <div class="code-block base-code">#{escape_html(entry[:base_content] || 'N/A')}</div>
1941
+ </td>
1942
+ <td>
1943
+ <div class="code-block incoming-code">#{escape_html(entry[:incoming_content] || 'N/A')}</div>
1944
+ </td>
1945
+ <td>
1946
+ <div class="code-block resolution-code">#{escape_html(entry[:resolved_content] || 'N/A')}</div>
1947
+ </td>
1948
+ <td>
1949
+ <div class="code-block">
1950
+ <strong>Method:</strong> #{escape_html(entry[:resolution_method])}<br>
1951
+ <strong>Action:</strong> #{escape_html(entry[:action])}<br>
1952
+ <strong>Time:</strong> #{entry[:timestamp]}<br><br>
1953
+ #{entry[:llm_suggestion] ? "<strong>LLM Analysis:</strong><br>#{escape_html(entry[:llm_suggestion])}" : ''}
1954
+ </div>
1955
+ </td>
1956
+ </tr>
1957
+ </tbody>
1958
+ </table>
1959
+ </div>
1960
+ CONFLICT_HTML
1961
+ end
1962
+
1963
+ html += <<~FOOTER_HTML
1964
+ <div class="footer">
1965
+ Generated by N2B v#{N2B::VERSION} - AI-Powered Merge Conflict Resolution<br>
1966
+ <small>This log contains the complete history of merge conflict resolutions for audit and review purposes.</small>
1967
+ </div>
1968
+ </body>
1969
+ </html>
1970
+ FOOTER_HTML
1971
+
1972
+ html
1973
+ end
1974
+
1975
+ def escape_html(text)
1976
+ return '' if text.nil?
1977
+ text.to_s
1978
+ .gsub('&', '&amp;')
1979
+ .gsub('<', '&lt;')
1980
+ .gsub('>', '&gt;')
1981
+ .gsub('"', '&quot;')
1982
+ .gsub("'", '&#39;')
1983
+ end
1984
+
1985
+ def extract_git_info
1986
+ branch = `git rev-parse --abbrev-ref HEAD 2>/dev/null`.strip rescue 'unknown'
1987
+ {
1988
+ branch: branch.empty? ? 'unknown' : branch
1989
+ }
1990
+ end
1991
+
1992
+ def determine_resolution_method(result)
1993
+ return 'Aborted' if result[:abort]
1994
+ return 'Manual Edit' if result[:reason]&.include?('manually resolved')
1995
+ return 'Manual Choice' if result[:reason]&.include?('Manually selected')
1996
+ return 'Skipped' if !result[:accepted] && !result[:abort]
1997
+ return 'LLM Suggestion' if result[:accepted] && result[:reason]
1998
+ 'Unknown'
1999
+ end
2000
+
2001
+ def determine_action(result)
2002
+ return 'aborted' if result[:abort]
2003
+ return 'accepted' if result[:accepted]
2004
+ return 'skipped'
2005
+ end
2006
+
2007
+ private # Ensure all subsequent methods are private unless specified
2008
+
2009
+ def get_vcs_type_for_file_operations
2010
+ # Leverages existing get_vcs_type but more for file content retrieval context
2011
+ get_vcs_type
2012
+ end
2013
+
2014
+ def convert_label_to_revision(label, vcs_type, side)
2015
+ # Convert conflict marker labels to actual VCS revision identifiers
2016
+ case vcs_type
2017
+ when :git
2018
+ case label.downcase
2019
+ when /head|working.*copy|current/
2020
+ 'HEAD'
2021
+ when /merge.*rev|incoming|branch/
2022
+ 'MERGE_HEAD'
2023
+ else
2024
+ # If it's already a valid Git revision, use it as-is
2025
+ label
2026
+ end
2027
+ when :hg
2028
+ case label.downcase
2029
+ when /working.*copy|current/
2030
+ '.' # Current working directory parent
2031
+ when /merge.*rev|incoming|branch/
2032
+ 'p2()' # Second parent (incoming branch)
2033
+ else
2034
+ # If it's already a valid Mercurial revision, use it as-is
2035
+ label
2036
+ end
2037
+ else
2038
+ label
2039
+ end
2040
+ end
2041
+
2042
+ def get_file_content_from_vcs(label, file_path, vcs_type)
2043
+ # Note: file_path is the path in the working directory.
2044
+ # VCS commands often need path relative to repo root if not run from root.
2045
+ # Assuming execution from repo root or that file_path is appropriately relative.
2046
+ # Pathname can help make it relative if needed:
2047
+ # relative_file_path = Pathname.new(File.absolute_path(file_path)).relative_path_from(Pathname.new(Dir.pwd)).to_s
2048
+
2049
+ # A simpler approach for `git show` is that it usually works with paths from repo root.
2050
+ # If @file_path is already relative to repo root or absolute, it might just work.
2051
+ # For robustness, ensuring it's relative to repo root is better.
2052
+ # However, current `execute_vcs_diff` uses `Dir.pwd`, implying commands are run from current dir.
2053
+ # Let's assume file_path as given is suitable for now.
2054
+
2055
+ command = case vcs_type
2056
+ when :git
2057
+ # For git show <commit-ish>:<path>, path is usually from repo root.
2058
+ # If @file_path is not from repo root, this might need adjustment.
2059
+ # Let's assume @file_path is correctly specified for this context.
2060
+ "git show #{Shellwords.escape(label)}:#{Shellwords.escape(file_path)}"
2061
+ when :hg
2062
+ "hg cat -r #{Shellwords.escape(label)} #{Shellwords.escape(file_path)}"
2063
+ else
2064
+ nil
2065
+ end
2066
+
2067
+ return nil unless command
2068
+
2069
+ begin
2070
+ # Timeout might be needed for very large files or slow VCS.
2071
+ content = `#{command}` # Using backticks captures stdout
2072
+ # Check $? for command success.
2073
+ # `git show` returns 0 on success, non-zero otherwise (e.g. 128 if path not found).
2074
+ # `hg cat` also returns 0 on success.
2075
+ return content if $?.success?
2076
+
2077
+ # If command failed, log a warning but don't necessarily halt everything.
2078
+ # The preview will just show empty for that panel.
2079
+ puts "#{COLOR_YELLOW}Warning: VCS command '#{command}' failed or returned no content. Exit status: #{$?.exitstatus}.#{COLOR_RESET}"
2080
+ nil
2081
+ rescue StandardError => e
2082
+ puts "#{COLOR_YELLOW}Warning: Could not fetch content for '#{file_path}' from VCS label '#{label}': #{e.message}#{COLOR_RESET}"
2083
+ nil
2084
+ end
2085
+ end
2086
+
2087
+ def apply_hunk_to_full_content(original_full_content_with_markers, conflict_block_details, resolved_hunk_text)
2088
+ return original_full_content_with_markers if resolved_hunk_text.nil?
2089
+
2090
+ lines = original_full_content_with_markers.lines.map(&:chomp)
2091
+ # Convert 1-based line numbers from block_details to 0-based array indices
2092
+ start_idx = conflict_block_details.start_line - 1
2093
+ end_idx = conflict_block_details.end_line - 1
2094
+
2095
+ # Basic validation of indices
2096
+ if start_idx < 0 || start_idx >= lines.length || end_idx < start_idx || end_idx >= lines.length
2097
+ # This case should ideally not happen if block_details are correct
2098
+ # Return original content or handle error appropriately
2099
+ # For preview, it's safer to show the original content with markers if hunk application is problematic
2100
+ return original_full_content_with_markers
2101
+ end
2102
+
2103
+ hunk_lines = resolved_hunk_text.lines.map(&:chomp)
2104
+
2105
+ new_content_lines = []
2106
+ new_content_lines.concat(lines[0...start_idx]) if start_idx > 0 # Lines before the conflict block
2107
+ new_content_lines.concat(hunk_lines) # The resolved hunk
2108
+ new_content_lines.concat(lines[(end_idx + 1)..-1]) if (end_idx + 1) < lines.length # Lines after the conflict block
2109
+
2110
+ new_content_lines.join("\n")
2111
+ end
2112
+
2113
+ # --- HTML Preview Generation ---
2114
+
2115
+ # private (already established)
2116
+
2117
+ def get_language_class(file_path)
2118
+ ext = File.extname(file_path).downcase
2119
+ case ext
2120
+ when '.rb' then 'ruby'
2121
+ when '.js' then 'javascript'
2122
+ when '.py' then 'python'
2123
+ when '.java' then 'java'
2124
+ when '.c', '.h', '.cpp', '.hpp' then 'cpp'
2125
+ when '.cs' then 'csharp'
2126
+ when '.go' then 'go'
2127
+ when '.php' then 'php'
2128
+ when '.ts' then 'typescript'
2129
+ when '.swift' then 'swift'
2130
+ when '.kt', '.kts' then 'kotlin'
2131
+ when '.rs' then 'rust'
2132
+ when '.scala' then 'scala'
2133
+ when '.pl' then 'perl'
2134
+ when '.pm' then 'perl'
2135
+ when '.sh' then 'bash'
2136
+ when '.html', '.htm', '.xhtml', '.xml' then 'xml' # Highlight.js uses 'xml' for HTML
2137
+ when '.css' then 'css'
2138
+ when '.json' then 'json'
2139
+ when '.yml', '.yaml' then 'yaml'
2140
+ when '.md', '.markdown' then 'markdown'
2141
+ else
2142
+ '' # Let Highlight.js auto-detect or default to plain text
2143
+ end
2144
+ end
2145
+
2146
+ def find_sub_content_lines(full_content, sub_content)
2147
+ return nil if sub_content.nil? || sub_content.empty?
2148
+ full_lines = full_content.lines.map(&:chomp)
2149
+ sub_lines = sub_content.lines.map(&:chomp)
2150
+
2151
+ return nil if sub_lines.empty?
2152
+
2153
+ full_lines.each_with_index do |_, index|
2154
+ match = true
2155
+ sub_lines.each_with_index do |sub_line_content, sub_index|
2156
+ unless full_lines[index + sub_index] == sub_line_content
2157
+ match = false
2158
+ break
2159
+ end
2160
+ end
2161
+ return { start: index + 1, end: index + sub_lines.length } if match # 1-based line numbers
2162
+ end
2163
+ nil
2164
+ end
2165
+
2166
+ def generate_conflict_preview_html(block_details, base_content_full, incoming_content_full, current_resolution_content_full, base_branch_name, incoming_branch_name, file_path, llm_suggestion = nil)
2167
+ require 'cgi' # For CGI.escapeHTML
2168
+ require 'fileutils' # For FileUtils.mkdir_p
2169
+ require 'shellwords' # For Shellwords.escape, already used elsewhere but good to have contextually
2170
+ require 'rbconfig' # For RbConfig::CONFIG
2171
+
2172
+ lang_class = get_language_class(file_path)
2173
+
2174
+ # Find line numbers for highlighting
2175
+ # These are line numbers within their respective full_content strings (1-based)
2176
+ base_highlight_lines = find_sub_content_lines(base_content_full, block_details.base_content)
2177
+ incoming_highlight_lines = find_sub_content_lines(incoming_content_full, block_details.incoming_content)
2178
+
2179
+ # For resolution, highlight the conflict area within the full resolution content
2180
+ # The current_resolution_content_full already contains the resolved content
2181
+ # We'll highlight the same line range as the original conflict
2182
+ resolution_highlight_lines = { start: block_details.start_line, end: block_details.end_line }
2183
+
2184
+ html_content = StringIO.new
2185
+ html_content << "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n"
2186
+ html_content << " <meta charset=\"UTF-8\">\n"
2187
+ html_content << " <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n"
2188
+ html_content << " <title>Conflict Preview: #{CGI.escapeHTML(File.basename(file_path))}</title>\n"
2189
+ html_content << " <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/default.min.css\">\n"
2190
+ html_content << " <script src=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js\"></script>\n"
2191
+ # Optional: specific languages if needed, e.g.,
2192
+ # html_content << " <script src=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/#{lang_class}.min.js\"></script>\n" if lang_class && !lang_class.empty?
2193
+
2194
+ html_content << " <style>\n"
2195
+ html_content << " body { font-family: sans-serif; margin: 0; display: flex; flex-direction: column; height: 100vh; }\n"
2196
+ html_content << " .header { padding: 10px; background-color: #f0f0f0; border-bottom: 1px solid #ccc; text-align: center; }\n"
2197
+ html_content << " .header h2 { margin: 0; }\n"
2198
+ html_content << " .llm-message { padding: 10px; background-color: #e8f4fd; border-bottom: 1px solid #ccc; margin: 0; }\n"
2199
+ html_content << " .llm-message h3 { margin: 0 0 5px 0; color: #1976d2; font-size: 0.9em; }\n"
2200
+ html_content << " .llm-message p { margin: 0; color: #424242; font-size: 0.85em; line-height: 1.3; }\n"
2201
+ html_content << " .columns-container { display: flex; flex: 1; overflow: hidden; }\n"
2202
+ html_content << " .column { flex: 1; padding: 0; border-left: 1px solid #ccc; overflow-y: auto; display: flex; flex-direction: column; }\n"
2203
+ html_content << " .column:first-child { border-left: none; }\n"
2204
+ html_content << " .column h3 { background-color: #e0e0e0; padding: 8px 10px; margin: 0; border-bottom: 1px solid #ccc; text-align: center; font-size: 1em; }\n"
2205
+ html_content << " .code-container { flex: 1; overflow-y: auto; position: relative; }\n"
2206
+ html_content << " pre { margin: 0; padding: 0; height: 100%; }\n"
2207
+ html_content << " code { display: block; padding: 10px 10px 10px 60px; font-family: 'SF Mono', Monaco, Inconsolata, 'Fira Code', monospace; font-size: 0.85em; line-height: 1.4em; }\n"
2208
+ html_content << " .line { display: block; position: relative; }\n"
2209
+ html_content << " .line-number { position: absolute; left: 0; width: 50px; padding-right: 10px; text-align: right; color: #999; user-select: none; font-size: 0.8em; background-color: #f8f8f8; border-right: 1px solid #e0e0e0; }\n"
2210
+ html_content << " .conflict-lines-base { background-color: #ffebee; border-left: 3px solid #f44336; }\n"
2211
+ html_content << " .conflict-lines-incoming { background-color: #e3f2fd; border-left: 3px solid #2196f3; }\n"
2212
+ html_content << " .conflict-lines-resolution { background-color: #e8f5e9; border-left: 3px solid #4caf50; }\n"
2213
+ html_content << " @media (max-width: 768px) { .columns-container { flex-direction: column; } .column { border-left: none; border-top: 1px solid #ccc;} }\n"
2214
+ html_content << " </style>\n</head>\n<body>\n"
2215
+
2216
+ html_content << " <div class=\"header\"><h2>Conflict Preview: #{CGI.escapeHTML(file_path)}</h2></div>\n"
2217
+
2218
+ # Add LLM message section if available
2219
+ if llm_suggestion && llm_suggestion['reason']
2220
+ html_content << " <div class=\"llm-message\">\n"
2221
+ html_content << " <h3>🤖 AI Analysis & Suggestion</h3>\n"
2222
+ html_content << " <p>#{CGI.escapeHTML(llm_suggestion['reason'])}</p>\n"
2223
+ html_content << " </div>\n"
2224
+ end
2225
+
2226
+ html_content << " <div class=\"columns-container\">\n"
2227
+
2228
+ # Helper to generate HTML for one column
2229
+ generate_column_html = lambda do |title, full_code, highlight_info, highlight_class_suffix|
2230
+ html_content << " <div class=\"column\">\n"
2231
+ html_content << " <h3>#{CGI.escapeHTML(title)}</h3>\n"
2232
+ html_content << " <div class=\"code-container\">\n" # Wrapper for scrolling
2233
+ html_content << " <pre><code class=\"#{lang_class}\">\n"
2234
+
2235
+ full_code.lines.each_with_index do |line_text, index|
2236
+ line_number = index + 1
2237
+ line_class = "line"
2238
+ if highlight_info && line_number >= highlight_info[:start] && line_number <= highlight_info[:end]
2239
+ line_class += " conflict-lines conflict-lines-#{highlight_class_suffix}"
2240
+ end
2241
+ html_content << "<span class=\"#{line_class}\"><span class=\"line-number\">#{line_number}</span>#{CGI.escapeHTML(line_text.chomp)}</span>\n"
2242
+ end
2243
+
2244
+ html_content << " </code></pre>\n"
2245
+ html_content << " </div>\n" # end .code-container
2246
+ html_content << " </div>\n" # end .column
2247
+ end
2248
+
2249
+ generate_column_html.call("Base (#{base_branch_name})", base_content_full, base_highlight_lines, "base")
2250
+ generate_column_html.call("Incoming (#{incoming_branch_name})", incoming_content_full, incoming_highlight_lines, "incoming")
2251
+ generate_column_html.call("Current Resolution", current_resolution_content_full, resolution_highlight_lines, "resolution")
2252
+
2253
+ html_content << " </div>\n" # end .columns-container
2254
+ html_content << " <script>hljs.highlightAll();</script>\n"
2255
+ html_content << "</body>\n</html>"
2256
+
2257
+ # Save to file with unique but persistent naming
2258
+ log_dir = '.n2b_merge_log'
2259
+ FileUtils.mkdir_p(log_dir)
2260
+
2261
+ # Create a more stable filename based on file path and conflict location
2262
+ file_basename = File.basename(file_path, '.*')
2263
+ conflict_id = "#{block_details.start_line}_#{block_details.end_line}"
2264
+ preview_filename = "conflict_#{file_basename}_lines_#{conflict_id}.html"
2265
+ full_preview_path = File.join(log_dir, preview_filename)
2266
+
2267
+ File.write(full_preview_path, html_content.string)
2268
+
2269
+ return File.absolute_path(full_preview_path)
2270
+ end
2271
+
2272
+ def open_html_in_browser(html_file_path)
2273
+ absolute_path = File.absolute_path(html_file_path)
2274
+ # Ensure correct file URL format for different OSes, especially Windows
2275
+ file_url = if RbConfig::CONFIG['host_os'] =~ /mswin|mingw|cygwin/
2276
+ "file:///#{absolute_path.gsub("\\", "/")}" # Windows needs forward slashes
2277
+ else
2278
+ "file://#{absolute_path}"
2279
+ end
2280
+
2281
+ command = nil
2282
+ os = RbConfig::CONFIG['host_os']
2283
+
2284
+ case os
2285
+ when /darwin|mac os/
2286
+ command = "open #{Shellwords.escape(file_url)}"
2287
+ when /linux/
2288
+ # Check for WSL environment, as xdg-open might not work as expected directly
2289
+ # or might open browser inside WSL, not on Windows host.
2290
+ # Powershell.exe can be used to open it on Windows host from WSL.
2291
+ if ENV['WSL_DISTRO_NAME'] || (ENV['IS_WSL'] == 'true') || File.exist?('/proc/sys/fs/binfmt_misc/WSLInterop')
2292
+ # Using powershell.exe to open the URL on the Windows host
2293
+ # Ensure the file_url is accessible from Windows (e.g. via /mnt/c/...)
2294
+ # This assumes the path is already a Windows-accessible path if running in WSL context
2295
+ # or that the user has set up their environment for this.
2296
+ # For file URLs, it's often better to translate to Windows path format.
2297
+ windows_path = absolute_path.gsub(%r{^/mnt/([a-z])}, '\1:') # Basic /mnt/c -> C:
2298
+ command = "powershell.exe -c \"Start-Process '#{windows_path}'\""
2299
+ puts "#{COLOR_YELLOW}Detected WSL, attempting to open in Windows browser: #{command}#{COLOR_RESET}"
2300
+ else
2301
+ command = "xdg-open #{Shellwords.escape(file_url)}"
2302
+ end
2303
+ when /mswin|mingw|cygwin/ # Windows
2304
+ # `start` command with an empty title "" for paths with spaces
2305
+ command = "start \"\" \"#{file_url.gsub("file:///", "")}\"" # `start` takes path directly
2306
+ else
2307
+ puts "#{COLOR_YELLOW}Unsupported OS: #{os}. Cannot open browser automatically.#{COLOR_RESET}"
2308
+ return false
2309
+ end
2310
+
2311
+ if command
2312
+ puts "#{COLOR_BLUE}Attempting to open preview in browser: #{command}#{COLOR_RESET}"
2313
+ begin
2314
+ success = system(command)
2315
+ unless success
2316
+ # system() returns false if command executes with non-zero status, nil if command fails to execute
2317
+ puts "#{COLOR_RED}Failed to execute command to open browser. Exit status: #{$?.exitstatus if $?}.#{COLOR_RESET}"
2318
+ raise "Command execution failed" # Will be caught by rescue
2319
+ end
2320
+ puts "#{COLOR_GREEN}Preview should now be open in your browser.#{COLOR_RESET}"
2321
+ return true
2322
+ rescue => e
2323
+ puts "#{COLOR_RED}Failed to automatically open the HTML preview: #{e.message}#{COLOR_RESET}"
2324
+ end
2325
+ end
2326
+
2327
+ puts "#{COLOR_YELLOW}Please open it manually: #{file_url}#{COLOR_RESET}"
2328
+ false
2329
+ end
2330
+
693
2331
  end
694
2332
  end