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,394 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zaniah/ui"
4
+
5
+ module Canopus
6
+ module Workspace::TestAware
7
+ TEST_LABEL_LIMIT = 200
8
+ TEST_COLORS = {idle: :muted, running: :accent, success: "#3fb950",
9
+ failure: :error, skipped: :muted}.freeze
10
+ TEST_MARKS = {idle: "▶", running: "●", success: "✓", failure: "×", skipped: "○"}.freeze
11
+
12
+ TestClick = Data.define(:workspace, :tests) do
13
+ def call(_editor, _row) = workspace.run_tests(tests)
14
+ end
15
+ private_constant :TestClick
16
+
17
+ attr_reader :tests
18
+
19
+ def test_results = @test_results.dup.freeze
20
+
21
+ def initialize_tests
22
+ @tests = [].freeze
23
+ @test_generation = 0
24
+ @test_discovery_started = @test_discovery_pending = @test_discovery_requested = false
25
+ @test_discovery_thread = @test_discovery_token = nil
26
+ @test_discovery = nil
27
+ @tests_by_path = {}.freeze
28
+ @test_results, @test_executions, @test_outputs = {}, {}, {}
29
+ @test_diagnostic_snapshot = {}.freeze
30
+ @decorations.register(:test) { |buffer, rows| test_decorations(buffer, rows) }
31
+ nil
32
+ end
33
+
34
+ def test_tree
35
+ @test_tree ||= Zaniah::UI::TreeView.new(test_nodes, height: 600).on_select do |value, _event, _context|
36
+ select_test(value)
37
+ end
38
+ refresh_tests unless @test_discovery_started
39
+ @test_tree
40
+ end
41
+
42
+ def refresh_tests
43
+ raise Error, "Workspace closed" if @closed
44
+
45
+ @test_generation += 1
46
+ @test_discovery_started = @test_discovery_pending = @test_discovery_requested = true
47
+ @test_tree&.replace([test_status_node(:discovering, "Discovering tests…")])
48
+ @panels.badge("test", nil)
49
+ start_test_discovery
50
+ @window&.request_frame
51
+ @test_discovery_thread
52
+ end
53
+
54
+ def test_discovery_pending? = !!@test_discovery_pending
55
+
56
+ def run_test(test) = run_tests([test])
57
+
58
+ def run_tests(tests)
59
+ unless tests.is_a?(Array) && !tests.empty? && tests.length <= TestRunner::Discovery::MAX_TESTS &&
60
+ tests.all? { |test| test.is_a?(TestRunner::Test) && @tests.include?(test) } &&
61
+ tests.all? { |test| test.framework == tests.first.framework && test.path == tests.first.path &&
62
+ test.line == tests.first.line }
63
+ raise Error, "Unknown tests"
64
+ end
65
+
66
+ tests = tests.dup.freeze
67
+ test = tests.first
68
+ task = TestRunner::Execution.task(test, root: @root, tests: tests)
69
+ previous = tests.filter_map { |candidate| @test_outputs[candidate] }.uniq
70
+ output = start_task(task, nil)
71
+ previous.each do |entry|
72
+ @task_runner.stop(entry)
73
+ old = @test_executions.delete(entry.id)
74
+ old&.cancel
75
+ old&.tests&.each do |candidate|
76
+ @test_outputs.delete(candidate) if @test_outputs[candidate].equal?(entry)
77
+ @test_results[candidate] = old.finish(nil) if @tests.include?(candidate)
78
+ end
79
+ end
80
+ execution = TestRunner::Execution.new(test, output, generation: @test_generation, tests: tests)
81
+ @test_executions[output.id] = execution
82
+ tests.each do |candidate|
83
+ @test_outputs[candidate] = output
84
+ @test_results[candidate] = TestRunner::Result.new(:running, nil, nil, nil).freeze
85
+ end
86
+ refresh_test_results
87
+ output
88
+ end
89
+
90
+ def toggle_tests
91
+ if @panels.visible?("test")
92
+ @panels.hide("test")
93
+ else
94
+ @panels.show("test")
95
+ refresh_tests unless @test_discovery_started
96
+ end
97
+ @window&.request_frame
98
+ end
99
+
100
+ def close_tests
101
+ @test_generation += 1
102
+ @test_discovery_requested = @test_discovery_pending = false
103
+ @test_discovery_token = nil
104
+ thread, @test_discovery_thread = @test_discovery_thread, nil
105
+ thread.join if thread && thread != Thread.current
106
+ @test_executions.clear
107
+ @test_outputs.clear
108
+ @test_results.clear
109
+ publish_test_diagnostics
110
+ nil
111
+ end
112
+
113
+ private
114
+
115
+ def start_test_discovery
116
+ return if @closed || @test_discovery_thread&.alive? || !@test_discovery_requested
117
+
118
+ generation = @test_generation
119
+ @test_discovery_requested = false
120
+ token = @test_discovery_token = Object.new.freeze
121
+ thread = Thread.new do
122
+ result = error = nil
123
+ begin
124
+ discovery = @test_discovery || TestRunner::Discovery.new(root: @root)
125
+ result = discovery.discover(cancelled: -> { @closed || generation != @test_generation })
126
+ rescue StandardError => failure
127
+ error = failure
128
+ ensure
129
+ post { complete_test_discovery(token, generation, result, error) } unless @closed
130
+ end
131
+ end
132
+ thread.report_on_exception = false
133
+ @test_discovery_thread = thread
134
+ end
135
+
136
+ def complete_test_discovery(token, generation, result, error)
137
+ return unless @test_discovery_token.equal?(token)
138
+
139
+ @test_discovery_thread = @test_discovery_token = nil
140
+ if generation != @test_generation || @test_discovery_requested
141
+ start_test_discovery
142
+ elsif error
143
+ @tests = [].freeze
144
+ @tests_by_path = {}.freeze
145
+ @test_results.clear
146
+ @test_discovery_pending = false
147
+ @test_tree&.replace([test_status_node(:error, test_label("Cannot discover tests: #{error.message}"))])
148
+ self.message = test_label("Cannot discover tests: #{error.message}")
149
+ @decorations.invalidate(:test)
150
+ publish_test_diagnostics
151
+ else
152
+ @tests = result
153
+ @tests_by_path = @tests.group_by(&:path).transform_values { |tests| tests.freeze }.freeze
154
+ known = @tests.to_h { |test| [test, true] }
155
+ @test_results.delete_if { |test, result| !known.key?(test) || result.status == :running }
156
+ @test_discovery_pending = false
157
+ @test_tree&.replace(test_nodes)
158
+ @panels.badge("test", @tests.empty? ? nil : @tests.length)
159
+ @decorations.invalidate(:test)
160
+ publish_test_diagnostics
161
+ end
162
+ @window&.request_frame
163
+ nil
164
+ end
165
+
166
+ def test_nodes
167
+ return [test_status_node(:discovering, "Discovering tests…")].freeze if @test_discovery_pending
168
+ return [test_status_node(:empty, "No tests found")].freeze if @tests.empty?
169
+
170
+ @tests.group_by(&:path).map do |path, tests|
171
+ children = tests.map do |test|
172
+ {id: [:test, test.framework, test.path, test.offset].freeze,
173
+ label: test_case_label(test), value: test}.freeze
174
+ end.freeze
175
+ {id: [:test_file, path].freeze, label: test_label(path), value: nil, children: children}.freeze
176
+ end.freeze
177
+ end
178
+
179
+ def test_status_node(kind, label)
180
+ {id: [:test_status, kind].freeze, label: label.freeze, value: nil}.freeze
181
+ end
182
+
183
+ def test_label(value)
184
+ bounded_test_label([value])
185
+ end
186
+
187
+ def test_case_label(test)
188
+ mark = TEST_MARKS.fetch(@test_results[test]&.status || :idle)
189
+ label = bounded_test_label([*test.groups, test.name], separator: " › ", suffix: " · line #{test.line}")
190
+ bounded_test_label(["#{mark} ", label])
191
+ end
192
+
193
+ def bounded_test_label(parts, separator: "", suffix: nil)
194
+ label = +""
195
+ parts.each_with_index do |part, index|
196
+ if index.positive? && !append_test_label(label, separator)
197
+ return finish_test_label(label)
198
+ end
199
+ return finish_test_label(label) unless append_test_label(label, part)
200
+ end
201
+ return finish_test_label(label) if suffix && !append_test_label(label, suffix)
202
+
203
+ label.rstrip.freeze
204
+ end
205
+
206
+ def append_test_label(label, value)
207
+ value.to_s.each_char do |character|
208
+ character = " " if character.match?(/\s/)
209
+ next if character == " " && (label.empty? || label.end_with?(" "))
210
+ return false if label.length >= TEST_LABEL_LIMIT
211
+
212
+ label << character
213
+ end
214
+ true
215
+ end
216
+
217
+ def finish_test_label(label)
218
+ label.rstrip!
219
+ label.slice!(TEST_LABEL_LIMIT - 1..) if label.length >= TEST_LABEL_LIMIT
220
+ label << "…"
221
+ label.freeze
222
+ end
223
+
224
+ def select_test(test)
225
+ return false unless test.is_a?(TestRunner::Test)
226
+
227
+ result = @test_results[test]
228
+ if result&.status == :failure
229
+ begin
230
+ return select_test_location(result.path, result.line)
231
+ rescue Error, SystemCallError
232
+ # The file may have changed since the run; use the declaration below.
233
+ end
234
+ end
235
+ select_test_location(test.path, test.line)
236
+ rescue Error, SystemCallError => error
237
+ self.message = "Cannot open test: #{error.message}"
238
+ false
239
+ end
240
+
241
+ def select_test_location(relative, line)
242
+ relative = TestRunner.normalize_path(relative)
243
+ raise Error, "Test target path is invalid" unless relative
244
+
245
+ candidate = File.join(@root, relative)
246
+ raise Error, "Test target is not a file" if File.lstat(candidate).symlink?
247
+ path = File.realpath(candidate)
248
+ prefix = @root.end_with?(File::SEPARATOR) ? @root : @root + File::SEPARATOR
249
+ raise Error, "Test target is outside the workspace" unless path.start_with?(prefix) && File.file?(path)
250
+
251
+ target = open(path)
252
+ row = line - 1
253
+ raise Error, "Test target line is no longer valid" unless row.between?(0, target.buffer.line_count - 1)
254
+ target.select(target.buffer.rope.line_start(row))
255
+ target.reveal_cursor
256
+ true
257
+ end
258
+
259
+ def test_decorations(buffer, rows)
260
+ return [] unless buffer.path&.start_with?(@root + File::SEPARATOR)
261
+
262
+ relative = buffer.path.delete_prefix(@root + File::SEPARATOR).tr(File::SEPARATOR, "/")
263
+ visible = @tests_by_path.fetch(relative, []).select { |test| rows.cover?(test.line - 1) }
264
+ visible.group_by(&:line).map do |line, tests|
265
+ tests = tests.freeze
266
+ status = test_row_status(tests)
267
+ label = tests.length == 1 ? "#{TEST_MARKS.fetch(status)} Run #{test_label(tests.first.name)}" :
268
+ "#{TEST_MARKS.fetch(status)} Run #{tests.length} tests on line #{line}"
269
+ style = {color: TEST_COLORS.fetch(status), gutter_offset: 1, gutter_width: 4,
270
+ hit_offset: 0, hit_width: 6}.freeze
271
+ Decoration::Item.new(:gutter, nil, line - 1, label, style, 30, :test,
272
+ TestClick.new(self, tests).freeze)
273
+ end
274
+ end
275
+
276
+ def test_row_status(tests)
277
+ statuses = tests.filter_map { |test| @test_results[test]&.status }
278
+ return :running if statuses.include?(:running)
279
+ return :failure if statuses.include?(:failure)
280
+ return :idle if statuses.length < tests.length
281
+ return :skipped if statuses.all? { |status| status == :skipped }
282
+
283
+ :success
284
+ end
285
+
286
+ def capture_test_output(output, data)
287
+ @test_executions[output.id]&.append(data)
288
+ end
289
+
290
+ def cancel_test_output(output)
291
+ @test_executions[output&.id]&.cancel
292
+ end
293
+
294
+ def complete_test_output(output)
295
+ execution = @test_executions.delete(output.id)
296
+ return unless execution
297
+
298
+ @test_outputs.delete_if { |_test, entry| entry.equal?(output) }
299
+ status = output.terminal.status if output.terminal.respond_to?(:status)
300
+ result = normalize_test_result(execution, execution.finish(status))
301
+ if execution.generation == @test_generation
302
+ execution.tests.each do |test|
303
+ @test_results[test] = test_result_for(test, result) if @tests.include?(test)
304
+ end
305
+ end
306
+ refresh_test_results
307
+ rescue StandardError => error
308
+ message = "#{execution.test.name} failed: #{error.message}".encode(Encoding::UTF_8,
309
+ invalid: :replace, undef: :replace).delete("\0").byteslice(0, 4_096).scrub("")
310
+ if execution.generation == @test_generation
311
+ execution.tests.each do |test|
312
+ next unless @tests.include?(test)
313
+
314
+ @test_results[test] = TestRunner::Result.new(:failure, test.path, test.line, message).freeze
315
+ end
316
+ end
317
+ refresh_test_results
318
+ end
319
+
320
+ def forget_test_output(output)
321
+ execution = @test_executions.delete(output.id)
322
+ return unless execution
323
+
324
+ execution.cancel
325
+ @test_outputs.delete_if { |_test, entry| entry.equal?(output) }
326
+ if execution.generation == @test_generation
327
+ execution.tests.each do |test|
328
+ @test_results[test] = execution.finish(nil) if @tests.include?(test)
329
+ end
330
+ end
331
+ refresh_test_results
332
+ end
333
+
334
+ def refresh_test_results
335
+ @test_tree&.replace(test_nodes)
336
+ @decorations.invalidate(:test)
337
+ publish_test_diagnostics
338
+ @window&.request_frame
339
+ nil
340
+ end
341
+
342
+ def normalize_test_result(execution, result)
343
+ return result unless result.status == :failure
344
+
345
+ path = File.join(@root, execution.test.path)
346
+ entry = File.lstat(path)
347
+ return result.with(line: execution.test.line).freeze if entry.symlink? || !entry.file? ||
348
+ entry.size > TestRunner::Discovery::MAX_FILE_BYTES
349
+
350
+ absolute = File.realpath(path)
351
+ prefix = @root.end_with?(File::SEPARATOR) ? @root : @root + File::SEPARATOR
352
+ return result.with(line: execution.test.line).freeze unless absolute.start_with?(prefix)
353
+
354
+ source = File.open(absolute, "rb") { |file| file.read(TestRunner::Discovery::MAX_FILE_BYTES + 1) }
355
+ return result.with(line: execution.test.line).freeze if source.bytesize > TestRunner::Discovery::MAX_FILE_BYTES
356
+
357
+ lines = [source.count("\n") + 1, 1].max
358
+ line = result.line.between?(1, lines) ? result.line : execution.test.line.clamp(1, lines)
359
+ result.with(line: line).freeze
360
+ rescue SystemCallError
361
+ result.with(line: execution.test.line).freeze
362
+ end
363
+
364
+ def test_result_for(test, result)
365
+ return result unless result.status == :failure
366
+
367
+ message = "#{test.name} failed".byteslice(0, 4_096).scrub("")
368
+ result.with(message: message).freeze
369
+ end
370
+
371
+ def publish_test_diagnostics
372
+ grouped = @test_results.each_with_object(Hash.new { |hash, uri| hash[uri] = [] }) do |(test, result), values|
373
+ next unless result.status == :failure
374
+
375
+ path = File.join(@root, result.path)
376
+ uri = Sadr::Protocol.uri(path)
377
+ line = result.line - 1
378
+ values[uri] << {"message" => result.message, "severity" => 1, "source" => "test",
379
+ "range" => {"start" => {"line" => line, "character" => 0},
380
+ "end" => {"line" => line, "character" => 0}}}
381
+ end
382
+ snapshot = grouped.transform_values { |values| values.first(Diagnostics::PUBLICATION_LIMIT).freeze }.freeze
383
+ changed = (@test_diagnostic_snapshot.keys | snapshot.keys).select do |uri|
384
+ @test_diagnostic_snapshot[uri] != snapshot[uri]
385
+ end
386
+ return false if changed.empty?
387
+
388
+ changed.each { |uri| @diagnostics.publish(:test, uri, snapshot.fetch(uri, []), notify: false) }
389
+ @test_diagnostic_snapshot = snapshot
390
+ diagnostics_changed(changed)
391
+ true
392
+ end
393
+ end
394
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+ require "tempfile"
7
+
8
+ module Canopus
9
+ class Workspace::Trust
10
+ def initialize(root, state_path: nil)
11
+ @root = File.realpath(root)
12
+ @state_path = state_path || File.join(ENV["XDG_STATE_HOME"] || File.expand_path("~/.local/state"), "canopus", "trust.json")
13
+ @key = Digest::SHA256.hexdigest(@root)
14
+ @states = load_states
15
+ end
16
+
17
+ def trusted? = @states[@key] == true
18
+ def status = trusted? ? :trusted : :untrusted
19
+
20
+ def trust!
21
+ update(true)
22
+ end
23
+
24
+ def untrust!
25
+ update(false)
26
+ end
27
+
28
+ private
29
+
30
+ def update(value)
31
+ @states[@key] = value
32
+ FileUtils.mkdir_p(File.dirname(@state_path))
33
+ Tempfile.create([".trust-", ".json"], File.dirname(@state_path), perm: 0o600) do |file|
34
+ file.write(JSON.generate(@states))
35
+ file.flush
36
+ file.fsync
37
+ file.close
38
+ File.chmod(0o600, file.path)
39
+ File.rename(file.path, @state_path)
40
+ end
41
+ value
42
+ end
43
+
44
+ def load_states
45
+ value = JSON.parse(File.read(@state_path))
46
+ value.is_a?(Hash) ? value.select { |key, state| key.is_a?(String) && (state.is_a?(TrueClass) || state.is_a?(FalseClass)) } : {}
47
+ rescue Errno::ENOENT, Errno::EACCES, JSON::ParserError, TypeError
48
+ {}
49
+ end
50
+ end
51
+
52
+ module Workspace::TrustAware
53
+ def workspace_trust = @trust
54
+
55
+ def toggle_workspace_trust
56
+ if @trust.trusted?
57
+ @trust.untrust!
58
+ @message = "Workspace untrusted"
59
+ else
60
+ @trust.trust!
61
+ @message = "Workspace trusted"
62
+ end
63
+ @window&.request_frame
64
+ @trust.status
65
+ end
66
+
67
+ def show_workspace_trust
68
+ @message = "Workspace #{@trust.status}"
69
+ @window&.request_frame
70
+ @trust.status
71
+ end
72
+ end
73
+ end