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,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "editor_config"
4
+
5
+ module Canopus
6
+ module Workspace::EditorConfigurable
7
+ EDITORCONFIG_MAX_BYTES = 64 * 1024
8
+ EDITORCONFIG_CACHE_LIMIT = 1_024
9
+
10
+ # Return settings with the file's EditorConfig properties inserted before
11
+ # the project settings layer.
12
+ def settings_for_path(path, language: nil)
13
+ settings = editorconfig_settings(path)
14
+ language ? settings.for_language(language) : settings
15
+ end
16
+
17
+ def settings_for_editor(editor)
18
+ settings_for_path(editor.buffer.path, language: editor.language_document.definition.name)
19
+ end
20
+
21
+ def invalidate_editorconfig
22
+ @editorconfig_cache&.clear
23
+ nil
24
+ end
25
+
26
+ def prepare_editorconfig_save(buffer, path)
27
+ return if buffer.read_only
28
+ values = settings_for_path(path)
29
+ buffer.trim_trailing_whitespace if values["trim_trailing_whitespace"]
30
+ buffer.ensure_final_newline if values["insert_final_newline"]
31
+ end
32
+
33
+ private
34
+
35
+ def editorconfig_settings(path)
36
+ return @settings unless @settings["editorconfig"] && path
37
+ properties = editorconfig_properties(path)
38
+ return @settings if properties.empty?
39
+
40
+ layers = @settings.layers.dup
41
+ project_path = File.expand_path(File.join(@root, ".canopus", "settings.jsonc"))
42
+ index = layers.index do |layer|
43
+ next false unless layer.is_a?(String)
44
+ candidate = File.expand_path(layer)
45
+ candidate = File.realpath(candidate) if File.exist?(candidate)
46
+ candidate == project_path
47
+ end
48
+ layers.insert(index || layers.length, properties)
49
+ Settings.new(*layers)
50
+ rescue StandardError => error
51
+ @message = "EditorConfig ignored: #{error.message}"
52
+ @settings
53
+ end
54
+
55
+ def editorconfig_properties(path)
56
+ absolute = File.expand_path(path, @root)
57
+ absolute = File.realpath(absolute) if File.exist?(absolute)
58
+ return {} unless absolute == @root || absolute.start_with?(@root + File::SEPARATOR)
59
+ relative = absolute.delete_prefix(@root + File::SEPARATOR)
60
+ return {} if relative.empty?
61
+
62
+ @editorconfig_cache ||= {}
63
+ return @editorconfig_cache[absolute] if @editorconfig_cache.key?(absolute)
64
+
65
+ raw = EditorConfig.load(relative) do |candidate|
66
+ editorconfig_file(candidate)
67
+ end
68
+ properties = map_editorconfig(EditorConfig.preprocess(raw))
69
+ @editorconfig_cache[absolute] = properties.freeze
70
+ @editorconfig_cache.shift if @editorconfig_cache.length > EDITORCONFIG_CACHE_LIMIT
71
+ properties
72
+ rescue ArgumentError, EncodingError, IOError, SystemCallError
73
+ @editorconfig_cache[absolute] = {}.freeze if defined?(@editorconfig_cache) && absolute
74
+ {}
75
+ end
76
+
77
+ def editorconfig_file(candidate)
78
+ absolute = File.expand_path(candidate, @root)
79
+ real = File.realpath(absolute)
80
+ return unless real == @root || real.start_with?(@root + File::SEPARATOR)
81
+ return unless absolute == @root || absolute.start_with?(@root + File::SEPARATOR)
82
+ stat = File.lstat(absolute)
83
+ return unless stat.file? && !stat.symlink? && stat.size <= EDITORCONFIG_MAX_BYTES
84
+
85
+ source = File.binread(absolute)
86
+ return unless source.force_encoding(Encoding::UTF_8).valid_encoding?
87
+
88
+ source
89
+ rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP, IOError, SystemCallError
90
+ nil
91
+ end
92
+
93
+ def map_editorconfig(properties)
94
+ values = {}
95
+ style = properties[EditorConfig::INDENT_STYLE]
96
+ values["use_tabs"] = style == EditorConfig::TAB if [EditorConfig::SPACE, EditorConfig::TAB].include?(style)
97
+
98
+ size = properties[EditorConfig::INDENT_SIZE]
99
+ size = properties[EditorConfig::TAB_WIDTH] if size.nil? || size == EditorConfig::TAB
100
+ values["tab_size"] = size.to_i if size&.match?(/\A[1-9]\d*\z/) && size.to_i <= 16
101
+
102
+ {EditorConfig::TRIM_TRAILING_WHITESPACE => "trim_trailing_whitespace",
103
+ EditorConfig::INSERT_FINAL_NEWLINE => "insert_final_newline"}.each do |source, target|
104
+ value = properties[source]
105
+ values[target] = value == EditorConfig::TRUE if [EditorConfig::TRUE, EditorConfig::FALSE].include?(value)
106
+ end
107
+
108
+ length = properties[EditorConfig::MAX_LINE_LENGTH]
109
+ values["max_line_length"] = length.to_i if length&.match?(/\A[1-9]\d*\z/) && length.to_i <= 1_000_000
110
+ values
111
+ end
112
+ end
113
+ end
@@ -4,10 +4,14 @@ module Canopus
4
4
  module Workspace::FileChangeAware
