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,641 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "unicode/display_width"
4
+
5
+ module Canopus
6
+ module Workspace::GitDiffView
7
+ MAX_DIFF_BYTES = 10 << 20
8
+ MAX_HISTORY = 200
9
+ MAX_HISTORY_SCAN = 20_000
10
+ MAX_PATH_BYTES = 4_096
11
+ MAX_REVISION_BYTES = 1_024
12
+ SIDE_COLUMN_WIDTH = 160
13
+ DIFF_BUDGET = Porrima::Budget.new(max_bytes: 1 << 20, max_lines: 2_000)
14
+ DELETED_STYLE = {color: "#f9758344"}.freeze
15
+ INSERTED_STYLE = {color: "#80b98744"}.freeze
16
+
17
+ GitDiffSource = Data.define(:path, :before_label, :after_label, :diff, :snapshot, :metadata, :unavailable)
18
+ GitDiffView = Data.define(:source, :mode, :highlights, :hunk_rows, :line_rows)
19
+ GitHistoryEntry = Data.define(:oid, :parent, :subject, :author, :time, :path)
20
+
21
+ def show_scm_diff(change = @scm_selection, mode: :inline, async: !!@window)
22
+ sections = Workspace::GitStaging::SCM_SECTIONS
23
+ raise Error, "Select a changed file" unless change.is_a?(Hash) && sections.key?(change[:kind])
24
+
25
+ state = git_state
26
+ raise Error, "Not a Git repository" unless state
27
+ path = safe_git_diff_path(change.fetch(:path))
28
+ mode = normalize_git_diff_mode(mode)
29
+ schedule_git_diff(mode: mode, async: async) do
30
+ capture = state.synchronize { |repository| capture_scm_diff(repository, path, change.fetch(:kind)) }
31
+ snapshot, fallback, pair = materialize_scm_diff(capture)
32
+ metadata = scm_metadata_change(snapshot.before, snapshot.after) if snapshot.diff&.empty? && !snapshot.hunks.empty?
33
+ unavailable = if pair.compact.empty?
34
+ git_diff_unavailable(path, snapshot.before.text, snapshot.after.text,
35
+ byte_values: [snapshot.before.raw, snapshot.after.raw]) || fallback
36
+ end
37
+ build_git_diff_source(path, pair.first, pair.last, snapshot: snapshot, metadata: metadata,
38
+ diff: snapshot.diff, unavailable: unavailable)
39
+ end
40
+ end
41
+
42
+ def show_git_diff(mode: :inline, async: !!@window)
43
+ current = editor
44
+ path = safe_git_diff_path(git_relative_path(current.buffer))
45
+ after_size = current.buffer.rope.bytesize
46
+ after = after_size <= MAX_DIFF_BYTES ? current.buffer.text.dup.freeze : nil
47
+ state = git_state
48
+ raise Error, "Not a Git repository" unless state
49
+ mode = normalize_git_diff_mode(mode)
50
+ schedule_git_diff(mode: mode, async: async) do
51
+ raw = state.synchronize { |repository| repository.blob(path) }
52
+ before = decode_git_diff_content(raw)
53
+ unavailable = git_diff_unavailable(path, before, after, byte_values: [raw, after_size])
54
+ build_git_diff_source(path, before, after, unavailable: unavailable)
55
+ end
56
+ end
57
+
58
+ def compare_git_revisions(before, after, path: nil, mode: :inline, async: !!@window)
59
+ state = git_state
60
+ raise Error, "Not a Git repository" unless state
61
+ path = safe_git_diff_path(git_diff_target_path(path))
62
+ before = validate_git_revision(before, allow_nil: true)
63
+ after = validate_git_revision(after)
64
+ mode = normalize_git_diff_mode(mode)
65
+ schedule_git_diff(mode: mode, async: async) do
66
+ captured = state.synchronize do |repository|
67
+ left_commit = resolve_git_revision(repository, before)
68
+ right_commit = resolve_git_revision(repository, after)
69
+ left, left_text = revision_file_content(repository, left_commit, path)
70
+ right, right_text = revision_file_content(repository, right_commit, path)
71
+ metadata = scm_metadata_change(left, right) if left_text == right_text &&
72
+ (left.exists != right.exists || left.mode != right.mode)
73
+ unavailable = git_diff_unavailable(path, left_text, right_text, byte_values: [left.raw, right.raw])
74
+ [left_text, right_text, metadata, unavailable].freeze
75
+ end
76
+ build_git_diff_source(path, captured[0], captured[1], snapshot: nil, metadata: captured[2],
77
+ unavailable: captured[3], before_label: revision_label(path, before), after_label: revision_label(path, after))
78
+ end
79
+ end
80
+
81
+ def show_git_revision_diff(before, after, **options) = compare_git_revisions(before, after, **options)
82
+ def show_revision_diff(before, after, **options) = compare_git_revisions(before, after, **options)
83
+
84
+ def toggle_git_diff_mode(current = editor, async: !!@window)
85
+ view = current&.buffer&.instance_variable_get(:@git_diff_view)
86
+ raise Error, "Open a Git diff first" unless view
87
+
88
+ mode = view.mode == :inline ? :side_by_side : :inline
89
+ pane = @panes.find { |candidate| candidate.editors.include?(current) }
90
+ schedule_git_diff(mode: mode, async: async, pane: pane, replacing: current) { view.source }
91
+ end
92
+
93
+ alias_method :toggle_scm_diff_mode, :toggle_git_diff_mode
94
+
95
+ def git_file_history(path = nil, revision: "HEAD", limit: 100)
96
+ state = git_state
97
+ raise Error, "Not a Git repository" unless state
98
+ path = safe_git_diff_path(git_diff_target_path(path))
99
+ revision = validate_git_revision(revision)
100
+ unless limit.is_a?(Integer) && limit.between?(1, MAX_HISTORY)
101
+ raise ArgumentError, "history limit must be between 1 and #{MAX_HISTORY}"
102
+ end
103
+
104
+ state.synchronize { |repository| collect_git_file_history(repository, path, revision, limit) }
105
+ end
106
+
107
+ def show_git_file_history(path = nil, revision: "HEAD", limit: 100, async: !!@window)
108
+ path = safe_git_diff_path(git_diff_target_path(path))
109
+ revision = validate_git_revision(revision)
110
+ if @window && async
111
+ generation = @git_history_generation = (@git_history_generation || 0) + 1
112
+ palette_generation = @palette_generation.to_i
113
+ @message = "Loading Git history…"
114
+ worker = Thread.new do
115
+ current_worker = Thread.current
116
+ entries = git_file_history(path, revision: revision, limit: limit)
117
+ post { finish_git_history(current_worker, generation, palette_generation, path, entries, nil) } unless @closed
118
+ rescue StandardError => error
119
+ post { finish_git_history(current_worker, generation, palette_generation, path, nil, error) } unless @closed
120
+ end
121
+ (@git_history_jobs ||= []) << worker
122
+ return worker
123
+ end
124
+
125
+ install_git_history(path, git_file_history(path, revision: revision, limit: limit))
126
+ end
127
+
128
+ def show_git_revision_compare(path = nil)
129
+ path = safe_git_diff_path(git_diff_target_path(path))
130
+ self.palette = {kind: :git_revision_compare, query: +"", index: 0, matches: [], path: path}
131
+ end
132
+
133
+ def scm_diff_decorations(buffer, rows)
134
+ view = buffer.instance_variable_get(:@git_diff_view)
135
+ return super unless view
136
+
137
+ items = view.highlights.select do |item|
138
+ row = buffer.rope.point_at(item.range.begin).row
139
+ rows.cover?(row)
140
+ end
141
+ snapshot = view.source.snapshot
142
+ return items unless snapshot&.diff && !snapshot.conflicted
143
+
144
+ action = snapshot.kind == :staged ? :unstage : :stage
145
+ verb = action == :stage ? "Stage" : "Unstage"
146
+ view.line_rows.each do |display_row, index|
147
+ next unless rows.cover?(display_row)
148
+ click = ->(current, row) { change_scm_line(action, index, current.buffer, row) }
149
+ items << Decoration::Item.new(:gutter, nil, display_row, "#{verb} line",
150
+ {color: :accent, gutter_offset: 2, gutter_width: 3, hit_width: 10}.freeze,
151
+ 1, :scm_diff, click)
152
+ end
153
+ view.hunk_rows.each do |display_row, index|
154
+ next unless rows.cover?(display_row) && !view.line_rows.key?(display_row)
155
+ click = ->(current, row) { change_scm_hunk(action, index, current.buffer, row) }
156
+ items << Decoration::Item.new(:gutter, nil, display_row, "#{verb} hunk",
157
+ {color: :muted, gutter_offset: 2, gutter_width: 3, hit_width: 10}.freeze,
158
+ 2, :scm_diff, click)
159
+ end
160
+ items
161
+ end
162
+
163
+ def close_git
164
+ @git_view_generation = (@git_view_generation || 0) + 1
165
+ @git_history_generation = (@git_history_generation || 0) + 1
166
+ jobs = [*@git_view_jobs, *@git_history_jobs].compact.uniq.reject { |thread| thread.equal?(Thread.current) }
167
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 0.1
168
+ jobs.each do |thread|
169
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
170
+ break unless remaining.positive?
171
+ thread.join(remaining)
172
+ end
173
+ jobs.each { |thread| thread.kill if thread.alive? }
174
+ jobs.each(&:join)
175
+ @git_view_jobs&.clear
176
+ @git_history_jobs&.clear
177
+ super
178
+ end
179
+
180
+ private
181
+
182
+ def schedule_git_diff(mode:, async:, pane: @active_pane, replacing: nil, &capture)
183
+ generation = @git_view_generation = (@git_view_generation || 0) + 1
184
+ work = -> { materialize_git_diff(capture.call, mode) }
185
+ return install_git_diff(*work.call, pane: pane, replacing: replacing) unless @window && async
186
+
187
+ @message = "Loading Git diff…"
188
+ worker = Thread.new do
189
+ current_worker = Thread.current
190
+ result = work.call
191
+ post { finish_git_diff(current_worker, generation, result, nil, pane, replacing) } unless @closed
192
+ rescue StandardError => error
193
+ post { finish_git_diff(current_worker, generation, nil, error, pane, replacing) } unless @closed
194
+ end
195
+ (@git_view_jobs ||= []) << worker
196
+ worker
197
+ end
198
+
199
+ def finish_git_diff(worker, generation, result, error, pane, replacing)
200
+ @git_view_jobs&.delete(worker)
201
+ return unless generation == @git_view_generation
202
+ if error
203
+ self.message = "Git diff: #{error.message}"
204
+ else
205
+ install_git_diff(*result, pane: pane, replacing: replacing)
206
+ end
207
+ end
208
+
209
+ def install_git_diff(text, view, pane:, replacing:)
210
+ return unless @panes.include?(pane)
211
+ return if replacing && !pane.editors.include?(replacing)
212
+
213
+ buffer = Buffer.new(text, read_only: true)
214
+ buffer.instance_variable_set(:@git_diff_view, view)
215
+ buffer.instance_variable_set(:@scm_diff_snapshot, view.source.snapshot)
216
+ @decorations.invalidate(:scm_diff, buffer: buffer)
217
+ @buffers[buffer.object_id] = buffer
218
+ pinned = replacing && pane.pinned.include?(replacing)
219
+ document = pane.open(buffer)
220
+ document.language = Language::Definition.new("diff", "diff", [], "", /\A\z/, /\A\z/, [])
221
+ apply_editor_settings(document)
222
+ pane.pin(document) if pinned
223
+ close_editor(replacing) if replacing
224
+ invalidate_hidden_selection_ranges
225
+ @window&.request_frame
226
+ document
227
+ rescue StandardError
228
+ @buffers.delete(buffer&.object_id)
229
+ buffer&.close
230
+ raise
231
+ end
232
+
233
+ def build_git_diff_source(path, before, after, snapshot: nil, metadata: nil, diff: nil, unavailable: nil,
234
+ before_label: nil, after_label: nil)
235
+ display = scm_display_path(path)
236
+ if !unavailable && before && after && (reason = scm_diff_budget_reason(before, after))
237
+ unavailable = "Diff unavailable: #{scm_display_path(path)} #{scm_diff_budget_description(reason)}\n"
238
+ end
239
+ unless unavailable
240
+ diff ||= Porrima.diff(before, after, budget: before == after ? nil : DIFF_BUDGET)
241
+ diff.edits
242
+ diff.hunks
243
+ diff.rows
244
+ diff.stat
245
+ end
246
+ GitDiffSource.new(path.dup.freeze, (before_label || "a/#{display}").freeze,
247
+ (after_label || "b/#{display}").freeze, diff, unavailable ? nil : snapshot,
248
+ metadata&.dup&.freeze, unavailable&.dup&.freeze).freeze
249
+ rescue Porrima::BudgetExceeded
250
+ retry_source = "Diff unavailable: #{scm_display_path(path)} exceeds the diff budget\n"
251
+ GitDiffSource.new(path.dup.freeze, (before_label || "a/#{display}").freeze,
252
+ (after_label || "b/#{display}").freeze, nil, nil, metadata&.dup&.freeze,
253
+ retry_source.freeze).freeze
254
+ end
255
+
256
+ def materialize_git_diff(source, mode)
257
+ if source.unavailable
258
+ view = GitDiffView.new(source, mode, [].freeze, {}.freeze, {}.freeze).freeze
259
+ return [source.unavailable, view]
260
+ end
261
+
262
+ mode == :inline ? render_inline_git_diff(source) : render_side_by_side_git_diff(source)
263
+ end
264
+
265
+ def render_inline_git_diff(source)
266
+ output = +"--- #{source.before_label}\n+++ #{source.after_label}\n"
267
+ highlights = []
268
+ spans = inline_spans(source.diff)
269
+ source.diff.hunks.each do |hunk|
270
+ output << "@@ -#{hunk.old_start},#{hunk.old_count} +#{hunk.new_start},#{hunk.new_count} @@\n"
271
+ hunk.edits.each do |edit|
272
+ output << {equal: " ", delete: "-", insert: "+"}.fetch(edit.kind)
273
+ base = output.bytesize
274
+ output << edit.text
275
+ record_inline_highlights(highlights, base, edit.text, spans[edit.object_id], edit.kind)
276
+ output << "\n\\n" unless edit.text.end_with?("\n")
277
+ end
278
+ end
279
+ if source.metadata
280
+ output << "@@ metadata @@\n #{source.metadata}\n"
281
+ end
282
+ snapshot = source.snapshot
283
+ hunk_rows = snapshot ? snapshot.hunk_rows : {}.freeze
284
+ line_rows = snapshot ? snapshot.line_rows : {}.freeze
285
+ view = GitDiffView.new(source, :inline, highlights.freeze, hunk_rows, line_rows).freeze
286
+ [output, view]
287
+ end
288
+
289
+ def render_side_by_side_git_diff(source)
290
+ groups = source.diff.hunks.map { |hunk| paired_hunk_rows(hunk) }
291
+ tab_size = git_diff_tab_size
292
+ old_digits = [source.diff.hunks.map { |hunk| hunk.old_start + hunk.old_count }.max.to_i.to_s.length, 1].max
293
+ new_digits = [source.diff.hunks.map { |hunk| hunk.new_start + hunk.new_count }.max.to_i.to_s.length, 1].max
294
+ left_width = groups.flatten(1).map do |old, new|
295
+ marker = side_line_ending_marker(old, new)
296
+ side_display_width(side_text(old, old_digits, "-") + marker, tab_size)
297
+ end.max.to_i.clamp(1, SIDE_COLUMN_WIDTH)
298
+ output = +"#{source.before_label} │ #{source.after_label}\n"
299
+ highlights, hunk_rows, line_rows = [], {}, {}
300
+ unit_by_edit = staging_units(source.snapshot)
301
+ display_row = 1
302
+ groups.each_with_index do |pairs, hunk_index|
303
+ hunk = source.diff.hunks.fetch(hunk_index)
304
+ hunk_rows[display_row] = hunk_index if source.snapshot
305
+ output << "@@ -#{hunk.old_start},#{hunk.old_count} +#{hunk.new_start},#{hunk.new_count} @@\n"
306
+ display_row += 1
307
+ pairs.each do |old, new|
308
+ hunk_rows[display_row] = hunk_index if source.snapshot
309
+ index = (old && unit_by_edit[old.object_id]) || (new && unit_by_edit[new.object_id])
310
+ line_rows[display_row] = index if index
311
+ old_spans, new_spans = old.equal?(new) ? [nil, nil] : Porrima::Inline.refine(old&.text.to_s, new&.text.to_s)
312
+ old_prefix = side_prefix(old, old_digits, old.equal?(new) ? " " : "-", side: :old)
313
+ old_body = diff_line_body(old&.text)
314
+ old_marker = side_line_ending_marker(old, new)
315
+ output << old_prefix
316
+ old_base = output.bytesize
317
+ old_body, old_ranges = fit_side_body(old_body, old_spans, old&.kind,
318
+ side_display_width(old_prefix, tab_size), left_width, tab_size, old_marker)
319
+ output << old_body
320
+ padding = [left_width - side_display_width(old_prefix + old_body, tab_size), 0].max
321
+ output << " " * padding << " │ "
322
+ new_prefix = side_prefix(new, new_digits, old.equal?(new) ? " " : "+", side: :new)
323
+ output << new_prefix
324
+ new_base = output.bytesize
325
+ new_body = diff_line_body(new&.text)
326
+ new_marker = side_line_ending_marker(new, old)
327
+ new_body, new_ranges = fit_side_body(new_body, new_spans, new&.kind,
328
+ side_display_width(new_prefix, tab_size), SIDE_COLUMN_WIDTH, tab_size, new_marker)
329
+ output << new_body << "\n"
330
+ display_row += 1
331
+ next if old.equal?(new)
332
+
333
+ old_ranges.each do |range|
334
+ highlights << Decoration::Item.new(:highlight, (old_base + range.begin...old_base + range.end).freeze,
335
+ nil, nil, DELETED_STYLE, 0, :scm_diff, nil)
336
+ end
337
+ new_ranges.each do |range|
338
+ highlights << Decoration::Item.new(:highlight, (new_base + range.begin...new_base + range.end).freeze,
339
+ nil, nil, INSERTED_STYLE, 0, :scm_diff, nil)
340
+ end
341
+ end
342
+ end
343
+ if source.metadata
344
+ hunk_rows[display_row] = 0 if source.snapshot
345
+ output << "@@ metadata @@\n"
346
+ display_row += 1
347
+ hunk_rows[display_row] = 0 if source.snapshot
348
+ output << source.metadata << "\n"
349
+ end
350
+ view = GitDiffView.new(source, :side_by_side, highlights.freeze,
351
+ hunk_rows.freeze, line_rows.freeze).freeze
352
+ [output, view]
353
+ end
354
+
355
+ def inline_spans(diff)
356
+ diff.hunks.each_with_object({}) do |hunk, result|
357
+ paired_hunk_rows(hunk).each do |old, new|
358
+ next if old.equal?(new)
359
+ old_spans, new_spans = Porrima::Inline.refine(old&.text.to_s, new&.text.to_s)
360
+ result[old.object_id] = old_spans if old
361
+ result[new.object_id] = new_spans if new
362
+ end
363
+ end
364
+ end
365
+
366
+ def paired_hunk_rows(hunk)
367
+ rows = []
368
+ index = 0
369
+ while index < hunk.edits.length
370
+ edit = hunk.edits[index]
371
+ if edit.kind == :equal
372
+ rows << [edit, edit]
373
+ index += 1
374
+ next
375
+ end
376
+ changed = []
377
+ while index < hunk.edits.length && hunk.edits[index].kind != :equal
378
+ changed << hunk.edits[index]
379
+ index += 1
380
+ end
381
+ deleted = changed.select { |item| item.kind == :delete }
382
+ inserted = changed.select { |item| item.kind == :insert }
383
+ [deleted.length, inserted.length].max.times { |offset| rows << [deleted[offset], inserted[offset]] }
384
+ end
385
+ rows
386
+ end
387
+
388
+ def record_inline_highlights(items, base, text, spans, kind)
389
+ return unless spans && kind != :equal
390
+ record_highlights(items, base, diff_line_body(text).bytesize, spans, kind)
391
+ end
392
+
393
+ def fit_side_body(body, spans, kind, column, width, tab_size, marker = "")
394
+ highlighted = []
395
+ offset = 0
396
+ spans&.each do |span|
397
+ ending = offset + span.text.bytesize
398
+ highlighted << (offset...ending) if span.kind == kind
399
+ offset = ending
400
+ end
401
+ content_width = [width - side_display_width(marker, tab_size), column].max
402
+ output, ranges, source_offset, highlighted_index, truncated = +"", [], 0, 0, false
403
+ body.each_grapheme_cluster do |grapheme|
404
+ display = grapheme == "\t" ? " " * (tab_size - column % tab_size) : grapheme
405
+ grapheme_width = Unicode::DisplayWidth.of(display, emoji: :rgi)
406
+ ending = source_offset + grapheme.bytesize
407
+ if column + grapheme_width > content_width - (ending < body.bytesize ? 1 : 0)
408
+ truncated = true
409
+ break
410
+ end
411
+ start = output.bytesize
412
+ output << display
413
+ highlighted_index += 1 while highlighted[highlighted_index] && highlighted[highlighted_index].end <= source_offset
414
+ range = highlighted[highlighted_index]
415
+ if range && range.begin < ending
416
+ if ranges.last&.end == start
417
+ ranges[-1] = (ranges.last.begin...output.bytesize)
418
+ else
419
+ ranges << (start...output.bytesize)
420
+ end
421
+ end
422
+ source_offset = ending
423
+ column += grapheme_width
424
+ end
425
+ output << "…" if truncated
426
+ unless marker.empty?
427
+ start = output.bytesize
428
+ output << marker
429
+ ranges << (start...output.bytesize) if kind == :delete || kind == :insert
430
+ end
431
+ [output, ranges.freeze]
432
+ end
433
+
434
+ def side_display_width(text, tab_size)
435
+ column = 0
436
+ text.each_grapheme_cluster do |grapheme|
437
+ column += grapheme == "\t" ? tab_size - column % tab_size : Unicode::DisplayWidth.of(grapheme, emoji: :rgi)
438
+ end
439
+ column
440
+ end
441
+
442
+ def git_diff_tab_size = @settings.for_language("diff")["tab_size"]
443
+
444
+ def record_highlights(items, base, limit, spans, kind)
445
+ offset = 0
446
+ spans.each do |span|
447
+ ending = [offset + span.text.bytesize, limit].min
448
+ if span.kind == kind && ending > offset
449
+ style = kind == :delete ? DELETED_STYLE : INSERTED_STYLE
450
+ items << Decoration::Item.new(:highlight, (base + offset...base + ending).freeze,
451
+ nil, nil, style, 0, :scm_diff, nil)
452
+ end
453
+ offset += span.text.bytesize
454
+ end
455
+ end
456
+
457
+ def side_text(edit, digits, marker)
458
+ side_prefix(edit, digits, marker, side: :old) + diff_line_body(edit&.text)
459
+ end
460
+
461
+ def side_prefix(edit, digits, marker, side:)
462
+ number = edit && (side == :new ? edit.new_line : edit.old_line)
463
+ "#{number ? number.to_s.rjust(digits) : ' ' * digits} #{edit ? marker : ' '} "
464
+ end
465
+
466
+ def diff_line_body(text) = text.to_s.delete_suffix("\n").delete_suffix("\r")
467
+
468
+ def side_line_ending_marker(edit, peer)
469
+ return "" unless edit
470
+ ending = git_line_ending(edit.text)
471
+ other = git_line_ending(peer&.text)
472
+ return " [no newline]" if ending == :none
473
+ return "" unless peer && ending != other
474
+ " [#{ending.to_s.upcase}]"
475
+ end
476
+
477
+ def git_line_ending(text)
478
+ return :crlf if text&.end_with?("\r\n")
479
+ return :lf if text&.end_with?("\n")
480
+ return :cr if text&.end_with?("\r")
481
+ :none
482
+ end
483
+
484
+ def staging_units(snapshot)
485
+ return {} unless snapshot
486
+ snapshot.lines.each_with_index.each_with_object({}) do |(unit, index), result|
487
+ unit.edits.each { |edit| result[edit.object_id] = index }
488
+ end
489
+ end
490
+
491
+ def decode_git_diff_content(raw)
492
+ return "" unless raw
493
+ return nil if raw.bytesize > MAX_DIFF_BYTES
494
+ scm_content(true, raw, nil).text
495
+ end
496
+
497
+ def git_diff_unavailable(path, before, after, byte_values:)
498
+ return "Diff unavailable: #{scm_display_path(path)} exceeds 10 MiB\n" if
499
+ byte_values.compact.sum { |value| value.respond_to?(:bytesize) ? value.bytesize : value.to_i } > MAX_DIFF_BYTES
500
+ return "Binary file changed: #{scm_display_path(path)}\n" unless before && after
501
+ end
502
+
503
+ def revision_file_content(repository, commit, path)
504
+ entry = commit && repository.tree(commit.oid)[path]
505
+ return [scm_content(false, nil, nil), ""] unless entry
506
+ if entry.mode == 0o160000
507
+ text = "Subproject commit #{entry.oid}\n"
508
+ content = Workspace::GitStaging::SCMContent.new(true, text.b.freeze, text.freeze,
509
+ Encoding::UTF_8, "".b.freeze, entry.mode).freeze
510
+ return [content, text]
511
+ end
512
+ content = scm_entry_content(repository, entry)
513
+ [content, content.text]
514
+ end
515
+
516
+ def revision_label(path, revision) = "#{scm_display_path(path)}@#{revision || '(empty)'}"
517
+
518
+ def normalize_git_diff_mode(mode)
519
+ return :inline if mode == :inline || mode == "inline"
520
+ return :side_by_side if [:side_by_side, :"side-by-side", "side_by_side", "side-by-side"].include?(mode)
521
+ raise ArgumentError, "Git diff mode must be :inline or :side_by_side"
522
+ end
523
+
524
+ def git_diff_target_path(path)
525
+ return path if path
526
+ view = editor&.buffer&.instance_variable_get(:@git_diff_view)
527
+ view ? view.source.path : git_relative_path
528
+ end
529
+
530
+ def safe_git_diff_path(path)
531
+ valid = path.is_a?(String) && path.bytesize.between?(1, MAX_PATH_BYTES) && !path.include?("\0")
532
+ raise Error, "Invalid Git path" unless valid
533
+ pieces = path.split("/", -1)
534
+ windows = Gem.win_platform?
535
+ drive = path.bytesize > 1 && ((65..90).cover?(path.getbyte(0)) || (97..122).cover?(path.getbyte(0))) && path.getbyte(1) == 58
536
+ unsafe = path.start_with?("/") || drive || pieces.any? do |piece|
537
+ windows_stem = piece.split(".", 2).first.sub(/[ ]+\z/, "") if windows
538
+ windows_reserved = windows_stem&.match?(
539
+ /\A(?:con(?:in\$|out\$)?|prn|aux|nul|clock\$|com[0-9¹²³]|lpt[0-9¹²³])\z/i
540
+ )
541
+ windows_git_alias = piece.match?(/\A\.?git~[0-9]+\z/i) if windows
542
+ ["", ".", ".."].include?(piece) || piece.casecmp?(".git") || windows &&
543
+ (piece.end_with?(".", " ") || piece.match?(/[\x01-\x1f<>:"|?*\\]/) || windows_reserved || windows_git_alias)
544
+ end
545
+ raise Error, "Unsafe Git path: #{scm_display_path(path)}" if unsafe
546
+ repository = git
547
+ raise Error, "Not a Git repository" unless repository
548
+ repository.worktree_path(path)
549
+ path.dup.freeze
550
+ rescue ArgumentError
551
+ raise Error, "Unsafe Git path: #{scm_display_path(path)}"
552
+ end
553
+
554
+ def validate_git_revision(revision, allow_nil: false)
555
+ return if allow_nil && revision.nil?
556
+ unless revision.is_a?(String) && revision.valid_encoding? && revision.bytesize.between?(1, MAX_REVISION_BYTES) &&
557
+ !revision.match?(/[\x00-\x20\\]/)
558
+ raise Error, "Invalid Git revision"
559
+ end
560
+ revision.dup.freeze
561
+ end
562
+
563
+ def resolve_git_revision(repository, revision)
564
+ return unless revision
565
+ repository.commit(revision) || raise(Error, "Unknown Git revision: #{revision}")
566
+ rescue ArgumentError => error
567
+ raise Error, "Invalid Git revision #{revision}: #{error.message}"
568
+ end
569
+
570
+ def collect_git_file_history(repository, path, revision, limit)
571
+ resolve_git_revision(repository, revision)
572
+ trees = {}
573
+ tree = lambda do |oid|
574
+ value = trees.delete(oid) || repository.tree(oid)
575
+ trees[oid] = value
576
+ trees.shift while trees.length > 2
577
+ value
578
+ end
579
+ history = []
580
+ scanned = 0
581
+ current_path = path
582
+ repository.each_commit(revision, limit: MAX_HISTORY_SCAN) do |commit|
583
+ scanned += 1
584
+ current_tree = tree.call(commit.oid)
585
+ current = current_tree[current_path]
586
+ parent = commit.parents.first
587
+ parent_tree = tree.call(parent) if parent
588
+ previous = parent_tree&.[](current_path)
589
+ rename_candidates = if current && !previous && parent_tree
590
+ parent_tree.each_value.select do |entry|
591
+ same_git_tree_entry?(current, entry) && !same_git_tree_entry?(entry, current_tree[entry.path])
592
+ end
593
+ else
594
+ []
595
+ end
596
+ renamed_from = rename_candidates.one? ? rename_candidates.first.path : nil
597
+ next if !renamed_from && same_git_tree_entry?(current, previous)
598
+ signature = commit.signature(role: :author)
599
+ subject = safe_git_history_text(commit.message.to_s.lines.first, 200)
600
+ author = safe_git_history_text(signature.name, 100)
601
+ history << GitHistoryEntry.new(commit.oid.dup.freeze, parent&.dup&.freeze, subject.freeze,
602
+ author.freeze, signature.time, current_path.dup.freeze).freeze
603
+ current_path = renamed_from if renamed_from
604
+ break if history.length >= limit
605
+ break if rename_candidates.length > 1
606
+ end
607
+ raise Error, "Git history scan exceeded #{MAX_HISTORY_SCAN} commits" if scanned == MAX_HISTORY_SCAN && history.length < limit
608
+ history.freeze
609
+ end
610
+
611
+ def same_git_tree_entry?(left, right)
612
+ return !left && !right unless left && right
613
+ left.oid == right.oid && left.mode == right.mode
614
+ end
615
+
616
+ def git_history_label(entry)
617
+ subject = safe_git_history_text(entry.subject, 200)
618
+ author = safe_git_history_text(entry.author, 100)
619
+ author = author.empty? ? "" : " · #{author}"
620
+ "#{entry.oid[0, 8]} #{subject}#{author}"
621
+ end
622
+
623
+ def safe_git_history_text(value, limit)
624
+ value.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
625
+ .gsub(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/, " ").strip.slice(0, limit)
626
+ end
627
+
628
+ def install_git_history(path, entries)
629
+ self.palette = {kind: :git_file_history, query: +"", index: 0,
630
+ matches: entries.map { |entry| git_history_label(entry) }, items: entries, path: path}
631
+ update_palette
632
+ @palette
633
+ end
634
+
635
+ def finish_git_history(worker, generation, palette_generation, path, entries, error)
636
+ @git_history_jobs&.delete(worker)
637
+ return unless generation == @git_history_generation && palette_generation == @palette_generation.to_i
638
+ error ? self.message = "Git history: #{error.message}" : install_git_history(path, entries)
639
+ end
640
+ end
641
+ end