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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +55 -0
- data/README.md +191 -19
- data/assets/keymaps/emacs.jsonc +5 -0
- data/assets/keymaps/jetbrains.jsonc +5 -0
- data/assets/keymaps/sublime.jsonc +5 -0
- data/assets/keymaps/vscode.jsonc +5 -0
- data/docs/debugging.md +143 -0
- data/docs/distribution.md +40 -9
- data/docs/lsp.md +131 -0
- data/docs/performance.md +18 -0
- data/docs/providers.md +36 -0
- data/docs/release-log.md +12 -0
- data/docs/tasks.md +64 -0
- data/docs/testing.md +49 -0
- data/docs/vim.md +3 -0
- data/exe/canopus +4 -1
- data/lib/canopus/buffer.rb +291 -50
- data/lib/canopus/cli.rb +5 -2
- data/lib/canopus/command.rb +13 -4
- data/lib/canopus/controller.rb +183 -40
- data/lib/canopus/debug/breakpoints.rb +550 -0
- data/lib/canopus/debug/configuration.rb +291 -0
- data/lib/canopus/debug/console.rb +243 -0
- data/lib/canopus/debug/panel.rb +487 -0
- data/lib/canopus/debug/session.rb +354 -0
- data/lib/canopus/decoration.rb +4 -2
- data/lib/canopus/denebola_compat.rb +27 -0
- data/lib/canopus/diagnostics.rb +173 -0
- data/lib/canopus/display_map/overlay_map.rb +6 -2
- data/lib/canopus/editor/snippet_expandable.rb +3 -2
- data/lib/canopus/editor.rb +122 -31
- data/lib/canopus/git/state.rb +100 -0
- data/lib/canopus/language/background_analysis.rb +93 -14
- data/lib/canopus/language/document.rb +70 -34
- data/lib/canopus/language/symbol.rb +1 -0
- data/lib/canopus/language/syntax_worker.rb +61 -9
- data/lib/canopus/language.rb +3 -0
- data/lib/canopus/minimap.rb +201 -0
- data/lib/canopus/patch.rb +3 -1
- data/lib/canopus/plugins/api.rb +4 -2
- data/lib/canopus/plugins/isolated_runtime.rb +64 -7
- data/lib/canopus/plugins/registry.rb +114 -3
- data/lib/canopus/plugins.rb +8 -7
- data/lib/canopus/project.rb +15 -6
- data/lib/canopus/provider.rb +192 -0
- data/lib/canopus/regexp_compat.rb +36 -4
- data/lib/canopus/selection.rb +12 -0
- data/lib/canopus/settings/schema.rb +201 -0
- data/lib/canopus/settings.rb +328 -49
- data/lib/canopus/task/configuration.rb +356 -0
- data/lib/canopus/task/problem_matcher.rb +264 -0
- data/lib/canopus/task/runner.rb +314 -0
- data/lib/canopus/test_runner/bounded_ignore.rb +144 -0
- data/lib/canopus/test_runner/discovery.rb +97 -0
- data/lib/canopus/test_runner/execution.rb +194 -0
- data/lib/canopus/test_runner/minitest.rb +116 -0
- data/lib/canopus/test_runner/rspec.rb +103 -0
- data/lib/canopus/test_runner/safe_walker.rb +37 -0
- data/lib/canopus/test_runner.rb +54 -0
- data/lib/canopus/theme/import.rb +131 -0
- data/lib/canopus/theme.rb +28 -4
- data/lib/canopus/version.rb +1 -1
- data/lib/canopus/vim/motionable.rb +1 -1
- data/lib/canopus/vim.rb +10 -16
- data/lib/canopus/workspace/auto_savable.rb +83 -0
- data/lib/canopus/workspace/debug_aware.rb +600 -0
- data/lib/canopus/workspace/edit/plan.rb +2 -2
- data/lib/canopus/workspace/editor_configurable.rb +113 -0
- data/lib/canopus/workspace/file_change_aware.rb +11 -3
- data/lib/canopus/workspace/git_aware.rb +80 -34
- data/lib/canopus/workspace/git_blame.rb +175 -0
- data/lib/canopus/workspace/git_conflict_resolution.rb +410 -0
- data/lib/canopus/workspace/git_diff_view.rb +641 -0
- data/lib/canopus/workspace/git_history.rb +171 -0
- data/lib/canopus/workspace/git_remote.rb +267 -0
- data/lib/canopus/workspace/git_staging.rb +583 -0
- data/lib/canopus/workspace/hierarchy_aware.rb +477 -0
- data/lib/canopus/workspace/keymap_aware.rb +66 -0
- data/lib/canopus/workspace/language_aware.rb +3721 -135
- data/lib/canopus/workspace/language_server_configurable.rb +287 -71
- data/lib/canopus/workspace/persistent_undo.rb +169 -0
- data/lib/canopus/workspace/problems_aware.rb +131 -0
- data/lib/canopus/workspace/project_searchable.rb +88 -11
- data/lib/canopus/workspace/project_tree_editable.rb +3 -1
- data/lib/canopus/workspace/recoverable.rb +204 -0
- data/lib/canopus/workspace/session_persistable.rb +85 -13
- data/lib/canopus/workspace/settings_aware.rb +76 -2
- data/lib/canopus/workspace/task_aware.rb +194 -0
- data/lib/canopus/workspace/test_aware.rb +394 -0
- data/lib/canopus/workspace/trust.rb +73 -0
- data/lib/canopus/workspace/view/terminal_presentable.rb +298 -47
- data/lib/canopus/workspace/view.rb +399 -74
- data/lib/canopus/workspace.rb +688 -44
- data/lib/canopus.rb +20 -2
- data/sig/canopus.rbs +138 -31
- data/sig/controller.rbs +10 -3
- data/sig/debug.rbs +121 -0
- data/sig/decoration.rbs +12 -2
- data/sig/diagnostics.rbs +26 -0
- data/sig/minimap.rbs +20 -0
- data/sig/provider.rbs +29 -0
- data/sig/services.rbs +1 -16
- data/sig/task.rbs +92 -0
- data/sig/test_runner.rbs +109 -0
- data/sig/trust.rbs +17 -0
- data/sig/workspace_edits.rbs +2 -2
- data/sig/workspace_services.rbs +234 -2
- data/tools/check_dependencies.rb +21 -2
- data/tools/package.rb +172 -14
- data/tools/package_signature_test.rb +33 -0
- data/tools/package_test.rb +29 -0
- data/tools/sign_package.rb +123 -0
- data/tools/update.rb +189 -0
- data/tools/update_test.rb +71 -0
- metadata +206 -31
- data/lib/canopus/lsp/client.rb +0 -307
- data/lib/canopus/lsp/error.rb +0 -7
- data/lib/canopus/lsp/future/subscription.rb +0 -9
- data/lib/canopus/lsp/future.rb +0 -87
- data/lib/canopus/lsp/protocol.rb +0 -83
- data/lib/canopus/lsp/server_error.rb +0 -13
- data/lib/canopus/lsp/timeout.rb +0 -7
- data/lib/canopus/lsp/transport.rb +0 -123
- data/lib/canopus/lsp.rb +0 -19
- data/lib/canopus/project/search.rb +0 -164
- data/lib/canopus/project/search_worker/cancelled.rb +0 -3
- data/lib/canopus/project/search_worker/runner.rb +0 -9
- data/lib/canopus/project/search_worker.rb +0 -108
- data/lib/canopus/terminal/cell.rb +0 -7
- data/lib/canopus/terminal/grid.rb +0 -321
- data/lib/canopus/terminal/pty.rb +0 -178
- data/lib/canopus/terminal/scrollback.rb +0 -38
- data/lib/canopus/terminal/vt.rb +0 -398
- data/lib/canopus/terminal.rb +0 -13
- data/sig/lsp.rbs +0 -99
- 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
|