5
5
  def start_watching
6
6
  return if @watcher || !@project
7
- native = begin
8
- Zaniah::Platform.watch(@root)
9
- rescue LoadError, Zaniah::Error, SystemCallError
7
+ native = if @window && @window.class == Zaniah::Platform::Headless::Window
10
8
  nil
9
+ else
10
+ begin
11
+ Zaniah::Platform.watch(@root)
12
+ rescue LoadError, Zaniah::Error, SystemCallError
13
+ nil
14
+ end
11
15
  end
12
16
  @watcher = @project.watcher(native: native)
13
17
  @last_watch = 0
@@ -20,6 +24,10 @@ module Canopus
20
24
  events = @watcher.poll
21
25
  invalidate_git unless events.empty?
22
26
  refresh_files unless events.empty?
27
+ if events.any? { |event| File.basename(event.path) == ".editorconfig" }
28
+ invalidate_editorconfig
29
+ apply_settings
30
+ end
23
31
  events.each do |event|
24
32
  path = File.expand_path(event.path, @root)
25
33
  buffer = @buffers[path]
@@ -1,35 +1,39 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../git/state"
4
+
3
5
  module Canopus
4
6
  module Workspace::GitAware
5
7
  def git
6
8
  return @git if defined?(@git)
7
9
  @git = Thuban::Repository.new(@root)
8
10
  @git = nil unless @git.root
11
+ @git
9
12
  rescue ArgumentError
10
13
  @git = nil
11
14
  end
12
15
  def git_status
13
- return @git_status if @git_status
14
- return {} unless git
16
+ state = git_state
17
+ return {} unless state
18
+ return state.snapshot.entries if state.snapshot
15
19
  if @window
16
20
  unless @git_status_job&.alive?
17
- generation = @git_generation
21
+ generation = @git_generation ||= 0
18
22
  @git_status_job = Thread.new do
19
- status = git.status.to_h { |entry| [entry.path, entry.code] }
20
- post { @git_status = status if generation == @git_generation } unless @closed
23
+ snapshot = state.capture
24
+ post { install_git_snapshot(state, snapshot) if generation == @git_generation } unless @closed
21
25
  rescue StandardError => error
22
26
  post { @message = "Git status: #{error.message}" } unless @closed
23
27
  end
24
28
  end
25
29
  {}
26
30
  else
27
- @git_status = git.status.to_h { |entry| [entry.path, entry.code] }
31
+ install_git_snapshot(state, state.capture).entries
28
32
  end
29
33
  end
30
34
  def invalidate_git
31
35
  @git_generation = (@git_generation || 0) + 1
32
- @git_status = @git_diff_cache = nil
36
+ @git_state&.invalidate
33
37
  @decorations.invalidate(:git)
34
38
  @panes.each do |pane|
35
39
  pane.editors.each do |current|
@@ -41,18 +45,18 @@ module Canopus
41
45
  def poll_git_changes(now: Process.clock_gettime(Process::CLOCK_MONOTONIC))
