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,583 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zaniah/ui"
4
+
5
+ module Canopus
6
+ module Workspace::GitStaging
7
+ SCM_SECTIONS = {staged: "Staged", unstaged: "Changes", untracked: "Untracked"}.freeze
8
+ SCM_DIFF_MAX_BYTES = 10 << 20
9
+ SCM_DIFF_MAX_LINES = 200_000
10
+ SCM_DIFF_BUDGET = Porrima::Budget.new(max_bytes: 1 << 20, max_lines: 2_000)
11
+ SCM_COMMIT_SUBJECT_GUIDE = 50
12
+ SCM_COMMIT_BODY_GUIDE = 72
13
+ SCMContent = Data.define(:exists, :raw, :text, :encoding, :bom, :mode)
14
+ SCMDiffSnapshot = Data.define(:path, :kind, :head, :index_entries, :worktree, :conflicted, :before, :after,
15
+ :diff, :hunks, :lines, :hunk_rows, :line_rows)
16
+
17
+ def scm_tree
18
+ return @scm_tree if @scm_tree
19
+
20
+ @scm_tree = Zaniah::UI::TreeView.new(scm_nodes, height: 600).on_select do |change, event, _context|
21
+ select_scm_change(change, event)
22
+ end
23
+ SCM_SECTIONS.each_key { |kind| @scm_tree.expand([:scm_section, kind]) }
24
+ refresh_scm
25
+ @scm_tree
26
+ end
27
+
28
+ def scm_panel
29
+ @scm_commit_message ||= +""
30
+ @scm_commit_input ||= Zaniah::UI::TextArea.new(@scm_commit_message, rows: 5,
31
+ label: "Commit message", placeholder: "Subject\n\nBody").on_change do |value|
32
+ @scm_commit_message = value
33
+ end
34
+ @scm_commit_amend ||= false
35
+ @scm_commit_amend_control ||= Zaniah::UI::Checkbox.new("Amend", value: @scm_commit_amend).on_change do |value, *, **|
36
+ @scm_commit_amend = value
37
+ end
38
+ @scm_commit_button ||= Zaniah::UI::Button.new("Commit").on_click { submit_scm_commit }
39
+ guide = Zaniah::UI::Label.new(
40
+ "Subject guide: #{SCM_COMMIT_SUBJECT_GUIDE} columns · Body guide: #{SCM_COMMIT_BODY_GUIDE} columns",
41
+ tone: :muted, size: :xs, wrap: :word
42
+ )
43
+ controls = Zaniah::Div.new.flex_row.items_center.gap(8)
44
+ .children([@scm_commit_amend_control, @scm_commit_button])
45
+ Zaniah::Div.new.flex_col.gap(8).p(4)
46
+ .children([@scm_commit_input, guide, controls, scm_tree.flex_1])
47
+ end
48
+
49
+ def commit_git(message = nil, amend: nil)
50
+ message = message.nil? ? @scm_commit_message.to_s : String(message)
51
+ amend = !!@scm_commit_amend if amend.nil?
52
+ raise ArgumentError, "amend must be true or false" unless [true, false].include?(amend)
53
+ raise Error, "Enter a commit message" if message.strip.empty?
54
+ raise Error, "Git commit already in progress" if @git_commit_job&.alive?
55
+
56
+ state = git_state
57
+ raise Error, "Not a Git repository" unless state
58
+ snapshot = state.snapshot
59
+ snapshot ||= state.capture unless @window
60
+ raise Error, "Git status is still loading; try again" unless snapshot
61
+ raise Error, "Cannot commit from a detached HEAD" unless snapshot.branch
62
+ staged = snapshot.entries.any? { |_, code| !code.start_with?(" ", "?") }
63
+ raise Error, "Stage changes before committing" unless amend || staged
64
+
65
+ submitted = message.dup.freeze
66
+ set_scm_commit_busy(true)
67
+ @git_commit_job = Thread.new do
68
+ oid = state.synchronize(snapshot: snapshot, index_lock: true) do |repository|
69
+ current = repository.commit
70
+ raise Error, "Cannot amend an unborn branch" if amend && !current
71
+
72
+ author = amend ? current.signature(role: :author) : repository.signature(role: :author)
73
+ committer = repository.signature(role: :committer)
74
+ repository.commit!(message: submitted, author: author, committer: committer, amend: amend)
75
+ end
76
+ latest = state.capture rescue nil
77
+ worker = Thread.current
78
+ post { finish_git_commit(worker, state, latest, submitted, oid, nil) } unless @closed
79
+ rescue StandardError => error
80
+ latest = state.capture rescue nil
81
+ worker = Thread.current
82
+ post { finish_git_commit(worker, state, latest, submitted, nil, error) } unless @closed
83
+ end
84
+ end
85
+
86
+ def refresh_scm
87
+ entries = git_status
88
+ @scm_tree&.replace(scm_nodes(entries))
89
+ @panels.badge("scm", entries.empty? ? nil : entries.length) if @panels&.key?("scm")
90
+ @window&.request_frame
91
+ nil
92
+ end
93
+
94
+ def stage_git_file(path = nil)
95
+ path ||= selected_scm_path(:unstaged, :untracked)
96
+ state = git_state
97
+ raise Error, "Not a Git repository" unless state
98
+
99
+ state.synchronize do |repository|
100
+ index = repository.index
101
+ absolute = repository.worktree_path(path)
102
+ if File.exist?(absolute) || File.symlink?(absolute)
103
+ stat = File.lstat(absolute)
104
+ if stat.directory?
105
+ entry = index[path] || repository.tree[path]
106
+ raise Error, "Git submodule is not initialized: #{path}" unless entry&.mode == 0o160000
107
+ nested = submodule_repository(absolute)
108
+ raise Error, "Git submodule is not initialized: #{path}" unless nested
109
+ oid = nested.head
110
+ raise Error, "Git submodule has no HEAD: #{path}" unless oid
111
+ index.stage(path, oid, 0o160000, stat: stat)
112
+ else
113
+ content = repository.worktree_content(path)
114
+ mode = scm_file_mode(repository, stat, index[path] || repository.tree[path])
115
+ index.stage(path, repository.write_blob(content), mode, stat: stat)
116
+ end
117
+ else
118
+ index.remove(path)
119
+ end
120
+ index.write
121
+ end
122
+ refresh_after_index_write
123
+ path
124
+ rescue Thuban::RefLockError
125
+ refresh_after_index_write
126
+ raise
127
+ end
128
+
129
+ def stage_git_hunk(index = nil, buffer: editor.buffer, row: nil)
130
+ change_scm_hunk(:stage, index, buffer, row)
131
+ end
132
+
133
+ def unstage_git_hunk(index = nil, buffer: editor.buffer, row: nil)
134
+ change_scm_hunk(:unstage, index, buffer, row)
135
+ end
136
+
137
+ def stage_git_line(index = nil, buffer: editor.buffer, row: nil)
138
+ change_scm_line(:stage, index, buffer, row)
139
+ end
140
+
141
+ def unstage_git_line(index = nil, buffer: editor.buffer, row: nil)
142
+ change_scm_line(:unstage, index, buffer, row)
143
+ end
144
+
145
+ def unstage_git_file(path = nil)
146
+ path ||= selected_scm_path(:staged)
147
+ state = git_state
148
+ raise Error, "Not a Git repository" unless state
149
+
150
+ state.synchronize do |repository|
151
+ index = repository.index
152
+ if (entry = repository.tree[path])
153
+ index.stage(path, entry.oid, entry.mode)
154
+ else
155
+ index.remove(path)
156
+ end
157
+ index.write
158
+ end
159
+ refresh_after_index_write
160
+ path
161
+ rescue Thuban::RefLockError
162
+ refresh_after_index_write
163
+ raise
164
+ end
165
+
166
+ def show_scm_diff(change = @scm_selection)
167
+ raise Error, "Select a changed file" unless change.is_a?(Hash) && SCM_SECTIONS.key?(change[:kind])
168
+
169
+ path, kind = change.values_at(:path, :kind)
170
+ state = git_state
171
+ raise Error, "Not a Git repository" unless state
172
+ capture = state.synchronize { |repository| capture_scm_diff(repository, path, kind) }
173
+ snapshot, text = materialize_scm_diff(capture)
174
+ buffer = Buffer.new(text, read_only: true)
175
+ buffer.instance_variable_set(:@scm_diff_snapshot, snapshot)
176
+ @decorations.invalidate(:scm_diff, buffer: buffer)
177
+ @buffers[buffer.object_id] = buffer
178
+ document = @active_pane.open(buffer)
179
+ document.language = Language::Definition.new("diff", "diff", [], "", /\A\z/, /\A\z/, [])
180
+ invalidate_hidden_selection_ranges
181
+ document
182
+ end
183
+
184
+ def scm_diff_decorations(buffer, rows)
185
+ snapshot = buffer.instance_variable_get(:@scm_diff_snapshot)
186
+ return [] unless snapshot&.diff && !snapshot.conflicted
187
+
188
+ action = snapshot.kind == :staged ? :unstage : :stage
189
+ verb = action == :stage ? "Stage" : "Unstage"
190
+ line_items = snapshot.line_rows.filter_map do |display_row, index|
191
+ next unless rows.cover?(display_row)
192
+
193
+ click = ->(current, row) { change_scm_line(action, index, current.buffer, row) }
194
+ Decoration::Item.new(:gutter, nil, display_row, "#{verb} line",
195
+ {color: :accent, gutter_offset: 2, gutter_width: 3, hit_width: 10}.freeze,
196
+ 1, :scm_diff, click)
197
+ end
198
+ hunk_items = snapshot.hunk_rows.filter_map do |display_row, index|
199
+ next unless rows.cover?(display_row) && !snapshot.line_rows.key?(display_row)
200
+
201
+ click = ->(current, row) { change_scm_hunk(action, index, current.buffer, row) }
202
+ Decoration::Item.new(:gutter, nil, display_row, "#{verb} hunk",
203
+ {color: :muted, gutter_offset: 2, gutter_width: 3, hit_width: 10}.freeze,
204
+ 2, :scm_diff, click)
205
+ end
206
+ line_items + hunk_items
207
+ end
208
+
209
+ private
210
+
211
+ def submit_scm_commit
212
+ commit_git
213
+ rescue StandardError => error
214
+ self.message = error.message
215
+ end
216
+
217
+ def set_scm_commit_busy(value)
218
+ @scm_commit_input&.disabled(value)
219
+ @scm_commit_amend_control&.disabled(value)
220
+ @scm_commit_button&.loading(value)
221
+ @window&.request_frame
222
+ end
223
+
224
+ def finish_git_commit(worker, state, snapshot, submitted, oid, error)
225
+ @git_commit_job = nil if @git_commit_job.equal?(worker)
226
+ set_scm_commit_busy(false)
227
+ invalidate_git
228
+ install_git_snapshot(state, snapshot) if snapshot
229
+ if error
230
+ self.message = "Git commit: #{error.message}"
231
+ return
232
+ end
233
+
234
+ if @scm_commit_message == submitted
235
+ @scm_commit_input ? @scm_commit_input.clear : @scm_commit_message = +""
236
+ end
237
+ self.message = "Committed #{oid[0, 8]}"
238
+ end
239
+
240
+ def capture_scm_diff(repository, path, kind)
241
+ head_oid = repository.head
242
+ index = repository.index
243
+ head_entry = repository.tree(head_oid || "HEAD")[path]
244
+ index_entry = index[path]
245
+ worktree = scm_worktree_content(repository, path, index_entry || head_entry)
246
+ head = scm_entry_content(repository, head_entry)
247
+ staged = scm_entry_content(repository, index_entry)
248
+ before, after = case kind
249
+ when :staged then [head, staged]
250
+ when :unstaged then [staged, worktree]
251
+ else [scm_content(false, nil, nil), worktree]
252
+ end
253
+ entries = scm_index_entries(index, path)
254
+ conflicted = index.entries.any? { |entry| entry.path == path && !entry.stage.zero? }
255
+ metadata = [path.dup.freeze, kind, head_oid&.dup&.freeze, entries, worktree, conflicted]
256
+ display_path = scm_display_path(path)
257
+ if [head_entry, index_entry].compact.any? { |entry| entry.mode == 0o160000 }
258
+ raw_before, raw_after = gitlink_diff(repository, path, kind, head_entry, index_entry)
259
+ return [metadata.freeze, before, after, display_path.freeze,
260
+ [raw_before.freeze, raw_after.freeze].freeze].freeze
261
+ end
262
+ [metadata.freeze, before, after, display_path.freeze, nil].freeze
263
+ end
264
+
265
+ def materialize_scm_diff(capture)
266
+ metadata, before, after, display_path, gitlink_pair = capture
267
+ if gitlink_pair
268
+ snapshot = SCMDiffSnapshot.new(*metadata, before, after, nil, [].freeze, [].freeze, {}.freeze, {}.freeze)
269
+ text = Porrima.unified(*gitlink_pair, old_name: "a/#{display_path}", new_name: "b/#{display_path}")
270
+ return [snapshot, text, gitlink_pair]
271
+ end
272
+ unless before.text && after.text
273
+ snapshot = SCMDiffSnapshot.new(*metadata, before, after, nil, [].freeze, [].freeze, {}.freeze, {}.freeze)
274
+ return [snapshot, "Binary file changed: #{display_path}\n", [nil, nil].freeze]
275
+ end
276
+ if (reason = scm_diff_budget_reason(before.text, after.text))
277
+ snapshot = SCMDiffSnapshot.new(*metadata, before, after, nil, [].freeze, [].freeze, {}.freeze, {}.freeze)
278
+ return [snapshot, "Diff unavailable: #{display_path} #{scm_diff_budget_description(reason)}\n",
279
+ [before.text, after.text].freeze]
280
+ end
281
+
282
+ diff = Porrima.diff(before.text, after.text, budget: before.text == after.text ? nil : SCM_DIFF_BUDGET)
283
+ diff.edits
284
+ diff.marks
285
+ diff.rows
286
+ diff.stat
287
+ hunks, lines, hunk_rows, line_rows = scm_diff_targets(diff, before, after)
288
+ snapshot = SCMDiffSnapshot.new(*metadata, before, after, diff, hunks, lines, hunk_rows, line_rows)
289
+ text = diff.to_unified(old_name: "a/#{display_path}", new_name: "b/#{display_path}")
290
+ text << "@@ metadata @@\n #{scm_metadata_change(before, after)}\n" if diff.empty? && !hunks.empty?
291
+ [snapshot, text, [before.text, after.text].freeze]
292
+ end
293
+
294
+ def scm_diff_budget_reason(before, after)
295
+ return :bytes if before.bytesize + after.bytesize > SCM_DIFF_MAX_BYTES
296
+ lines = ->(text) { text.count("\n") + (!text.empty? && !text.end_with?("\n") ? 1 : 0) }
297
+ return :lines if lines.call(before) + lines.call(after) > SCM_DIFF_MAX_LINES
298
+ :line_endings if before != after && [before, after].any? { |text| text.match?(/\r(?!\n)|[\u2028\u2029]/) }
299
+ end
300
+
301
+ def scm_diff_budget_description(reason)
302
+ return "exceeds 10 MiB" if reason == :bytes
303
+ return "exceeds 200,000 lines" if reason == :lines
304
+ "uses unsupported line separators"
305
+ end
306
+
307
+ def scm_display_path(path)
308
+ !path.valid_encoding? || path.match?(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/) ? path.dump[1...-1] : path
309
+ end
310
+
311
+ def scm_metadata_change(before, after)
312
+ return "new empty file (mode #{after.mode.to_s(8)})" unless before.exists
313
+ return "deleted empty file (mode #{before.mode.to_s(8)})" unless after.exists
314
+
315
+ "mode #{before.mode.to_s(8)} -> #{after.mode.to_s(8)}"
316
+ end
317
+
318
+ def scm_entry_content(repository, entry)
319
+ raw = entry && entry.mode != 0o160000 ? repository.object(entry.oid).last : nil
320
+ scm_content(!entry.nil?, raw, entry&.mode)
321
+ end
322
+
323
+ def scm_worktree_content(repository, path, baseline = nil)
324
+ absolute = repository.worktree_path(path)
325
+ exists = File.exist?(absolute) || File.symlink?(absolute)
326
+ return scm_content(false, nil, nil) unless exists
327
+
328
+ stat = File.lstat(absolute)
329
+ mode = scm_file_mode(repository, stat, baseline)
330
+ raw = stat.file? || stat.symlink? ? repository.worktree_content(path) : nil
331
+ scm_content(true, raw, mode)
332
+ rescue Errno::ENOENT, Errno::ENOTDIR
333
+ scm_content(false, nil, nil)
334
+ end
335
+
336
+ def scm_content(exists, raw, mode)
337
+ raw = raw&.dup&.freeze
338
+ raise EncodingError if raw && raw.bytesize > SCM_DIFF_MAX_BYTES
339
+ text, encoding, bom = Buffer.decode_bytes(raw.to_s)
340
+ detection = Buffer.send(:detect_bytes, raw.to_s)
341
+ raise EncodingError if raw && bom.empty? && encoding != Encoding::UTF_8 && detection.confidence < 0.9
342
+ raise EncodingError if raw && raw != bom + text.encode(encoding).b
343
+
344
+ SCMContent.new(exists, raw, text.freeze, encoding, bom.dup.freeze, mode)
345
+ rescue EncodingError, Error
346
+ SCMContent.new(exists, raw, nil, nil, nil, mode)
347
+ end
348
+
349
+ def scm_file_mode(repository, stat, baseline)
350
+ return 0o120000 if stat.symlink?
351
+ return 0o040000 unless stat.file?
352
+ unless repository.filemode?
353
+ return baseline.mode if [0o100644, 0o100755].include?(baseline&.mode)
354
+ return 0o100644
355
+ end
356
+
357
+ (stat.mode & 0o100).positive? ? 0o100755 : 0o100644
358
+ end
359
+
360
+ def scm_index_entries(index, path)
361
+ index.entries.select { |entry| entry.path == path }.map do |entry|
362
+ entry.members.map { |member| value = entry.public_send(member); value.is_a?(String) ? value.dup.freeze : value }.freeze
363
+ end.freeze
364
+ end
365
+
366
+ def scm_diff_targets(diff, before, after)
367
+ hunks = diff.hunks.dup
368
+ synthetic = diff.empty? && (before.exists != after.exists || before.mode != after.mode)
369
+ if synthetic
370
+ edit = Porrima::Hunk.new(old_start: 0, old_count: 0, new_start: 0, new_count: 0, edits: [].freeze).freeze
371
+ return [[edit].freeze, [edit].freeze, {2 => 0, 3 => 0}.freeze, {}.freeze]
372
+ end
373
+
374
+ lines = []
375
+ hunk_rows = {}
376
+ line_rows = {}
377
+ display_row = 2
378
+ hunks.each_with_index do |hunk, hunk_index|
379
+ header_row = display_row
380
+ hunk_rows[header_row] = hunk_index
381
+ display_row += 1
382
+ unit_by_edit = {}
383
+ index = 0
384
+ while index < hunk.edits.length
385
+ if hunk.edits[index].kind == :equal
386
+ index += 1
387
+ next
388
+ end
389
+ changed = []
390
+ while index < hunk.edits.length && hunk.edits[index].kind != :equal
391
+ changed << hunk.edits[index]
392
+ index += 1
393
+ end
394
+ deleted = changed.select { |edit| edit.kind == :delete }
395
+ inserted = changed.select { |edit| edit.kind == :insert }
396
+ [deleted.length, inserted.length].max.times do |offset|
397
+ old, new = deleted[offset], inserted[offset]
398
+ old_start = old ? old.old_line : new.old_line - 1
399
+ new_start = new ? new.new_line : old.new_line - 1 + inserted.length
400
+ edits = [old, new].compact.freeze
401
+ unit = Porrima::Hunk.new(old_start: old_start, old_count: old ? 1 : 0,
402
+ new_start: new_start, new_count: new ? 1 : 0, edits: edits).freeze
403
+ unit_index = lines.length
404
+ lines << unit
405
+ edits.each { |edit| unit_by_edit[edit.object_id] = unit_index }
406
+ end
407
+ end
408
+ hunk.edits.each do |edit|
409
+ hunk_rows[display_row] = hunk_index
410
+ line_rows[display_row] = unit_by_edit.fetch(edit.object_id) unless edit.kind == :equal
411
+ display_row += 1
412
+ unless edit.text.end_with?("\n")
413
+ hunk_rows[display_row] = hunk_index
414
+ line_rows[display_row] = unit_by_edit.fetch(edit.object_id) unless edit.kind == :equal
415
+ display_row += 1
416
+ end
417
+ end
418
+ end
419
+ [hunks.freeze, lines.freeze, hunk_rows.freeze, line_rows.freeze]
420
+ end
421
+
422
+ def change_scm_hunk(action, index, buffer, row)
423
+ snapshot = scm_snapshot_for(buffer, action)
424
+ row ||= buffer.rope.point_at(editor.primary.head).row
425
+ index ||= snapshot.hunk_rows[row]
426
+ raise Error, "Place the cursor in a Git hunk" unless index
427
+
428
+ apply_scm_change(snapshot, action, snapshot.hunks.fetch(index))
429
+ end
430
+
431
+ def change_scm_line(action, index, buffer, row)
432
+ snapshot = scm_snapshot_for(buffer, action)
433
+ row ||= buffer.rope.point_at(editor.primary.head).row
434
+ index ||= snapshot.line_rows[row]
435
+ raise Error, "Place the cursor on a changed line" unless index
436
+
437
+ apply_scm_change(snapshot, action, snapshot.lines.fetch(index))
438
+ end
439
+
440
+ def scm_snapshot_for(buffer, action)
441
+ snapshot = buffer.instance_variable_get(:@scm_diff_snapshot)
442
+ raise Error, "Open an SCM diff first" unless snapshot
443
+ allowed = action == :stage ? %i[unstaged untracked] : %i[staged]
444
+ raise Error, "This diff cannot be #{action}d" unless allowed.include?(snapshot.kind)
445
+ raise Error, "Merge conflicts cannot be partially staged" if snapshot.conflicted
446
+ raise Error, "Binary files and submodules cannot be partially staged" unless snapshot.diff
447
+ snapshot
448
+ end
449
+
450
+ def apply_scm_change(snapshot, action, hunk)
451
+ state = git_state
452
+ raise Error, "Not a Git repository" unless state
453
+
454
+ state.synchronize do |repository|
455
+ index = repository.index
456
+ validate_scm_snapshot!(repository, index, snapshot)
457
+ source, target = action == :stage ? [snapshot.before, snapshot.after] : [snapshot.after, snapshot.before]
458
+ text = action == :stage ? Porrima.apply(source.text, hunk) : Porrima.revert(source.text, hunk)
459
+ exists = text == target.text ? target.exists : text == source.text ? source.exists : target.exists || !text.empty?
460
+ if exists
461
+ raw = if text == target.text && target.exists
462
+ target.raw
463
+ elsif text == source.text && source.exists
464
+ source.raw
465
+ else
466
+ codec = source.exists ? source : target
467
+ codec.bom + text.encode(codec.encoding).b
468
+ end
469
+ mode = text == target.text && target.exists ? target.mode : source.mode || target.mode
470
+ raise Error, "Cannot partially stage this file type" unless [0o100644, 0o100755, 0o120000].include?(mode)
471
+ index.stage(snapshot.path, repository.write_blob(raw), mode)
472
+ else
473
+ index.remove(snapshot.path)
474
+ end
475
+ validate_scm_worktree_and_head!(repository, snapshot)
476
+ index.write
477
+ end
478
+ refresh_after_index_write
479
+ snapshot.path
480
+ rescue Thuban::RefLockError
481
+ refresh_after_index_write
482
+ raise
483
+ end
484
+
485
+ def validate_scm_snapshot!(repository, index, snapshot)
486
+ stale = repository.head != snapshot.head || scm_index_entries(index, snapshot.path) != snapshot.index_entries ||
487
+ scm_worktree_signature(scm_worktree_content(repository, snapshot.path, snapshot.worktree)) != scm_worktree_signature(snapshot.worktree)
488
+ raise Error, "Git diff is stale; reopen it before staging" if stale
489
+ end
490
+
491
+ def validate_scm_worktree_and_head!(repository, snapshot)
492
+ stale = repository.head != snapshot.head ||
493
+ scm_worktree_signature(scm_worktree_content(repository, snapshot.path, snapshot.worktree)) != scm_worktree_signature(snapshot.worktree)
494
+ raise Error, "Git diff is stale; reopen it before staging" if stale
495
+ end
496
+
497
+ def scm_worktree_signature(content) = [content.exists, content.raw, content.mode]
498
+
499
+ def gitlink_diff(repository, path, kind, head, staged)
500
+ case kind
501
+ when :staged
502
+ [gitlink_entry_text(head), gitlink_entry_text(staged)]
503
+ when :unstaged
504
+ [gitlink_entry_text(staged), gitlink_worktree_text(repository, path)]
505
+ else
506
+ ["", gitlink_worktree_text(repository, path)]
507
+ end
508
+ end
509
+
510
+ def gitlink_entry_text(entry)
511
+ return "" unless entry
512
+ return "Subproject commit #{entry.oid}\n" if entry.mode == 0o160000
513
+
514
+ "Git object #{entry.oid} (mode #{entry.mode.to_s(8)})\n"
515
+ end
516
+
517
+ def gitlink_worktree_text(repository, path)
518
+ absolute = repository.worktree_path(path)
519
+ return "" unless File.exist?(absolute) || File.symlink?(absolute)
520
+ return "Submodule path is not a directory\n" unless File.directory?(absolute) && !File.symlink?(absolute)
521
+
522
+ nested = submodule_repository(absolute)
523
+ return "Submodule is not initialized\n" unless nested
524
+ oid = nested.head
525
+ oid ? "Subproject commit #{oid}\n" : "Submodule has no HEAD\n"
526
+ rescue ArgumentError, Thuban::CorruptObject, SystemCallError
527
+ "Submodule is unavailable\n"
528
+ end
529
+
530
+ def submodule_repository(absolute)
531
+ nested = Thuban::Repository.new(absolute)
532
+ return unless nested.root && File.realpath(nested.root) == File.realpath(absolute)
533
+
534
+ nested
535
+ rescue ArgumentError, SystemCallError
536
+ nil
537
+ end
538
+
539
+ def scm_nodes(entries = git_status)
540
+ grouped = SCM_SECTIONS.to_h { |kind, _| [kind, []] }
541
+ entries.sort.each do |path, code|
542
+ change = ->(kind) { {path: path, kind: kind, code: code}.freeze }
543
+ grouped[:untracked] << scm_file_node(change.call(:untracked)) if code == "??"
544
+ grouped[:staged] << scm_file_node(change.call(:staged)) if code.getbyte(0) != 32 && code.getbyte(0) != 63
545
+ grouped[:unstaged] << scm_file_node(change.call(:unstaged)) if code.getbyte(1) != 32 && code.getbyte(1) != 63
546
+ end
547
+ SCM_SECTIONS.map do |kind, label|
548
+ children = grouped.fetch(kind).freeze
549
+ {id: [:scm_section, kind].freeze, label: "#{label} (#{children.length})".freeze,
550
+ value: nil, children: children}.freeze
551
+ end.freeze
552
+ end
553
+
554
+ def scm_file_node(change)
555
+ path, kind, code = change.values_at(:path, :kind, :code)
556
+ mark = kind == :untracked ? "?" : kind == :staged ? code[0] : code[1]
557
+ {id: [:scm_file, kind, path].freeze, label: "#{mark} #{scm_display_path(path)}".freeze, value: change}.freeze
558
+ end
559
+
560
+ def select_scm_change(change, event = nil)
561
+ return false unless change.is_a?(Hash) && SCM_SECTIONS.key?(change[:kind])
562
+
563
+ @scm_selection = change
564
+ if event&.click_count.to_i >= 2
565
+ change[:kind] == :staged ? unstage_git_file(change[:path]) : stage_git_file(change[:path])
566
+ else
567
+ show_scm_diff(change)
568
+ end
569
+ true
570
+ end
571
+
572
+ def selected_scm_path(*kinds)
573
+ change = @scm_selection
574
+ raise Error, "Select a #{kinds == [:staged] ? 'staged' : 'changed'} file" unless change.is_a?(Hash) && kinds.include?(change[:kind])
575
+ change.fetch(:path)
576
+ end
577
+
578
+ def refresh_after_index_write
579
+ invalidate_git
580
+ refresh_scm
581
+ end
582
+ end
583
+ end