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,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canopus
4
+ module Workspace::GitHistory
5
+ MAX_COMMITS = 200
6
+ MAX_HISTORY_TREE_ENTRIES = 200_000
7
+ MAX_COMMIT_PATHS = 2_000
8
+
9
+ GitCommitEntry = Data.define(:oid, :parents, :subject, :author, :time, :paths, :graph, :paths_truncated)
10
+
11
+ def git_commit_history(revision: "HEAD", limit: 100)
12
+ state = git_state
13
+ raise Error, "Not a Git repository" unless state
14
+ revision = validate_git_revision(revision)
15
+ unless limit.is_a?(Integer) && limit.between?(1, MAX_COMMITS)
16
+ raise ArgumentError, "history limit must be between 1 and #{MAX_COMMITS}"
17
+ end
18
+
19
+ state.synchronize { |repository| collect_git_commit_history(repository, revision, limit) }
20
+ end
21
+
22
+ def show_git_commit_history(revision: "HEAD", limit: 100, async: !!@window)
23
+ revision = validate_git_revision(revision)
24
+ if @window && async
25
+ generation = @git_commit_history_generation = (@git_commit_history_generation || 0) + 1
26
+ palette_generation = @palette_generation.to_i
27
+ self.message = "Loading Git history…"
28
+ worker = Thread.new do
29
+ current = Thread.current
30
+ entries = git_commit_history(revision: revision, limit: limit)
31
+ post { finish_git_commit_history(current, generation, palette_generation, entries, nil) } unless @closed
32
+ rescue StandardError => error
33
+ post { finish_git_commit_history(current, generation, palette_generation, nil, error) } unless @closed
34
+ end
35
+ (@git_commit_history_jobs ||= []) << worker
36
+ return worker
37
+ end
38
+
39
+ install_git_commit_history(git_commit_history(revision: revision, limit: limit))
40
+ end
41
+
42
+ def open_git_commit(entry)
43
+ raise ArgumentError, "expected a Git commit entry" unless entry.is_a?(GitCommitEntry)
44
+ if entry.paths.empty?
45
+ self.message = entry.paths_truncated ? "Changed paths omitted by the history budget" : "Commit has no file changes"
46
+ return
47
+ end
48
+ return compare_git_revisions(entry.parents.first, entry.oid, path: entry.paths.first) if entry.paths.one?
49
+
50
+ labels = entry.paths.map { |path| scm_display_path(path) }
51
+ self.palette = {kind: :git_commit_paths, query: +"", index: 0, matches: labels,
52
+ all_matches: labels, items: entry.paths, commit: entry}
53
+ update_palette
54
+ @palette
55
+ end
56
+
57
+ def close_git
58
+ @git_commit_history_generation = (@git_commit_history_generation || 0) + 1
59
+ jobs = [*@git_commit_history_jobs].compact.uniq.reject { |thread| thread.equal?(Thread.current) }
60
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 0.1
61
+ jobs.each do |thread|
62
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
63
+ break unless remaining.positive?
64
+ thread.join(remaining)
65
+ end
66
+ jobs.each { |thread| thread.kill if thread.alive? }
67
+ jobs.each(&:join)
68
+ @git_commit_history_jobs&.clear
69
+ super
70
+ end
71
+
72
+ private
73
+
74
+ def collect_git_commit_history(repository, revision, limit)
75
+ resolve_git_revision(repository, revision)
76
+ lanes = []
77
+ tree_entries = 0
78
+ commits = topological_git_commits(repository.each_commit(revision, limit: limit).to_a)
79
+ commits.map do |commit|
80
+ parents = commit.parents.map { |oid| oid.dup.freeze }.freeze
81
+ lane = lanes.index(commit.oid) || lanes.length
82
+ lanes << commit.oid unless lanes.include?(commit.oid)
83
+ graph = lanes.each_index.map { |index| index == lane ? "●" : "│" }.join(" ").freeze
84
+ lanes.delete_at(lane)
85
+ parents.reverse_each { |parent| lanes.insert(lane, parent) unless lanes.include?(parent) }
86
+
87
+ paths, consumed, truncated = git_commit_paths(repository, commit, MAX_HISTORY_TREE_ENTRIES - tree_entries)
88
+ tree_entries += consumed
89
+ signature = commit.signature(role: :author)
90
+ GitCommitEntry.new(commit.oid.dup.freeze, parents,
91
+ safe_git_history_text(commit.message.to_s.lines.first, 200).freeze,
92
+ safe_git_history_text(signature.name, 100).freeze, signature.time,
93
+ paths.freeze, graph, truncated).freeze
94
+ end.freeze
95
+ end
96
+
97
+ def topological_git_commits(commits)
98
+ remaining = commits.dup
99
+ ordered = []
100
+ until remaining.empty?
101
+ index = remaining.index do |candidate|
102
+ remaining.none? { |child| child.parents.include?(candidate.oid) }
103
+ end
104
+ raise Error, "Invalid Git commit graph" unless index
105
+
106
+ ordered << remaining.delete_at(index)
107
+ end
108
+ ordered
109
+ end
110
+
111
+ def git_commit_paths(repository, commit, remaining)
112
+ return [[], 0, true] unless remaining.positive?
113
+
114
+ current = repository.tree(commit.oid)
115
+ previous = commit.parents.first ? repository.tree(commit.parents.first) : {}
116
+ paths, consumed, truncated = [], 0, false
117
+ current.each do |path, entry|
118
+ consumed += 1
119
+ if consumed > remaining
120
+ truncated = true
121
+ break
122
+ end
123
+ paths << path.dup.freeze unless same_git_tree_entry?(entry, previous[path])
124
+ if paths.length >= MAX_COMMIT_PATHS
125
+ truncated = true
126
+ break
127
+ end
128
+ end
129
+ unless truncated
130
+ previous.each do |path, entry|
131
+ consumed += 1
132
+ if consumed > remaining
133
+ truncated = true
134
+ break
135
+ end
136
+ paths << path.dup.freeze unless current.key?(path) || same_git_tree_entry?(current[path], entry)
137
+ if paths.length >= MAX_COMMIT_PATHS
138
+ truncated = true
139
+ break
140
+ end
141
+ end
142
+ end
143
+ [paths.sort_by(&:b), [consumed, remaining].min, truncated]
144
+ end
145
+
146
+ def git_commit_history_label(entry)
147
+ paths = entry.paths.first(4).map { |path| scm_display_path(path) }.join(", ")
148
+ paths << ", …" if entry.paths.length > 4 || entry.paths_truncated
149
+ suffix = paths.empty? ? "" : " · #{paths}"
150
+ "#{entry.graph} #{entry.oid[0, 8]} #{entry.subject} · #{entry.author}#{suffix}"
151
+ end
152
+
153
+ def git_commit_history_search(entry)
154
+ ([entry.oid, entry.subject, entry.author] + entry.paths.map { |path| scm_display_path(path) }).join(" ")
155
+ end
156
+
157
+ def install_git_commit_history(entries)
158
+ self.palette = {kind: :git_commit_history, query: +"", index: 0,
159
+ matches: entries.map { |entry| git_commit_history_label(entry) }, items: entries,
160
+ search_values: entries.map { |entry| git_commit_history_search(entry) }}
161
+ update_palette
162
+ @palette
163
+ end
164
+
165
+ def finish_git_commit_history(worker, generation, palette_generation, entries, error)
166
+ @git_commit_history_jobs&.delete(worker)
167
+ return unless generation == @git_commit_history_generation && palette_generation == @palette_generation.to_i
168
+ error ? self.message = "Git history: #{safe_git_history_text(error.message, 500)}" : install_git_commit_history(entries)
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canopus
4
+ module Workspace::GitRemote
5
+ TRANSFER_PHASES = %i[pack remote push].freeze
6
+ GitTransferState = Struct.new(:operation, :remote, :lock, :cancelled, :job, :progress,
7
+ :progress_posted, :prompt_auth, :redactions, :locked_buffers, keyword_init: true) do
8
+ def cancel!
9
+ lock.synchronize { self.cancelled = true }
10
+ end
11
+
12
+ def cancelled? = lock.synchronize { cancelled }
13
+ end
14
+ private_constant :GitTransferState
15
+
16
+ def git_remotes
17
+ state = git_state
18
+ raise Error, "Not a Git repository" unless state
19
+ state.synchronize { |repository| repository.remotes.transform_keys { |name| name.dup.freeze }.freeze }
20
+ end
21
+
22
+ def fetch_git(remote = nil, credentials: nil)
23
+ start_git_transfer(:fetch, remote, credentials: credentials)
24
+ end
25
+
26
+ def pull_git(remote = nil, credentials: nil)
27
+ raise Error, "Save or discard all buffer changes before pulling" if @buffers.values.any?(&:dirty?)
28
+ start_git_transfer(:pull, remote, credentials: credentials)
29
+ end
30
+
31
+ def push_git(remote = nil, credentials: nil)
32
+ start_git_transfer(:push, remote, credentials: credentials)
33
+ end
34
+
35
+ def cancel_git_transfer
36
+ state = (@git_transfer_guard ||= Mutex.new).synchronize { @git_transfer_state }
37
+ return false unless state
38
+
39
+ state.cancel!
40
+ self.message = "Cancelling Git #{state.operation}…"
41
+ true
42
+ end
43
+
44
+ def git_transfer_progress
45
+ state = (@git_transfer_guard ||= Mutex.new).synchronize { @git_transfer_state }
46
+ state&.lock&.synchronize { state.progress }
47
+ end
48
+
49
+ def poll_git_changes(now: Process.clock_gettime(Process::CLOCK_MONOTONIC))
50
+ return if @closed
51
+
52
+ super
53
+ poll_git_autofetch(now)
54
+ nil
55
+ end
56
+
57
+ def accept_git_credentials
58
+ prompt = @palette
59
+ raise Error, "No Git credential prompt is open" unless prompt&.dig(:kind) == :git_credentials
60
+
61
+ value = prompt.fetch(:query)
62
+ if prompt[:stage] == :username
63
+ raise Error, "Git username is too long" if value.bytesize > 1_024
64
+ username = value.dup
65
+ value.clear
66
+ self.palette = prompt.merge(stage: username.empty? ? :token : :password, username: username,
67
+ query: +"", secret: true, matches: [])
68
+ return @palette
69
+ end
70
+
71
+ raise Error, "Git credential is too long" if value.bytesize > 4_096
72
+ secret = value.dup
73
+ value.clear
74
+ operation, remote, username = prompt.values_at(:operation, :remote, :username)
75
+ self.palette = nil
76
+ credentials = username.to_s.empty? ? Thuban::Remote::Credentials.bearer(token: secret) :
77
+ Thuban::Remote::Credentials.static(username: username, password: secret)
78
+ start_git_transfer(operation, remote, credentials: credentials, prompt_auth: false,
79
+ redactions: [username, secret].compact)
80
+ ensure
81
+ secret&.clear
82
+ end
83
+
84
+ def close_git
85
+ state = (@git_transfer_guard ||= Mutex.new).synchronize { @git_transfer_state }
86
+ if state
87
+ state.cancel!
88
+ state.job&.join unless state.job&.equal?(Thread.current)
89
+ end
90
+ super
91
+ end
92
+
93
+ private
94
+
95
+ def poll_git_autofetch(now)
96
+ options = @settings["git"]
97
+ return unless options["autofetch"]
98
+ unless @last_git_autofetch
99
+ @last_git_autofetch = now
100
+ return
101
+ end
102
+ return if now - @last_git_autofetch < options["autofetch_interval"]
103
+ return if (@git_transfer_guard ||= Mutex.new).synchronize { @git_transfer_state }
104
+
105
+ @last_git_autofetch = now
106
+ fetch_git
107
+ rescue Error => error
108
+ self.message = "Git autofetch: #{safe_git_history_text(error.message, 200)}"
109
+ end
110
+
111
+ def start_git_transfer(operation, remote, credentials:, prompt_auth: true, redactions: [])
112
+ if operation == :pull && @buffers.values.any?(&:dirty?)
113
+ raise Error, "Save or discard all buffer changes before pulling"
114
+ end
115
+ state = nil
116
+ guard = @git_transfer_guard ||= Mutex.new
117
+ guard.synchronize do
118
+ raise Error, "A Git transfer is already in progress" if @git_transfer_state
119
+ remote = select_git_remote(operation, remote)
120
+ return remote if remote.is_a?(Hash)
121
+ state = GitTransferState.new(operation: operation, remote: remote, lock: Mutex.new,
122
+ cancelled: false, prompt_auth: prompt_auth && credentials.nil?, redactions: redactions.map(&:dup).freeze,
123
+ locked_buffers: operation == :pull ? lock_pull_buffers : [].freeze)
124
+ @git_transfer_state = state
125
+ state.job = Thread.new { run_git_transfer(state, credentials) }
126
+ end
127
+ self.message = "Git #{operation} started"
128
+ state.job
129
+ rescue StandardError
130
+ unlock_pull_buffers(state&.locked_buffers)
131
+ raise
132
+ end
133
+
134
+ def select_git_remote(operation, requested)
135
+ remotes = git_remotes
136
+ if requested
137
+ raise Error, "Unknown Git remote" unless requested.is_a?(String) && remotes.key?(requested)
138
+ return requested.dup.freeze
139
+ end
140
+ return "origin".freeze if remotes.key?("origin")
141
+ return remotes.keys.first if remotes.one?
142
+ raise Error, "No Git remotes are configured" if remotes.empty?
143
+
144
+ labels = remotes.keys.map { |name| safe_git_history_text(name, 200) }
145
+ self.palette = {kind: :git_remotes, operation: operation, query: +"", index: 0,
146
+ matches: labels, all_matches: labels, items: remotes.keys}
147
+ update_palette
148
+ @palette
149
+ end
150
+
151
+ def run_git_transfer(state, credentials)
152
+ effective = credentials || default_git_credentials(state.remote)
153
+ state.lock.synchronize { state.prompt_auth = false unless effective }
154
+ git_state.synchronize do |repository|
155
+ progress = ->(event) { publish_git_transfer_progress(state, event) }
156
+ cancelled = -> { state.cancelled? }
157
+ case state.operation
158
+ when :fetch
159
+ repository.fetch(state.remote, credentials: effective, cancelled: cancelled, &progress)
160
+ when :pull
161
+ repository.pull(state.remote, credentials: effective, cancelled: cancelled, &progress)
162
+ when :push
163
+ branch = repository.branch
164
+ raise Error, "Cannot push from a detached HEAD" unless branch
165
+ ref = "refs/heads/#{branch}"
166
+ repository.push(state.remote, refspecs: "#{ref}:#{ref}", credentials: effective,
167
+ cancelled: cancelled, &progress)
168
+ end
169
+ end
170
+ post { finish_git_transfer(state, nil) } unless @closed
171
+ rescue StandardError => error
172
+ post { finish_git_transfer(state, error) } unless @closed
173
+ ensure
174
+ (@git_transfer_guard ||= Mutex.new).synchronize do
175
+ @git_transfer_state = nil if @closed && @git_transfer_state.equal?(state)
176
+ end
177
+ end
178
+
179
+ def default_git_credentials(remote)
180
+ url = git_remotes[remote]
181
+ url&.match?(/\Ahttps?:\/\//i) ? Thuban::Remote::Credentials.helper : nil
182
+ end
183
+
184
+ def publish_git_transfer_progress(state, event)
185
+ phase = TRANSFER_PHASES.include?(event.phase) ? event.phase : :remote
186
+ progress = {operation: state.operation, remote: safe_git_history_text(state.remote, 200), phase: phase,
187
+ current: transfer_number(event.current), total: transfer_number(event.total), bytes: transfer_number(event.bytes)}.freeze
188
+ should_post = state.lock.synchronize do
189
+ state.progress = progress
190
+ next false if state.progress_posted
191
+ state.progress_posted = true
192
+ end
193
+ post { install_git_transfer_progress(state) } if should_post && !@closed
194
+ end
195
+
196
+ def transfer_number(value)
197
+ value.is_a?(Integer) && value >= 0 ? value : nil
198
+ end
199
+
200
+ def install_git_transfer_progress(state)
201
+ progress = state.lock.synchronize do
202
+ state.progress_posted = false
203
+ state.progress
204
+ end
205
+ return unless (@git_transfer_guard ||= Mutex.new).synchronize { @git_transfer_state.equal?(state) }
206
+
207
+ detail = progress[:total] ? " #{progress[:current] || 0}/#{progress[:total]}" :
208
+ progress[:bytes] ? " #{progress[:bytes]} bytes" : ""
209
+ self.message = "Git #{state.operation}: #{progress[:phase]}#{detail}"
210
+ end
211
+
212
+ def finish_git_transfer(state, error)
213
+ current = (@git_transfer_guard ||= Mutex.new).synchronize do
214
+ next false unless @git_transfer_state.equal?(state)
215
+ @git_transfer_state = nil
216
+ true
217
+ end
218
+ return unless current
219
+
220
+ unlock_pull_buffers(state.locked_buffers)
221
+ if error.is_a?(Thuban::AuthenticationError) && state.prompt_auth
222
+ return prompt_git_credentials(state.operation, state.remote)
223
+ end
224
+ if error
225
+ self.message = git_transfer_error_message(state, error)
226
+ return
227
+ end
228
+
229
+ invalidate_git
230
+ refresh_files
231
+ refresh_scm if @scm_tree
232
+ if state.operation == :pull
233
+ @buffers.values.uniq.each do |buffer|
234
+ buffer.reload if buffer.path && File.file?(buffer.path) && !buffer.read_only && !buffer.dirty?
235
+ end
236
+ end
237
+ self.message = "Git #{state.operation} complete"
238
+ end
239
+
240
+ def prompt_git_credentials(operation, remote)
241
+ self.palette = {kind: :git_credentials, operation: operation, remote: remote,
242
+ stage: :username, query: +"", index: 0, matches: [], secret: false}
243
+ self.message = "Authentication required; enter a username, or leave blank for a bearer token"
244
+ @palette
245
+ end
246
+
247
+ def git_transfer_error_message(state, error)
248
+ return "Git #{state.operation} cancelled" if error.is_a?(Thuban::Cancelled)
249
+ return "Git #{state.operation} authentication failed" if error.is_a?(Thuban::AuthenticationError)
250
+ return "Git #{state.operation} failed" if error.is_a?(Thuban::TransportError)
251
+
252
+ text = safe_git_history_text(error.message, 500)
253
+ state.redactions.each { |secret| text = text.gsub(secret, "[REDACTED]") unless secret.empty? }
254
+ "Git #{state.operation}: #{text}"
255
+ end
256
+
257
+ def lock_pull_buffers
258
+ buffers = @buffers.values.uniq.select { |buffer| buffer.path && !buffer.read_only }
259
+ buffers.each { |buffer| buffer.instance_variable_set(:@read_only, true) }
260
+ buffers.freeze
261
+ end
262
+
263
+ def unlock_pull_buffers(buffers)
264
+ buffers&.each { |buffer| buffer.instance_variable_set(:@read_only, false) }
265
+ end
266
+ end
267
+ end