42
46
  return if @last_git_poll && now - @last_git_poll < 1
43
47
  @last_git_poll = now
44
- return unless git
45
- index = File.join(git.git_dir, "index")
46
- stat = File.stat(index) if File.file?(index)
47
- state = [git.head, git.branch, stat&.mtime, stat&.size, stat&.ino]
48
- return if @git_state == state
49
- initial = !defined?(@git_state)
50
- @git_state = state
51
- return if initial
52
- invalidate_git
53
- @window&.request_frame
54
- rescue SystemCallError, ArgumentError => error
55
- @message = "Git: #{error.message}"
48
+ state = git_state
49
+ return unless state
50
+ unless @git_poll_job&.alive?
51
+ generation = @git_generation ||= 0
52
+ @git_poll_job = Thread.new do
53
+ identity = state.identity
54
+ post { install_git_identity(identity) if generation == @git_generation } unless @closed
55
+ rescue StandardError => error
56
+ post { @message = "Git: #{error.message}" } unless @closed
57
+ end
58
+ end
59
+ nil
56
60
  end
57
61
  def git_relative_path(buffer = editor.buffer)
58
62
  raise Error, "Current document is not in a Git repository" unless git && buffer.path && buffer.path.start_with?(git.root + File::SEPARATOR)
@@ -61,22 +65,25 @@ module Canopus
61
65
  def git_diff(buffer = editor.buffer, async: true)
62
66
  return unless git && buffer.path && buffer.path.start_with?(git.root + File::SEPARATOR) && buffer.rope.bytesize < 10 << 20
63
67
  path = git_relative_path(buffer)
64
- @git_diff_cache ||= {}
65
- key = [buffer.object_id, path, buffer.version, git.head]
66
- @git_diff_cache.clear if @git_diff_cache.length > 20
67
- return @git_diff_cache[key] if @git_diff_cache.key?(key)
68
+ state = git_state
69
+ key = [buffer.object_id, path, buffer.version, state.snapshot&.head]
70
+ cached = state.cached_diff(key)
71
+ return cached if cached
68
72
  if @window && async
69
73
  @git_diff_jobs ||= {}
70
74
  unless @git_diff_jobs[buffer]&.alive?
71
75
  snapshot = buffer.rope
76
+ generation = @git_generation ||= 0
72
77
  @git_diff_jobs[buffer] = Thread.new do
73
- before = Buffer.decode_bytes(git.blob(path, reference: key.last || "HEAD").to_s).first
78
+ before = Buffer.decode_bytes(state.synchronize { |repository| repository.blob(path, reference: key.last || "HEAD") }.to_s).first
74
79
  diff = Porrima.diff(before, snapshot.to_s, context: 0)
75
80
  diff.hunks
76
81
  diff.marks
77
82
  post do
78
- (@git_diff_cache ||= {})[key] = diff
79
- @decorations.invalidate(:git, buffer: buffer)
83
+ if generation == @git_generation
84
+ state.store_diff(key, diff)
85
+ @decorations.invalidate(:git, buffer: buffer)
86
+ end
80
87
  end unless @closed
81
88
  rescue StandardError => error
82
89
  post { @message = "Git diff: #{error.message}" } unless @closed
@@ -84,11 +91,11 @@ module Canopus
84
91
  end
85
92
  nil
86
93
  else
87
- before = Buffer.decode_bytes(git.blob(path).to_s).first
94
+ before = Buffer.decode_bytes(state.synchronize { |repository| repository.blob(path, reference: key.last || "HEAD") }.to_s).first
88
95
  diff = Porrima.diff(before, buffer.text, context: 0)
89
96
  diff.hunks
90
97
  diff.marks
91
- @git_diff_cache[key] = diff
98
+ state.store_diff(key, diff)
92
99
  end
93
100
  end
94
101
  def git_hunks(buffer = editor.buffer, async: true) = git_diff(buffer, async: async)&.hunks || []
@@ -100,23 +107,24 @@ module Canopus
100
107
  next if first + count <= rows.begin || first >= rows.end
101
108
 
