canopus 0.4.0 → 0.5.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.
Files changed (137) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/README.md +191 -19
  4. data/assets/keymaps/emacs.jsonc +5 -0
  5. data/assets/keymaps/jetbrains.jsonc +5 -0
  6. data/assets/keymaps/sublime.jsonc +5 -0
  7. data/assets/keymaps/vscode.jsonc +5 -0
  8. data/docs/debugging.md +143 -0
  9. data/docs/distribution.md +40 -9
  10. data/docs/lsp.md +131 -0
  11. data/docs/performance.md +18 -0
  12. data/docs/providers.md +36 -0
  13. data/docs/release-log.md +12 -0
  14. data/docs/tasks.md +64 -0
  15. data/docs/testing.md +49 -0
  16. data/docs/vim.md +3 -0
  17. data/exe/canopus +4 -1
  18. data/lib/canopus/buffer.rb +291 -50
  19. data/lib/canopus/cli.rb +5 -2
  20. data/lib/canopus/command.rb +13 -4
  21. data/lib/canopus/controller.rb +183 -40
  22. data/lib/canopus/debug/breakpoints.rb +550 -0
  23. data/lib/canopus/debug/configuration.rb +291 -0
  24. data/lib/canopus/debug/console.rb +243 -0
  25. data/lib/canopus/debug/panel.rb +487 -0
  26. data/lib/canopus/debug/session.rb +354 -0
  27. data/lib/canopus/decoration.rb +4 -2
  28. data/lib/canopus/denebola_compat.rb +27 -0
  29. data/lib/canopus/diagnostics.rb +173 -0
  30. data/lib/canopus/display_map/overlay_map.rb +6 -2
  31. data/lib/canopus/editor/snippet_expandable.rb +3 -2
  32. data/lib/canopus/editor.rb +122 -31
  33. data/lib/canopus/git/state.rb +100 -0
  34. data/lib/canopus/language/background_analysis.rb +93 -14
  35. data/lib/canopus/language/document.rb +70 -34
  36. data/lib/canopus/language/symbol.rb +1 -0
  37. data/lib/canopus/language/syntax_worker.rb +61 -9
  38. data/lib/canopus/language.rb +3 -0
  39. data/lib/canopus/minimap.rb +201 -0
  40. data/lib/canopus/patch.rb +3 -1
  41. data/lib/canopus/plugins/api.rb +4 -2
  42. data/lib/canopus/plugins/isolated_runtime.rb +64 -7
  43. data/lib/canopus/plugins/registry.rb +114 -3
  44. data/lib/canopus/plugins.rb +8 -7
  45. data/lib/canopus/project.rb +15 -6
  46. data/lib/canopus/provider.rb +192 -0
  47. data/lib/canopus/regexp_compat.rb +36 -4
  48. data/lib/canopus/selection.rb +12 -0
  49. data/lib/canopus/settings/schema.rb +201 -0
  50. data/lib/canopus/settings.rb +328 -49
  51. data/lib/canopus/task/configuration.rb +356 -0
  52. data/lib/canopus/task/problem_matcher.rb +264 -0
  53. data/lib/canopus/task/runner.rb +314 -0
  54. data/lib/canopus/test_runner/bounded_ignore.rb +144 -0
  55. data/lib/canopus/test_runner/discovery.rb +97 -0
  56. data/lib/canopus/test_runner/execution.rb +194 -0
  57. data/lib/canopus/test_runner/minitest.rb +116 -0
  58. data/lib/canopus/test_runner/rspec.rb +103 -0
  59. data/lib/canopus/test_runner/safe_walker.rb +37 -0
  60. data/lib/canopus/test_runner.rb +54 -0
  61. data/lib/canopus/theme/import.rb +131 -0
  62. data/lib/canopus/theme.rb +28 -4
  63. data/lib/canopus/version.rb +1 -1
  64. data/lib/canopus/vim/motionable.rb +1 -1
  65. data/lib/canopus/vim.rb +10 -16
  66. data/lib/canopus/workspace/auto_savable.rb +83 -0
  67. data/lib/canopus/workspace/debug_aware.rb +600 -0
  68. data/lib/canopus/workspace/edit/plan.rb +2 -2
  69. data/lib/canopus/workspace/editor_configurable.rb +113 -0
  70. data/lib/canopus/workspace/file_change_aware.rb +11 -3
  71. data/lib/canopus/workspace/git_aware.rb +80 -34
  72. data/lib/canopus/workspace/git_blame.rb +175 -0
  73. data/lib/canopus/workspace/git_conflict_resolution.rb +410 -0
  74. data/lib/canopus/workspace/git_diff_view.rb +641 -0
  75. data/lib/canopus/workspace/git_history.rb +171 -0
  76. data/lib/canopus/workspace/git_remote.rb +267 -0
  77. data/lib/canopus/workspace/git_staging.rb +583 -0
  78. data/lib/canopus/workspace/hierarchy_aware.rb +477 -0
  79. data/lib/canopus/workspace/keymap_aware.rb +66 -0
  80. data/lib/canopus/workspace/language_aware.rb +3721 -135
  81. data/lib/canopus/workspace/language_server_configurable.rb +287 -71
  82. data/lib/canopus/workspace/persistent_undo.rb +169 -0
  83. data/lib/canopus/workspace/problems_aware.rb +131 -0
  84. data/lib/canopus/workspace/project_searchable.rb +88 -11
  85. data/lib/canopus/workspace/project_tree_editable.rb +3 -1
  86. data/lib/canopus/workspace/recoverable.rb +204 -0
  87. data/lib/canopus/workspace/session_persistable.rb +85 -13
  88. data/lib/canopus/workspace/settings_aware.rb +76 -2
  89. data/lib/canopus/workspace/task_aware.rb +194 -0
  90. data/lib/canopus/workspace/test_aware.rb +394 -0
  91. data/lib/canopus/workspace/trust.rb +73 -0
  92. data/lib/canopus/workspace/view/terminal_presentable.rb +298 -47
  93. data/lib/canopus/workspace/view.rb +399 -74
  94. data/lib/canopus/workspace.rb +688 -44
  95. data/lib/canopus.rb +20 -2
  96. data/sig/canopus.rbs +138 -31
  97. data/sig/controller.rbs +10 -3
  98. data/sig/debug.rbs +121 -0
  99. data/sig/decoration.rbs +12 -2
  100. data/sig/diagnostics.rbs +26 -0
  101. data/sig/minimap.rbs +20 -0
  102. data/sig/provider.rbs +29 -0
  103. data/sig/services.rbs +1 -16
  104. data/sig/task.rbs +92 -0
  105. data/sig/test_runner.rbs +109 -0
  106. data/sig/trust.rbs +17 -0
  107. data/sig/workspace_edits.rbs +2 -2
  108. data/sig/workspace_services.rbs +234 -2
  109. data/tools/check_dependencies.rb +21 -2
  110. data/tools/package.rb +172 -14
  111. data/tools/package_signature_test.rb +33 -0
  112. data/tools/package_test.rb +29 -0
  113. data/tools/sign_package.rb +123 -0
  114. data/tools/update.rb +189 -0
  115. data/tools/update_test.rb +71 -0
  116. metadata +206 -31
  117. data/lib/canopus/lsp/client.rb +0 -307
  118. data/lib/canopus/lsp/error.rb +0 -7
  119. data/lib/canopus/lsp/future/subscription.rb +0 -9
  120. data/lib/canopus/lsp/future.rb +0 -87
  121. data/lib/canopus/lsp/protocol.rb +0 -83
  122. data/lib/canopus/lsp/server_error.rb +0 -13
  123. data/lib/canopus/lsp/timeout.rb +0 -7
  124. data/lib/canopus/lsp/transport.rb +0 -123
  125. data/lib/canopus/lsp.rb +0 -19
  126. data/lib/canopus/project/search.rb +0 -164
  127. data/lib/canopus/project/search_worker/cancelled.rb +0 -3
  128. data/lib/canopus/project/search_worker/runner.rb +0 -9
  129. data/lib/canopus/project/search_worker.rb +0 -108
  130. data/lib/canopus/terminal/cell.rb +0 -7
  131. data/lib/canopus/terminal/grid.rb +0 -321
  132. data/lib/canopus/terminal/pty.rb +0 -178
  133. data/lib/canopus/terminal/scrollback.rb +0 -38
  134. data/lib/canopus/terminal/vt.rb +0 -398
  135. data/lib/canopus/terminal.rb +0 -13
  136. data/sig/lsp.rbs +0 -99
  137. data/sig/terminal.rbs +0 -130
