canopus 0.1.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 (146) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +148 -0
  5. data/docs/adr/001-runtime-gem-components.md +24 -0
  6. data/docs/adr/002-bounded-diff-engine.md +22 -0
  7. data/docs/adr/003-background-language-analysis.md +22 -0
  8. data/docs/adr/004-persistent-display-map.md +23 -0
  9. data/docs/adr/005-windows-runtime.md +27 -0
  10. data/docs/adr/README.md +36 -0
  11. data/docs/distribution.md +27 -0
  12. data/docs/lsp.md +38 -0
  13. data/docs/performance.md +40 -0
  14. data/docs/snippets.md +32 -0
  15. data/docs/vim.md +24 -0
  16. data/docs/workspace_edits.md +32 -0
  17. data/examples/native_smoke.rb +25 -0
  18. data/examples/plugins/word_count.rb +11 -0
  19. data/exe/canopus +19 -0
  20. data/lib/canopus/block_map.rb +19 -0
  21. data/lib/canopus/buffer.rb +293 -0
  22. data/lib/canopus/cli.rb +171 -0
  23. data/lib/canopus/controller.rb +507 -0
  24. data/lib/canopus/data_compat.rb +25 -0
  25. data/lib/canopus/display_map/line_builder.rb +54 -0
  26. data/lib/canopus/display_map/line_set.rb +8 -0
  27. data/lib/canopus/display_map/pending_line_set.rb +9 -0
  28. data/lib/canopus/display_map/summary.rb +23 -0
  29. data/lib/canopus/display_map/worker.rb +89 -0
  30. data/lib/canopus/display_map.rb +350 -0
  31. data/lib/canopus/display_point.rb +5 -0
  32. data/lib/canopus/editor/snippet_expandable.rb +281 -0
  33. data/lib/canopus/editor.rb +445 -0
  34. data/lib/canopus/error.rb +5 -0
  35. data/lib/canopus/fold_map.rb +87 -0
  36. data/lib/canopus/git/blame.rb +50 -0
  37. data/lib/canopus/git/commit.rb +7 -0
  38. data/lib/canopus/git/corrupt_object.rb +7 -0
  39. data/lib/canopus/git/diff.rb +131 -0
  40. data/lib/canopus/git/index.rb +94 -0
  41. data/lib/canopus/git/object_database.rb +45 -0
  42. data/lib/canopus/git/pack.rb +186 -0
  43. data/lib/canopus/git/repository.rb +313 -0
  44. data/lib/canopus/git/status.rb +74 -0
  45. data/lib/canopus/git/tree_entry.rb +7 -0
  46. data/lib/canopus/git.rb +15 -0
  47. data/lib/canopus/icon_theme.rb +43 -0
  48. data/lib/canopus/language/background_analysis/job.rb +12 -0
  49. data/lib/canopus/language/background_analysis/scheduler.rb +70 -0
  50. data/lib/canopus/language/background_analysis.rb +280 -0
  51. data/lib/canopus/language/definition.rb +7 -0
  52. data/lib/canopus/language/document.rb +143 -0
  53. data/lib/canopus/language/symbol.rb +7 -0
  54. data/lib/canopus/language/syntax_worker.rb +91 -0
  55. data/lib/canopus/language.rb +42 -0
  56. data/lib/canopus/lazy_rope.rb +218 -0
  57. data/lib/canopus/lsp/client.rb +299 -0
  58. data/lib/canopus/lsp/error.rb +7 -0
  59. data/lib/canopus/lsp/future/subscription.rb +9 -0
  60. data/lib/canopus/lsp/future.rb +87 -0
  61. data/lib/canopus/lsp/protocol.rb +83 -0
  62. data/lib/canopus/lsp/server_error.rb +13 -0
  63. data/lib/canopus/lsp/timeout.rb +7 -0
  64. data/lib/canopus/lsp/transport.rb +123 -0
  65. data/lib/canopus/lsp.rb +19 -0
  66. data/lib/canopus/markdown.rb +69 -0
  67. data/lib/canopus/match_data_compat.rb +17 -0
  68. data/lib/canopus/multi_buffer.rb +266 -0
  69. data/lib/canopus/pane.rb +61 -0
  70. data/lib/canopus/patch/composite.rb +12 -0
  71. data/lib/canopus/patch/reload.rb +18 -0
  72. data/lib/canopus/patch.rb +27 -0
  73. data/lib/canopus/performance_recorder.rb +207 -0
  74. data/lib/canopus/plugins/api.rb +50 -0
  75. data/lib/canopus/plugins/isolated_runtime.rb +167 -0
  76. data/lib/canopus/plugins/local_runtime.rb +25 -0
  77. data/lib/canopus/plugins/permission_denied.rb +7 -0
  78. data/lib/canopus/plugins/registry.rb +30 -0
  79. data/lib/canopus/plugins.rb +11 -0
  80. data/lib/canopus/project/ignore_matcher.rb +104 -0
  81. data/lib/canopus/project/search.rb +164 -0
  82. data/lib/canopus/project/search_worker/cancelled.rb +3 -0
  83. data/lib/canopus/project/search_worker/runner.rb +9 -0
  84. data/lib/canopus/project/search_worker.rb +108 -0
  85. data/lib/canopus/project/tree.rb +48 -0
  86. data/lib/canopus/project/watcher.rb +59 -0
  87. data/lib/canopus/project.rb +127 -0
  88. data/lib/canopus/regexp_compat.rb +30 -0
  89. data/lib/canopus/save_conflict.rb +5 -0
  90. data/lib/canopus/selection.rb +11 -0
  91. data/lib/canopus/settings.rb +118 -0
  92. data/lib/canopus/snippet/transform.rb +209 -0
  93. data/lib/canopus/snippet.rb +183 -0
  94. data/lib/canopus/tab_map.rb +30 -0
  95. data/lib/canopus/terminal/cell.rb +7 -0
  96. data/lib/canopus/terminal/grid.rb +321 -0
  97. data/lib/canopus/terminal/pty.rb +103 -0
  98. data/lib/canopus/terminal/scrollback.rb +38 -0
  99. data/lib/canopus/terminal/vt.rb +398 -0
  100. data/lib/canopus/terminal.rb +13 -0
  101. data/lib/canopus/theme.rb +43 -0
  102. data/lib/canopus/version.rb +5 -0
  103. data/lib/canopus/vim/commandable.rb +148 -0
  104. data/lib/canopus/vim/motionable.rb +321 -0
  105. data/lib/canopus/vim/operator_capable.rb +377 -0
  106. data/lib/canopus/vim/text_object_selectable.rb +141 -0
  107. data/lib/canopus/vim.rb +426 -0
  108. data/lib/canopus/workspace/edit/executable.rb +139 -0
  109. data/lib/canopus/workspace/edit/node.rb +8 -0
  110. data/lib/canopus/workspace/edit/plan.rb +170 -0
  111. data/lib/canopus/workspace/edit/resource_preparable.rb +98 -0
  112. data/lib/canopus/workspace/edit.rb +6 -0
  113. data/lib/canopus/workspace/file_change_aware.rb +40 -0
  114. data/lib/canopus/workspace/file_previewable.rb +31 -0
  115. data/lib/canopus/workspace/git_aware.rb +140 -0
  116. data/lib/canopus/workspace/language_aware.rb +413 -0
  117. data/lib/canopus/workspace/language_server_configurable.rb +181 -0
  118. data/lib/canopus/workspace/project_searchable.rb +208 -0
  119. data/lib/canopus/workspace/project_tree_editable.rb +82 -0
  120. data/lib/canopus/workspace/session_persistable.rb +153 -0
  121. data/lib/canopus/workspace/settings_aware.rb +91 -0
  122. data/lib/canopus/workspace/view/terminal_presentable.rb +185 -0
  123. data/lib/canopus/workspace/view.rb +643 -0
  124. data/lib/canopus/workspace.rb +525 -0
  125. data/lib/canopus/wrap_map.rb +108 -0
  126. data/lib/canopus.rb +24 -0
  127. data/sig/canopus.rbs +375 -0
  128. data/sig/controller.rbs +55 -0
  129. data/sig/display_stages.rbs +49 -0
  130. data/sig/git.rbs +140 -0
  131. data/sig/lsp.rbs +99 -0
  132. data/sig/markdown.rbs +11 -0
  133. data/sig/performance_recorder.rbs +23 -0
  134. data/sig/search_services.rbs +8 -0
  135. data/sig/services.rbs +81 -0
  136. data/sig/snippet.rbs +42 -0
  137. data/sig/terminal.rbs +125 -0
  138. data/sig/vim.rbs +27 -0
  139. data/sig/workspace_edits.rbs +11 -0
  140. data/sig/workspace_services.rbs +117 -0
  141. data/tools/certify_snippet_regex.rb +35 -0
  142. data/tools/check_dependencies.rb +80 -0
  143. data/tools/native_check.rb +70 -0
  144. data/tools/package.rb +113 -0
  145. data/tools/package_test.rb +32 -0
  146. metadata +314 -0
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+ require_relative "../edit"
5
+ require_relative "node"
6
+ require_relative "resource_preparable"
7
+ require_relative "executable"
8
+
9
+ module Canopus
10
+ module Workspace::Edit
11
+ # Resource edits are uncommon and explicitly confirmed. No filesystem mutation
12
+ # happens until every ordered resource/text operation has passed preflight.
13
+ class Plan
14
+ include ResourcePreparable
15
+ include Executable
16
+
17
+ def initialize(workspace, edit)
18
+ @workspace, @root = workspace, workspace.root
19
+ @nodes, @observed, @originals = {}, {}, {}
20
+ @operations, @new_buffers, @recoveries, @journal = [], [], [], []
21
+ @text_bytes = @edit_count = 0
22
+ changes = edit.fetch("documentChanges")
23
+ raise Error, "invalid documentChanges" unless changes.is_a?(Array) && changes.length <= 10_000
24
+ changes.each_with_index do |change, index|
25
+ @failed_change = index
26
+ raise Error, "invalid workspace change" unless change.is_a?(Hash)
27
+ change["kind"] ? prepare_resource(change, index) : prepare_text(change, index)
28
+ end
29
+ @failed_change = nil
30
+ rescue StandardError
31
+ close
32
+ raise
33
+ end
34
+
35
+ def close
36
+ @new_buffers.each { |buffer| buffer.close unless @workspace.buffers.value?(buffer) }
37
+ end
38
+
39
+ private
40
+
41
+ def local_path(uri)
42
+ raise Error, "resource URI is too long" unless uri.is_a?(String) && uri.bytesize <= 16_384
43
+ path = File.expand_path(LSP::Protocol.path(uri))
44
+ raise Error, "resource path is too long" if path.bytesize > 4_096
45
+ unless path.start_with?(@root + File::SEPARATOR)
46
+ parent, pieces = File.dirname(path), [File.basename(path)]
47
+ until File.directory?(parent)
48
+ pieces.unshift(File.basename(parent))
49
+ parent = File.dirname(parent)
50
+ end
51
+ path = File.join(File.realpath(parent), *pieces)
52
+ end
53
+ raise Error, "language server resource is outside project" unless path.start_with?(@root + File::SEPARATOR)
54
+ pieces = path.delete_prefix(@root + File::SEPARATOR).split(File::SEPARATOR)
55
+ raise Error, "language server cannot modify Git metadata or recovery files" if protected_path?(path)
56
+ parent = @root
57
+ pieces[0...-1].each do |piece|
58
+ parent = File.join(parent, piece)
59
+ node = node_at(parent)
60
+ raise Error, "resource parent does not exist or follows a symlink" unless node&.exists && node.kind == :directory
61
+ end
62
+ path
63
+ end
64
+
65
+ def protected_path?(path)
66
+ pieces = path.delete_prefix(@root + File::SEPARATOR).split(File::SEPARATOR).map(&:downcase)
67
+ pieces.include?(".git") || pieces.each_cons(2).any? { |pair| pair == %w[.canopus trash] }
68
+ end
69
+
70
+ def node_at(path)
71
+ return @nodes[path] if @nodes.key?(path)
72
+ raise Error, "workspace edit exceeds 100000 filesystem entries" if @nodes.length >= 100_000
73
+ stat = File.lstat(path) rescue nil
74
+ buffer = @workspace.buffers[path]
75
+ @observed[path] = fingerprint(path)
76
+ return @nodes[path] = nil unless stat || buffer
77
+ kind = if stat&.symlink? then :symlink
78
+ elsif stat&.directory? then :directory
79
+ elsif !stat || stat.file? then :file
80
+ else :special
81
+ end
82
+ raise Error, "special files are not workspace edit targets" if kind == :special
83
+ @nodes[path] = Node.new(origin: stat && path, path: path, kind: kind, exists: !!stat, buffer: buffer, edits: [])
84
+ end
85
+
86
+ def fingerprint(path)
87
+ stat = File.lstat(path)
88
+ [stat.dev, stat.ino, stat.mode, stat.size, stat.mtime, stat.ctime]
89
+ rescue Errno::ENOENT, Errno::ENOTDIR
90
+ nil
91
+ end
92
+
93
+ def subtree(path)
94
+ first = node_at(path)
95
+ return [] unless first
96
+ pending, result, seen = [first], [], {}
97
+ until pending.empty?
98
+ node = pending.pop
99
+ next if seen[node.object_id]
100
+ seen[node.object_id] = true
101
+ result << node
102
+ raise Error, "resource operation exceeds 100000 entries" if result.length > 100_000
103
+ if node.kind == :directory && node.origin
104
+ Dir.children(node.origin).each do |name|
105
+ child_path = File.join(node.path, name)
106
+ raise Error, "resource operation includes protected metadata" if protected_path?(child_path)
107
+ unless @nodes.key?(child_path)
108
+ origin = File.join(node.origin, name)
109
+ child = node_at(origin)
110
+ @nodes[child_path] = child
111
+ child.path = child_path if child
112
+ end
113
+ pending << @nodes[child_path] if @nodes[child_path]
114
+ end
115
+ end
116
+ end
117
+ # Include unsaved documents under a directory even if they have no disk entry.
118
+ @workspace.buffers.keys.grep(String).each do |candidate|
119
+ next unless candidate.start_with?(path + File::SEPARATOR)
120
+ node = node_at(candidate)
121
+ result << node if node && !seen[node.object_id]
122
+ seen[node.object_id] = true if node
123
+ end
124
+ @nodes.each_value do |node|
125
+ next unless node && node.path.start_with?(path + File::SEPARATOR) && !seen[node.object_id]
126
+ result << node
127
+ seen[node.object_id] = true
128
+ end
129
+ result
130
+ end
131
+
132
+ def buffer_for(node)
133
+ return node.buffer if node.rope
134
+ unless node.buffer
135
+ node.buffer = node.origin ? Buffer.open(node.origin) : Buffer.new("", path: node.path)
136
+ @new_buffers << node.buffer
137
+ end
138
+ buffer = node.buffer
139
+ @originals[buffer] ||= [buffer.rope, buffer.version, buffer.path]
140
+ node.rope, node.version = buffer.rope, buffer.version
141
+ buffer
142
+ end
143
+
144
+ def prepare_text(change, index)
145
+ document, edits = change.values_at("textDocument", "edits")
146
+ raise Error, "invalid text document edit" unless document.is_a?(Hash) && edits.is_a?(Array)
147
+ path = local_path(document.fetch("uri"))
148
+ node = node_at(path)
149
+ raise Error, "text edit target does not exist or is a symlink" unless node && node.kind == :file
150
+ buffer = buffer_for(node)
151
+ raise Error, "cannot edit a read-only document" if buffer.read_only
152
+ version = document["version"]
153
+ raise Error, "language server edit has stale document version" unless version.nil? || (version.is_a?(Integer) && version == node.version)
154
+ changes = edits.map do |entry|
155
+ unless entry.is_a?(Hash) && entry["range"].is_a?(Hash) && entry["newText"].is_a?(String) && entry["newText"].valid_encoding?
156
+ raise Error, "invalid LSP text edit"
157
+ end
158
+ @text_bytes += entry["newText"].bytesize
159
+ @edit_count += 1
160
+ raise Error, "workspace edit text exceeds safety limits" if @text_bytes > (32 << 20) || @edit_count > 100_000
161
+ range = entry.fetch("range")
162
+ [LSP::Protocol.offset(node.rope, range.fetch("start"))...LSP::Protocol.offset(node.rope, range.fetch("end")), entry.fetch("newText")]
163
+ end
164
+ node.rope = node.rope.apply_edits(changes)
165
+ node.version += 1 unless changes.empty?
166
+ node.edits << [changes, index] unless changes.empty?
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canopus
4
+ module Workspace::Edit
5
+ module ResourcePreparable
6
+ private
7
+
8
+ def prepare_resource(change, index)
9
+ options = change.fetch("options", {})
10
+ unless options.is_a?(Hash) && options.values.all? { |value| value == true || value == false }
11
+ raise Error, "invalid resource operation options"
12
+ end
13
+ case change["kind"]
14
+ when "create" then prepare_create(change, options, index)
15
+ when "rename" then prepare_rename(change, options, index)
16
+ when "delete" then prepare_delete(change, options, index)
17
+ else raise Error, "unknown resource operation"
18
+ end
19
+ end
20
+
21
+ def protect_buffers(nodes, destination: false)
22
+ nodes.each do |node|
23
+ buffer = node.buffer
24
+ @originals[buffer] ||= [buffer.rope, buffer.version, buffer.path] if buffer
25
+ raise Error, "save or discard changes before deleting or overwriting a resource" if !node.edits.empty? || buffer&.dirty? || buffer&.read_only
26
+ raise Error, "close the destination document before overwriting it" if destination && buffer
27
+ if buffer && @workspace.buffers.values.grep(MultiBuffer).any? { |multi| multi.excerpts.any? { |excerpt| excerpt.buffer.equal?(buffer) } }
28
+ raise Error, "close excerpt views before deleting a source resource"
29
+ end
30
+ end
31
+ end
32
+
33
+ def prepare_create(change, options, index)
34
+ path = local_path(change.fetch("uri"))
35
+ previous = node_at(path)
36
+ return if previous&.exists && options["ignoreIfExists"] && !options["overwrite"]
37
+ raise Error, "resource already exists" if previous&.exists && !options["overwrite"]
38
+ raise Error, "cannot replace a directory or symlink with a file" if previous&.exists && previous.kind != :file
39
+ protect_buffers([previous].compact)
40
+ # A trailing slash explicitly denotes a directory URI.
41
+ kind = change.fetch("uri").end_with?("/") ? :directory : :file
42
+ raise Error, "cannot replace a file with a directory" if kind == :directory && previous&.exists
43
+ node = Node.new(path: path, kind: kind, exists: true, buffer: previous&.buffer, edits: [])
44
+ if node.buffer
45
+ buffer_for(node)
46
+ node.reset = true
47
+ node.version += 1 unless node.rope.empty?
48
+ node.rope = Denebola::Rope.new("")
49
+ end
50
+ @nodes[path] = node
51
+ @operations << {kind: :create, path: path, directory: kind == :directory, backup: !!previous&.exists, index: index}
52
+ end
53
+
54
+ def prepare_rename(change, options, index)
55
+ source, target = local_path(change.fetch("oldUri")), local_path(change.fetch("newUri"))
56
+ node = node_at(source)
57
+ raise Error, "rename source does not exist" unless node&.exists
58
+ if source == target || target.start_with?(source + File::SEPARATOR) || source.start_with?(target + File::SEPARATOR)
59
+ raise Error, "cannot move a resource into itself or overwrite its ancestor"
60
+ end
61
+ previous = node_at(target)
62
+ return if previous&.exists && options["ignoreIfExists"] && !options["overwrite"]
63
+ raise Error, "rename destination exists" if previous&.exists && !options["overwrite"]
64
+ destination_nodes = subtree(target)
65
+ protect_buffers(destination_nodes, destination: true)
66
+ moving = subtree(source)
67
+ moving.each do |entry|
68
+ buffer_for(entry) if entry.buffer
69
+ destination = target + entry.path.delete_prefix(source)
70
+ other = @nodes[destination] || @workspace.buffers[destination]
71
+ raise Error, "rename destination has an open document" if other && !destination_nodes.include?(other) && !moving.include?(other)
72
+ end
73
+ destination_nodes.each { |entry| @nodes[entry.path] = nil }
74
+ moving.each do |entry|
75
+ old = entry.path
76
+ entry.path = target + old.delete_prefix(source)
77
+ @nodes[old] = nil
78
+ @nodes[entry.path] = entry
79
+ end
80
+ @operations << {kind: :rename, source: source, path: target, backup: !!previous&.exists, index: index}
81
+ end
82
+
83
+ def prepare_delete(change, options, index)
84
+ path = local_path(change.fetch("uri"))
85
+ node = node_at(path)
86
+ return if !node&.exists && options["ignoreIfNotExists"]
87
+ raise Error, "delete target does not exist" unless node&.exists
88
+ removed = subtree(path)
89
+ if node.kind == :directory && removed.any? { |entry| entry != node && entry.exists } && !options["recursive"]
90
+ raise Error, "recursive deletion must be explicit for nonempty directories"
91
+ end
92
+ protect_buffers(removed)
93
+ removed.each { |entry| @nodes[entry.path] = nil }
94
+ @operations << {kind: :delete, path: path, nodes: removed, index: index}
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canopus
4
+ module Workspace::Edit
5
+ end
6
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canopus
4
+ module Workspace::FileChangeAware
5
+ def start_watching
6
+ return if @watcher || !@project
7
+ native = begin
8
+ Zaniah::Platform.watch(@root)
9
+ rescue LoadError, Zaniah::Error, SystemCallError
10
+ nil
11
+ end
12
+ @watcher = @project.watcher(native: native)
13
+ @last_watch = 0
14
+ end
15
+ def poll_changes
16
+ return unless @watcher
17
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
18
+ return if now - @last_watch < (@watcher.backend == :native ? 0.05 : 1.0)
19
+ @last_watch = now
20
+ events = @watcher.poll
21
+ invalidate_git unless events.empty?
22
+ refresh_files unless events.empty?
23
+ events.each do |event|
24
+ path = File.expand_path(event.path, @root)
25
+ buffer = @buffers[path]
26
+ next unless buffer
27
+ if event.type == :deleted
28
+ @message = "File was deleted on disk: #{event.path}"
29
+ elsif buffer.dirty?
30
+ @message = "File changed on disk; unsaved edits preserved: #{event.path}"
31
+ else
32
+ buffer.reload
33
+ end
34
+ rescue SaveConflict, SystemCallError, Error => error
35
+ @message = error.message
36
+ end
37
+ @window&.request_frame unless events.empty?
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canopus
4
+ module Workspace::FilePreviewable
5
+ # A preview never creates a tab, loads a complete file, or changes selections.
6
+ def file_preview(path)
7
+ absolute = canonical_path(path)
8
+ buffer = @buffers[absolute]
9
+ stamp = buffer ? buffer.version : [File.mtime(absolute), File.size(absolute)]
10
+ key = [absolute, stamp]
11
+ @file_previews ||= {}
12
+ return @file_previews[key] if @file_previews.key?(key)
13
+ source = if buffer
14
+ ending = [16_384, buffer.rope.bytesize].min
15
+ begin
16
+ buffer.rope.byteslice(0, ending).to_s
17
+ rescue RangeError
18
+ ending -= 1
19
+ retry
20
+ end
21
+ else
22
+ File.binread(absolute, 16_384).force_encoding(Encoding::UTF_8).scrub
23
+ end
24
+ rows = source.include?("\0") ? ["Binary / UTF-16 preview unavailable"] : source.lines.first(40).map { |line| line.chomp[0, 300] }
25
+ @file_previews.shift if @file_previews.length >= 16
26
+ @file_previews[key] = rows.freeze
27
+ rescue SystemCallError, Error => error
28
+ ["Preview unavailable: #{error.message}"]
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canopus
4
+ module Workspace::GitAware
5
+ def git
6
+ return @git if defined?(@git)
7
+ @git = Git::Repository.new(@root)
8
+ @git = nil unless @git.root
9
+ rescue ArgumentError
10
+ @git = nil
11
+ end
12
+ def git_status
13
+ return @git_status if @git_status
14
+ return {} unless git
15
+ if @window
16
+ unless @git_status_job&.alive?
17
+ generation = @git_generation
18
+ @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
21
+ rescue StandardError => error
22
+ post { @message = "Git status: #{error.message}" } unless @closed
23
+ end
24
+ end
25
+ {}
26
+ else
27
+ @git_status = git.status.to_h { |entry| [entry.path, entry.code] }
28
+ end
29
+ end
30
+ def invalidate_git
31
+ @git_generation = (@git_generation || 0) + 1
32
+ @git_status = @git_hunk_cache = nil
33
+ @panes.each do |pane|
34
+ pane.editors.each do |current|
35
+ current.display_map.block_map.blocks.values.each { |block| current.display_map.remove_block(block.id) if block.kind == :git_diff }
36
+ end
37
+ end
38
+ nil
39
+ end
40
+ def poll_git_changes(now: Process.clock_gettime(Process::CLOCK_MONOTONIC))
41
+ return if @last_git_poll && now - @last_git_poll < 1
42
+ @last_git_poll = now
43
+ return unless git
44
+ index = File.join(git.git_dir, "index")
45
+ stat = File.stat(index) if File.file?(index)
46
+ state = [git.head, git.branch, stat&.mtime, stat&.size, stat&.ino]
47
+ return if @git_state == state
48
+ initial = !defined?(@git_state)
49
+ @git_state = state
50
+ return if initial
51
+ invalidate_git
52
+ @window&.request_frame
53
+ rescue SystemCallError, ArgumentError => error
54
+ @message = "Git: #{error.message}"
55
+ end
56
+ def git_relative_path(buffer = editor.buffer)
57
+ raise Error, "Current document is not in a Git repository" unless git && buffer.path && buffer.path.start_with?(git.root + File::SEPARATOR)
58
+ buffer.path.delete_prefix(git.root + File::SEPARATOR)
59
+ end
60
+ def git_hunks(buffer = editor.buffer, async: true)
61
+ return [] unless git && buffer.path && buffer.path.start_with?(git.root + File::SEPARATOR) && buffer.rope.bytesize < 10 << 20
62
+ path = git_relative_path(buffer)
63
+ @git_hunk_cache ||= {}
64
+ key = [buffer.object_id, path, buffer.version, git.head]
65
+ @git_hunk_cache.clear if @git_hunk_cache.length > 20
66
+ return @git_hunk_cache[key] if @git_hunk_cache.key?(key)
67
+ if @window && async
68
+ @git_hunk_jobs ||= {}
69
+ unless @git_hunk_jobs[buffer]&.alive?
70
+ snapshot = buffer.rope
71
+ @git_hunk_jobs[buffer] = Thread.new do
72
+ before = Buffer.decode_bytes(git.blob(path, reference: key.last || "HEAD").to_s).first
73
+ hunks = Git::Diff.hunks(before, snapshot.to_s, context: 0)
74
+ post { (@git_hunk_cache ||= {})[key] = hunks } unless @closed
75
+ rescue StandardError => error
76
+ post { @message = "Git diff: #{error.message}" } unless @closed
77
+ end
78
+ end
79
+ []
80
+ else
81
+ before = Buffer.decode_bytes(git.blob(path).to_s).first
82
+ @git_hunk_cache[key] = Git::Diff.hunks(before, buffer.text, context: 0)
83
+ end
84
+ end
85
+ def show_git_diff
86
+ path = git_relative_path
87
+ before = Buffer.decode_bytes(git.blob(path).to_s).first
88
+ buffer = Buffer.new(Git::Diff.unified(before, editor.buffer.text, old_name: "a/#{path}", new_name: "b/#{path}"), read_only: true)
89
+ @buffers[buffer.object_id] = buffer
90
+ @active_pane.open(buffer).language = Language::Definition.new("diff", "diff", [], "", /\A\z/, /\A\z/, [])
91
+ end
92
+ def toggle_git_hunk(current = editor, row: nil)
93
+ row ||= current.buffer.rope.point_at(current.primary.head).row
94
+ hunk = git_hunks(current.buffer, async: false).find do |item|
95
+ (row + 1).between?([item.new_start, 1].max, [item.new_start + item.new_count - 1, item.new_start].max)
96
+ end
97
+ raise Error, "No change at the cursor" unless hunk
98
+ id = [:git_hunk, current.buffer.path, current.buffer.version, git.head, hunk.old_start, hunk.new_start]
99
+ map = current.display_map
100
+ if map.block_map.blocks.key?(id)
101
+ map.remove_block(id)
102
+ expanded = false
103
+ else
104
+ # Keep inline previews bounded; the full diff remains available in its
105
+ # own document for large hunks and unusually long source lines.
106
+ lines = ["@@ -#{hunk.old_start},#{hunk.old_count} +#{hunk.new_start},#{hunk.new_count} @@"]
107
+ hunk.edits.first(200).each do |edit|
108
+ body = edit.text.delete_suffix("\n").delete_suffix("\r")
109
+ lines << "#{edit.kind == :delete ? '-' : edit.kind == :insert ? '+' : ' '}#{body.slice(0, 500)}#{body.length > 500 ? '…' : ''}"
110
+ end
111
+ lines << "… #{hunk.edits.length - 200} more lines; open git.diff for the full change" if hunk.edits.length > 200
112
+ map.insert_block(id, row: [hunk.new_start - 1, 0].max.clamp(0, current.buffer.line_count - 1), text: lines.join("\n"), kind: :git_diff)
113
+ expanded = true
114
+ end
115
+ @window&.request_frame
116
+ expanded
117
+ end
118
+ def show_git_blame
119
+ path = git_relative_path
120
+ lines = git.blame(path)
121
+ @hover_card = lines.map { |line| "#{line.commit.to_s[0, 8]} #{line.author} #{line.text}" }.join
122
+ end
123
+ def revert_current_hunk
124
+ row = editor.buffer.rope.point_at(editor.primary.head).row + 1
125
+ hunk = git_hunks(async: false).find { |item| row.between?([item.new_start, 1].max, [item.new_start + item.new_count - 1, item.new_start].max) }
126
+ raise Error, "No change at the cursor" unless hunk
127
+ replacement = Git::Diff.revert(editor.buffer.text, hunk)
128
+ editor.buffer.edit([[0...editor.buffer.rope.bytesize, replacement]], kind: :revert_hunk)
129
+ end
130
+ def checkout_branch(name)
131
+ raise Error, "Save or discard all buffer changes before switching branches" if @buffers.values.any?(&:dirty?)
132
+ raise Error, "Not a Git repository" unless git
133
+ git.checkout(name)
134
+ invalidate_git
135
+ refresh_files
136
+ @buffers.values.each { |buffer| buffer.reload if buffer.path && File.file?(buffer.path) && !buffer.read_only }
137
+ @message = "Switched to #{name}"
138
+ end
139
+ end
140
+ end