102
109
  color = mark.kind == :removed ? :error : mark.kind == :added ? "#80b987" : :accent
103
- style = {color: color, rows: count}.freeze
110
+ style = {color: color, rows: count, hit_width: 7}.freeze
104
111
  click = ->(current, row) { toggle_git_hunk(current, row: row) }
105
112
  Decoration::Item.new(:gutter, nil, first, "Toggle Git hunk", style, 0, :git, click)
106
113
  end
107
114
  end
108
115
  def show_git_diff
109
116
  path = git_relative_path
110
- before = Buffer.decode_bytes(git.blob(path).to_s).first
117
+ before = Buffer.decode_bytes(git_state.synchronize { |repository| repository.blob(path) }.to_s).first
111
118
  buffer = Buffer.new(Porrima.unified(before, editor.buffer.text, old_name: "a/#{path}", new_name: "b/#{path}"), read_only: true)
112
119
  @buffers[buffer.object_id] = buffer
113
120
  @active_pane.open(buffer).language = Language::Definition.new("diff", "diff", [], "", /\A\z/, /\A\z/, [])
121
+ invalidate_hidden_selection_ranges
114
122
  end
115
123
  def toggle_git_hunk(current = editor, row: nil)
116
124
  row ||= current.buffer.rope.point_at(current.primary.head).row
117
125
  hunk = git_diff(current.buffer, async: false)&.hunk_at(new_line: row + 1)
118
126
  raise Error, "No change at the cursor" unless hunk
119
- id = [:git_hunk, current.buffer.path, current.buffer.version, git.head, hunk.old_start, hunk.new_start]
127
+ id = [:git_hunk, current.buffer.path, current.buffer.version, git_state.snapshot&.head, hunk.old_start, hunk.new_start]
120
128
  map = current.display_map
121
129
  if map.block_map.blocks.key?(id)
122
130
  map.remove_block(id)
@@ -138,7 +146,7 @@ module Canopus
138
146
  end
139
147
  def show_git_blame
140
148
  path = git_relative_path
141
- lines = git.blame(path)
149
+ lines = git_state.synchronize { |repository| repository.blame(path) }
142
150
  @hover_card = lines.map { |line| "#{line.commit.to_s[0, 8]} #{line.author} #{line.text}" }.join
143
151
  end
144
152
  def revert_current_hunk
@@ -150,12 +158,50 @@ module Canopus
150
158
  end
151
159
  def checkout_branch(name)
152
160
  raise Error, "Save or discard all buffer changes before switching branches" if @buffers.values.any?(&:dirty?)
153
- raise Error, "Not a Git repository" unless git
154
- git.checkout(name)
161
+ raise Error, "Not a Git repository" unless git_state
162
+ git_state.synchronize { |repository| repository.checkout(name) }
155
163
  invalidate_git
156
164
  refresh_files
157
165
  @buffers.values.each { |buffer| buffer.reload if buffer.path && File.file?(buffer.path) && !buffer.read_only }
158
166
  @message = "Switched to #{name}"
159
167
  end
168
+
169
+ private
170
+
171
+ def git_state
172
+ return @git_state if defined?(@git_state)
173
+ @git_state = Git::State.new(git) if git
174
+ end
175
+
176
+ def install_git_snapshot(state, snapshot)
177
+ state.install(snapshot)
178
+ @git_identity = snapshot.identity
179
+ refresh_scm if @scm_tree
180
+ snapshot
181
+ end
182
+
183
+ def install_git_identity(identity)
184
+ initial = !defined?(@git_identity)
185
+ changed = !initial && @git_identity != identity
186
+ @git_identity = identity
187
+ return unless changed
188
+
189
+ invalidate_git
190
+ refresh_scm if @scm_tree
191
+ @window&.request_frame
192
+ end
193
+
194
+ def git_branches
195
+ state = git_state
196
+ state ? state.synchronize { |repository| repository.branches } : []
197
+ end
198
+
199
+ def close_git
200
+ [@git_status_job, @git_poll_job, @git_commit_job, *@git_diff_jobs&.values].compact.uniq.each do |thread|
201
+ thread.join unless thread.equal?(Thread.current)
202
+ end
203
+ @git_diff_jobs&.clear
204
+ nil
205
+ end
160
206
  end