@@ -0,0 +1,410 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canopus
4
+ module Workspace::GitConflictResolution
5
+ MERGE_MAX_BYTES = 1 << 20
6
+ MERGE_MAX_LINES = 2_000
7
+ CONFLICT_MODES = [0o100644, 0o100755, 0o120000].freeze
8
+ CONFLICT_CHOICES = %i[ours theirs both manual].freeze
9
+
10
+ GitConflictContent = Data.define(:raw, :text, :encoding, :bom, :mode)
11
+ GitConflictCapture = Data.define(:conflict, :contents)
12
+ GitConflictPrepared = Data.define(:capture, :result, :reason)
13
+ GitConflictView = Data.define(:session, :role, :region)
14
+ GitConflictSession = Struct.new(:prepared, :result, :panes, :buffers, :editors, :choices, :mode,
15
+ keyword_init: true)
16
+
17
+ def show_git_conflicts(path = nil, async: !!@window)
18
+ state = git_state
19
+ raise Error, "Not a Git repository" unless state
20
+ raise Error, "Conflict resolution already in progress" if @git_conflict_resolve_job
21
+ path = safe_git_diff_path(path) if path
22
+ raise Error, "Finish the current manual conflict edit first" if git_conflict_editing?
23
+
24
+ generation = @git_conflict_generation = (@git_conflict_generation || 0) + 1
25
+ work = -> { prepare_git_conflict(capture_git_conflict(state, path)) }
26
+ return install_git_conflict(work.call) unless @window && async
27
+
28
+ @message = "Loading merge conflict…"
29
+ worker = Thread.new do
30
+ current = Thread.current
31
+ prepared = work.call
32
+ post { finish_git_conflict_load(current, generation, prepared, nil) } unless @closed
33
+ rescue StandardError => error
34
+ post { finish_git_conflict_load(current, generation, nil, error) } unless @closed
35
+ end
36
+ (@git_conflict_load_jobs ||= []) << worker
37
+ worker
38
+ end
39
+
40
+ alias_method :show_git_conflict, :show_git_conflicts
41
+
42
+ def resolve_git_conflict(choice, content: nil, mode: nil, async: !!@window, expected: nil)
43
+ raise ArgumentError, "invalid conflict choice" unless CONFLICT_CHOICES.include?(choice)
44
+ raise ArgumentError, "content is only accepted for manual resolution" if content && choice != :manual
45
+ if mode && !%i[both manual].include?(choice)
46
+ raise ArgumentError, "mode is only accepted for both or manual resolution"
47
+ end
48
+ session = @git_conflict_session
49
+ raise Error, "Open a merge conflict first" unless session
50
+ raise Error, "Merge conflict view is stale" if expected && !expected.equal?(session)
51
+ raise Error, "Conflict resolution already in progress" if @git_conflict_resolve_job
52
+ if session.prepared.reason && !%i[ours theirs].include?(choice)
53
+ raise Error, "#{session.prepared.reason}; choose ours or theirs"
54
+ end
55
+ unless mode.nil? || CONFLICT_MODES.include?(mode)
56
+ raise ArgumentError, "invalid conflict resolution mode"
57
+ end
58
+ if session.mode && mode && session.mode != mode
59
+ raise Error, "Conflict resolution mode changed"
60
+ end
61
+ session.mode ||= mode
62
+
63
+ if session.result&.conflicts&.any?
64
+ region = session.result.regions.first
65
+ resolution = case choice
66
+ when :ours then :ours
67
+ when :theirs then :theirs
68
+ when :both then :ours_then_theirs
69
+ else
70
+ content = session.buffers.fetch(:ours).text if content.nil?
71
+ validate_manual_conflict_text(content)
72
+ end
73
+ resolved = Porrima::Merge.resolve(session.result, region.index, resolution)
74
+ unless resolved.resolved?
75
+ session.result = resolved
76
+ session.choices << choice
77
+ install_git_conflict_region(session)
78
+ return session.editors.fetch(:ours)
79
+ end
80
+ content = Porrima::Merge.to_resolved_text(resolved)
81
+ elsif session.result
82
+ content = choice == :manual ? (content || session.buffers.fetch(:ours).text) :
83
+ Porrima::Merge.to_resolved_text(session.result)
84
+ elsif choice == :manual
85
+ raise Error, "Manual resolution requires content" unless content.is_a?(String)
86
+ end
87
+
88
+ arguments = git_conflict_resolution_arguments(session, choice, content, session.mode)
89
+ schedule_git_conflict_resolution(session, arguments, async: async)
90
+ end
91
+
92
+ def git_conflict_decorations(buffer, rows)
93
+ view = buffer.instance_variable_get(:@git_conflict_view)
94
+ return [] unless view && view.session.equal?(@git_conflict_session)
95
+
96
+ first = [rows.begin, 0].max
97
+ last = [rows.exclude_end? ? rows.end : rows.end + 1, buffer.line_count].min
98
+ items = (first...last).map do |row|
99
+ Decoration::Item.new(:line, nil, row, nil, {color: "#f9758326"}.freeze,
100
+ 10, :merge_conflict, nil)
101
+ end
102
+ return items unless view.role == :ours && rows.cover?(0)
103
+
104
+ choices = view.session.prepared.reason ? %i[ours theirs] : CONFLICT_CHOICES
105
+ choices.each_with_index do |choice, index|
106
+ label = {ours: "Use ours", theirs: "Use theirs", both: "Use both", manual: "Use manual edit"}.fetch(choice)
107
+ click = lambda do |_current, _offset|
108
+ resolve_git_conflict(choice, expected: view.session)
109
+ rescue StandardError => error
110
+ self.message = "Merge conflict: #{error.message}"
111
+ end
112
+ items << Decoration::Item.new(:block, nil, 0, label,
113
+ {height: 20, position: :above, color: :accent, padding_left: index * 8}.freeze,
114
+ 20 + index, :merge_conflict, click)
115
+ end
116
+ items
117
+ end
118
+
119
+ def close_git
120
+ @git_conflict_generation = (@git_conflict_generation || 0) + 1
121
+ jobs = Array(@git_conflict_load_jobs).compact.reject { |thread| thread.equal?(Thread.current) }
122
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 0.1
123
+ jobs.each do |thread|
124
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
125
+ break unless remaining.positive?
126
+ thread.join(remaining)
127
+ end
128
+ jobs.each { |thread| thread.kill if thread.alive? }
129
+ jobs.each(&:join)
130
+ @git_conflict_resolve_job&.join unless @git_conflict_resolve_job.equal?(Thread.current)
131
+ @git_conflict_load_jobs&.clear
132
+ @git_conflict_session = nil
133
+ super
134
+ end
135
+
136
+ private
137
+
138
+ def capture_git_conflict(state, path = nil)
139
+ state.synchronize do |repository|
140
+ conflicts = repository.conflicts
141
+ conflict = path ? conflicts.find { |entry| entry.path == path } : conflicts.first
142
+ raise Error, "No merge conflict for #{path}" if path && !conflict
143
+ next unless conflict
144
+
145
+ contents = %i[base ours theirs].to_h do |role|
146
+ entry = conflict.public_send(role)
147
+ raw = if entry && entry.mode != 0o160000
148
+ type, value = repository.object(entry.oid)
149
+ raise Thuban::CorruptObject, "conflict entry is not a blob" unless type == "blob"
150
+ value.dup.freeze
151
+ end
152
+ [role, GitConflictContent.new(raw, nil, nil, nil, entry&.mode)]
153
+ end.freeze
154
+ GitConflictCapture.new(conflict, contents)
155
+ end
156
+ end
157
+
158
+ def prepare_git_conflict(capture)
159
+ return unless capture
160
+
161
+ raw_contents = capture.contents.values.filter_map(&:raw)
162
+ if raw_contents.sum(&:bytesize) > MERGE_MAX_BYTES
163
+ return GitConflictPrepared.new(capture, nil, "Conflict exceeds 1 MiB; choose a complete side".freeze)
164
+ end
165
+ decoded = capture.contents.transform_values do |content|
166
+ next content unless content.raw
167
+ text, encoding, bom = Buffer.decode_bytes(content.raw)
168
+ raise EncodingError unless content.raw == bom + text.encode(encoding).b
169
+ content.with(text: text.freeze, encoding: encoding, bom: bom.dup.freeze)
170
+ rescue EncodingError, Error
171
+ content
172
+ end.freeze
173
+ capture = capture.with(contents: decoded)
174
+ present = decoded.values.select(&:raw)
175
+ reason = if present.any? { |entry| entry.text.nil? }
176
+ "Binary or undecodable conflict; choose a complete side"
177
+ elsif present.sum { |entry| entry.text.count("\n") + 1 } > MERGE_MAX_LINES
178
+ "Conflict exceeds 2,000 lines; choose a complete side"
179
+ elsif present.any? { |entry| entry.text.match?(/\r(?!\n)|[\u2028\u2029]/) }
180
+ "Conflict uses unsupported line separators; choose a complete side"
181
+ elsif present.map { |entry| [entry.encoding, entry.bom] }.uniq.length > 1
182
+ "Conflict encodings differ; choose a complete side"
183
+ end
184
+ return GitConflictPrepared.new(capture, nil, reason.freeze) if reason
185
+
186
+ values = decoded.transform_values { |entry| entry.text || "" }
187
+ result = Porrima::Merge.three_way(base: values.fetch(:base), ours: values.fetch(:ours),
188
+ theirs: values.fetch(:theirs))
189
+ GitConflictPrepared.new(capture, result, nil)
190
+ end
191
+
192
+ def finish_git_conflict_load(worker, generation, prepared, error)
193
+ @git_conflict_load_jobs&.delete(worker)
194
+ return unless generation == @git_conflict_generation
195
+ return self.message = "Merge conflict: #{error.message}" if error
196
+
197
+ install_git_conflict(prepared)
198
+ end
199
+
200
+ def install_git_conflict(prepared, panes: nil)
201
+ unless prepared
202
+ cleanup_git_conflict_session(@git_conflict_session)
203
+ @git_conflict_session = nil
204
+ self.message = "No merge conflicts"
205
+ return nil
206
+ end
207
+
208
+ panes ||= @git_conflict_session&.panes
209
+ if @git_conflict_session && (!panes || panes.length != 3 || panes.uniq.length != 3 ||
210
+ panes.any? { |pane| !@panes.include?(pane) })
211
+ cleanup_git_conflict_session(@git_conflict_session)
212
+ panes = nil
213
+ else
214
+ cleanup_git_conflict_buffers(@git_conflict_session) if @git_conflict_session
215
+ end
216
+ panes ||= create_git_conflict_panes
217
+ session = GitConflictSession.new(prepared: prepared, result: prepared.result, panes: panes,
218
+ buffers: {}, editors: {}, choices: [], mode: nil)
219
+ @git_conflict_session = session
220
+ install_git_conflict_region(session)
221
+ self.message = "Resolve #{scm_display_path(prepared.capture.conflict.path)}"
222
+ session.editors.fetch(:ours)
223
+ end
224
+
225
+ def create_git_conflict_panes
226
+ panes = [@active_pane]
227
+ 2.times do
228
+ focus(panes.last)
229
+ pane = split(:horizontal)
230
+ if (duplicate = pane.active)
231
+ pane.close(duplicate, discard: true)
232
+ release_buffer(duplicate.buffer, discard: true)
233
+ end
234
+ panes << pane
235
+ end
236
+ panes.freeze
237
+ end
238
+
239
+ def install_git_conflict_region(session)
240
+ cleanup_git_conflict_buffers(session)
241
+ region = session.result&.regions&.first
242
+ conflict = region&.conflict
243
+ contents = if conflict
244
+ {base: conflict.base, ours: conflict.ours, theirs: conflict.theirs}
245
+ elsif session.result
246
+ captured = session.prepared.capture.contents
247
+ {base: captured.fetch(:base).text || "", ours: Porrima::Merge.to_resolved_text(session.result),
248
+ theirs: captured.fetch(:theirs).text || ""}
249
+ else
250
+ session.prepared.capture.contents.transform_values do |entry|
251
+ entry.text || (entry.raw ? "[binary content unavailable]\n" : "[deleted]\n")
252
+ end
253
+ end
254
+ source = File.join(@root, session.prepared.capture.conflict.path)
255
+ definition = definition_for(source)
256
+ %i[base ours theirs].each_with_index do |role, index|
257
+ editable = role == :ours && (session.result || session.prepared.reason.nil?)
258
+ label = "#{scm_display_path(File.basename(source))} [#{role}]"
259
+ buffer = Buffer.new(contents.fetch(role), read_only: !editable)
260
+ buffer.instance_variable_set(:@display_name, label.freeze)
261
+ buffer.instance_variable_set(:@git_conflict_view, GitConflictView.new(session, role, region))
262
+ @buffers[buffer.object_id] = buffer
263
+ current = session.panes.fetch(index).open(buffer)
264
+ current.language = definition
265
+ apply_editor_settings(current)
266
+ session.buffers[role] = buffer
267
+ session.editors[role] = current
268
+ end
269
+ @decorations.invalidate(:merge_conflict)
270
+ focus(session.panes.fetch(1))
271
+ invalidate_hidden_selection_ranges
272
+ @window&.request_frame
273
+ session.editors.fetch(:ours)
274
+ end
275
+
276
+ def git_conflict_resolution_arguments(session, choice, content, mode)
277
+ capture = session.prepared.capture
278
+ return {choice: choice} unless session.result
279
+ choices = session.choices + [choice]
280
+ %i[ours theirs].each do |role|
281
+ side = capture.contents.fetch(role)
282
+ return {choice: role} if side.raw.nil? && content.empty? && choices.all?(role)
283
+ end
284
+ raw = encode_git_conflict_text(capture, content)
285
+ if %i[ours theirs].include?(choice) && choices.all?(choice)
286
+ selected = capture.contents.fetch(choice)
287
+ return {choice: choice} if selected.raw == raw && (mode.nil? || mode == selected.mode)
288
+ end
289
+ matching = %i[ours theirs].select do |role|
290
+ side = capture.contents.fetch(role)
291
+ side.raw == raw && (mode.nil? || mode == side.mode)
292
+ end
293
+ return {choice: matching.first} if matching.one?
294
+ selected_role = %i[ours theirs].find { |role| choices.all?(role) }
295
+ selected_side_mode = selected_role && capture.conflict.public_send(selected_role)&.mode
296
+ selected_mode = mode || (selected_side_mode if CONFLICT_MODES.include?(selected_side_mode)) ||
297
+ git_conflict_mode(capture.conflict)
298
+ {choice: :manual, content: raw, mode: selected_mode}
299
+ end
300
+
301
+ def encode_git_conflict_text(capture, text)
302
+ text = validate_manual_conflict_text(text)
303
+ codec = capture.contents.values.find(&:raw)
304
+ codec ? codec.bom + text.encode(codec.encoding).b : text.b
305
+ rescue EncodingError => error
306
+ raise Error, "Cannot encode conflict resolution: #{error.message}"
307
+ end
308
+
309
+ def validate_manual_conflict_text(text)
310
+ raise Error, "Manual resolution requires UTF-8 text" unless text.is_a?(String) && text.valid_encoding? && !text.include?("\0")
311
+ text.encode(Encoding::UTF_8)
312
+ rescue EncodingError
313
+ raise Error, "Manual resolution requires UTF-8 text"
314
+ end
315
+
316
+ def git_conflict_mode(conflict)
317
+ modes = [conflict.ours, conflict.theirs].compact.map(&:mode).select { |value| CONFLICT_MODES.include?(value) }.uniq
318
+ raise Error, "Conflict resolution mode is ambiguous" unless modes.length == 1
319
+ modes.first
320
+ end
321
+
322
+ def schedule_git_conflict_resolution(session, arguments, async:)
323
+ state = git_state
324
+ generation = @git_conflict_generation = (@git_conflict_generation || 0) + 1
325
+ work = lambda do
326
+ path = state.synchronize do |repository|
327
+ repository.resolve_conflict(session.prepared.capture.conflict, **arguments)
328
+ end
329
+ prepared = prepare_git_conflict(capture_git_conflict(state))
330
+ snapshot = state.capture rescue nil
331
+ [path, prepared, snapshot].freeze
332
+ end
333
+ set_git_conflict_buffer_locked(session, true)
334
+ unless @window && async
335
+ result = work.call
336
+ finish_git_conflict_resolution(session, generation, result, nil)
337
+ return result.first
338
+ end
339
+
340
+ self.message = "Resolving merge conflict…"
341
+ @git_conflict_resolve_job = Thread.new do
342
+ current = Thread.current
343
+ result = work.call
344
+ post { finish_git_conflict_resolution(session, generation, result, nil, current) } unless @closed
345
+ rescue StandardError => error
346
+ post { finish_git_conflict_resolution(session, generation, nil, error, current) } unless @closed
347
+ end
348
+ rescue StandardError => error
349
+ set_git_conflict_buffer_locked(session, false)
350
+ invalidate_git if error.is_a?(Thuban::RefLockError)
351
+ raise
352
+ end
353
+
354
+ def finish_git_conflict_resolution(session, generation, result, error, worker = nil)
355
+ @git_conflict_resolve_job = nil if !worker || @git_conflict_resolve_job.equal?(worker)
356
+ return unless generation == @git_conflict_generation && session.equal?(@git_conflict_session)
357
+ if error
358
+ set_git_conflict_buffer_locked(session, false)
359
+ invalidate_git if error.is_a?(Thuban::RefLockError)
360
+ self.message = "Merge conflict: #{error.message}"
361
+ return
362
+ end
363
+
364
+ path, prepared, snapshot = result
365
+ panes = session.panes
366
+ invalidate_git
367
+ install_git_snapshot(git_state, snapshot) if snapshot
368
+ if prepared
369
+ install_git_conflict(prepared, panes: panes)
370
+ else
371
+ cleanup_git_conflict_session(session)
372
+ @git_conflict_session = nil
373
+ self.message = "Resolved #{scm_display_path(path)}; no merge conflicts remain"
374
+ end
375
+ path
376
+ end
377
+
378
+ def cleanup_git_conflict_buffers(session)
379
+ return unless session
380
+ session.editors.to_a.each do |role, current|
381
+ pane = session.panes[%i[base ours theirs].index(role)]
382
+ pane.close(current, discard: true) if pane && pane.editors.include?(current)
383
+ release_buffer(current.buffer, discard: true)
384
+ end
385
+ session.buffers.clear
386
+ session.editors.clear
387
+ end
388
+
389
+ def cleanup_git_conflict_session(session)
390
+ return unless session
391
+ cleanup_git_conflict_buffers(session)
392
+ session.panes.drop(1).reverse_each { |pane| close_empty_pane(pane) if @panes.include?(pane) && pane.editors.empty? }
393
+ focus(session.panes.first) if @panes.include?(session.panes.first)
394
+ @decorations.invalidate(:merge_conflict)
395
+ end
396
+
397
+ def git_conflict_editing?
398
+ session = @git_conflict_session
399
+ session && session.buffers.fetch(:ours, nil)&.dirty?
400
+ end
401
+
402
+ def set_git_conflict_buffer_locked(session, locked)
403
+ buffer = session&.buffers&.fetch(:ours, nil)
404
+ return unless buffer && session.result
405
+
406
+ buffer.instance_variable_set(:@read_only, locked)
407
+ @window&.request_frame
408
+ end
409
+ end
410
+ end