161
207
  end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canopus
4
+ module Workspace::GitBlame
5
+ BLAME_MAX_BYTES = 1 << 20
6
+ BLAME_MAX_LINES = 20_000
7
+ BLAME_CACHE_LIMIT = 16
8
+ BLAME_BUDGET = Porrima::Budget.new(max_bytes: 2 << 20, max_lines: BLAME_MAX_LINES)
9
+
10
+ GitBlameLine = Data.define(:commit, :author, :original_line, :path)
11
+ GitBlameSnapshot = Data.define(:key, :lines, :reason)
12
+
13
+ def git_blame_decorations(buffer, rows, current = nil)
14
+ mode = @settings["git"]["inline_blame"]
15
+ return [] if mode == "off" || !buffer.path || buffer.rope.bytesize > BLAME_MAX_BYTES ||
16
+ buffer.line_count > BLAME_MAX_LINES
17
+ text = buffer.text
18
+ return [] unless git_blame_text_supported?(text)
19
+
20
+ path = safe_git_diff_path(git_relative_path(buffer))
21
+ key = [buffer.object_id, buffer.version, @git_generation.to_i, path].freeze
22
+ snapshot = (@git_blame_cache ||= {})[key]
23
+ unless snapshot
24
+ requested = request_git_blame(buffer, path, key, text.dup.freeze)
25
+ snapshot = requested if requested.is_a?(GitBlameSnapshot)
26
+ return [] unless snapshot
27
+ end
28
+ return [] if snapshot.reason
29
+
30
+ visible = rows.begin...[rows.end, buffer.line_count].min
31
+ selected = if mode == "cursor"
32
+ return [] unless current.is_a?(Editor) && current.buffer.equal?(buffer)
33
+ row = buffer.rope.point_at(current.primary.head).row
34
+ visible.cover?(row) ? [row] : []
35
+ else
36
+ visible.to_a
37
+ end
38
+ selected.filter_map do |row|
39
+ next if row == buffer.line_count - 1 && buffer.line(row).empty?
40
+ git_blame_decoration(buffer, row, snapshot.lines[row])
41
+ end
42
+ rescue Error
43
+ []
44
+ end
45
+
46
+ def invalidate_git_blame(buffer = nil)
47
+ @git_blame_generation = (@git_blame_generation || 0) + 1
48
+ if buffer
49
+ @git_blame_cache&.delete_if { |key, _| key[0] == buffer.object_id }
50
+ else
51
+ @git_blame_cache&.clear
52
+ end
53
+ @decorations.invalidate(:blame, buffer: buffer)
54
+ nil
55
+ end
56
+
57
+ def invalidate_git
58
+ invalidate_git_blame
59
+ super
60
+ end
61
+
62
+ def close_git
63
+ @git_blame_generation = (@git_blame_generation || 0) + 1
64
+ jobs = [*@git_blame_jobs&.values].compact.uniq.reject { |thread| thread.equal?(Thread.current) }
65
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 0.1
66
+ jobs.each do |thread|
67
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
68
+ break unless remaining.positive?
69
+ thread.join(remaining)
70
+ end
71
+ jobs.each { |thread| thread.kill if thread.alive? }
72
+ jobs.each(&:join)
73
+ @git_blame_jobs&.clear
74
+ super
75
+ end
76
+
77
+ private
78
+
79
+ def request_git_blame(buffer, path, key, text)
80
+ @git_blame_jobs ||= {}
81
+ return if @git_blame_jobs[buffer]&.alive?
82
+
83
+ generation = @git_blame_generation ||= 0
84
+ work = lambda do
85
+ capture = git_state.synchronize do |repository|
86
+ raw = repository.blob(path)
87
+ if raw && raw.bytesize <= BLAME_MAX_BYTES
88
+ committed = Buffer.decode_bytes(raw).first
89
+ git_blame_text_supported?(committed) ? [committed, repository.blame(path)].freeze : [nil, nil].freeze
90
+ else
91
+ [nil, nil].freeze
92
+ end
93
+ end
94
+ materialize_git_blame(key, text, *capture)
95
+ end
96
+ return install_git_blame(buffer, work.call) unless @window
97
+
98
+ @git_blame_jobs[buffer] = Thread.new do
99
+ current = Thread.current
100
+ snapshot = work.call
101
+ post { finish_git_blame(current, generation, buffer, snapshot, nil) } unless @closed
102
+ rescue StandardError => error
103
+ post { finish_git_blame(current, generation, buffer, nil, error) } unless @closed
104
+ end
105
+ rescue StandardError => error
106
+ install_git_blame(buffer, GitBlameSnapshot.new(key, [].freeze,
107
+ safe_git_history_text(error.message, 200).freeze).freeze)
108
+ end
109
+
110
+ def git_blame_text_supported?(text)
111
+ text.bytesize <= BLAME_MAX_BYTES && text.count("\n") + 1 <= BLAME_MAX_LINES &&
112
+ !text.match?(/\r(?!\n)|[\u2028\u2029]/)
113
+ end
114
+
115
+ def materialize_git_blame(key, text, committed, blamed)
116
+ return GitBlameSnapshot.new(key, [].freeze, "Blame unavailable for large or missing content".freeze).freeze unless committed
117
+ reason = scm_diff_budget_reason(committed, text)
118
+ return GitBlameSnapshot.new(key, [].freeze, "Blame unavailable: #{scm_diff_budget_description(reason)}".freeze).freeze if reason
119
+
120
+ lines = Array.new(text.lines.length)
121
+ comparison_committed = committed.gsub("\r\n", "\n")
122
+ comparison_text = text.gsub("\r\n", "\n")
123
+ if comparison_committed == comparison_text
124
+ blamed.each_with_index { |line, index| lines[index] = copy_git_blame_line(line) }
125
+ else
126
+ Porrima.diff(comparison_committed, comparison_text, budget: BLAME_BUDGET).edits.each do |edit|
127
+ next unless edit.kind == :equal
128
+ lines[edit.new_line - 1] = copy_git_blame_line(blamed[edit.old_line - 1])
129
+ end
130
+ end
131
+ GitBlameSnapshot.new(key, lines.freeze, nil).freeze
132
+ rescue Porrima::BudgetExceeded
133
+ GitBlameSnapshot.new(key, [].freeze, "Blame unavailable: diff budget exceeded".freeze).freeze
134
+ end
135
+
136
+ def copy_git_blame_line(line)
137
+ return unless line
138
+ GitBlameLine.new(line.commit.to_s.dup.freeze, safe_git_history_text(line.author, 100).freeze,
139
+ line.original_line, line.path.to_s.dup.freeze).freeze
140
+ end
141
+
142
+ def git_blame_decoration(buffer, row, line)
143
+ source = buffer.line(row)
144
+ body = source.delete_suffix("\n").delete_suffix("\r")
145
+ offset = buffer.rope.line_start(row) + body.bytesize
146
+ content = if line
147
+ " #{line.author} · #{line.commit[0, 8]}"
148
+ else
149
+ " Uncommitted changes"
150
+ end
151
+ Decoration::Item.new(:inline, offset...offset, row, content.freeze,
152
+ {color: :muted, padding_left: 8, cells: Zaniah::Unicode.width(content)}.freeze,
153
+ 90, :blame, nil)
154
+ end
155
+
156
+ def install_git_blame(buffer, snapshot)
157
+ cache = @git_blame_cache ||= {}
158
+ cache.shift while cache.length >= BLAME_CACHE_LIMIT
159
+ cache[snapshot.key] = snapshot
160
+ @decorations.invalidate(:blame, buffer: buffer)
161
+ @window&.request_frame
162
+ snapshot
163
+ end
164
+
165
+ def finish_git_blame(worker, generation, buffer, snapshot, error)
166
+ @git_blame_jobs&.delete(buffer) if @git_blame_jobs&.[](buffer).equal?(worker)
167
+ return unless generation == @git_blame_generation
168
+ if error
169
+ self.message = "Git blame: #{safe_git_history_text(error.message, 200)}"
170
+ else
171
+ install_git_blame(buffer, snapshot)
172
+ end
173
+ end
174
+ end
175
+ end