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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +148 -0
- data/docs/adr/001-runtime-gem-components.md +24 -0
- data/docs/adr/002-bounded-diff-engine.md +22 -0
- data/docs/adr/003-background-language-analysis.md +22 -0
- data/docs/adr/004-persistent-display-map.md +23 -0
- data/docs/adr/005-windows-runtime.md +27 -0
- data/docs/adr/README.md +36 -0
- data/docs/distribution.md +27 -0
- data/docs/lsp.md +38 -0
- data/docs/performance.md +40 -0
- data/docs/snippets.md +32 -0
- data/docs/vim.md +24 -0
- data/docs/workspace_edits.md +32 -0
- data/examples/native_smoke.rb +25 -0
- data/examples/plugins/word_count.rb +11 -0
- data/exe/canopus +19 -0
- data/lib/canopus/block_map.rb +19 -0
- data/lib/canopus/buffer.rb +293 -0
- data/lib/canopus/cli.rb +171 -0
- data/lib/canopus/controller.rb +507 -0
- data/lib/canopus/data_compat.rb +25 -0
- data/lib/canopus/display_map/line_builder.rb +54 -0
- data/lib/canopus/display_map/line_set.rb +8 -0
- data/lib/canopus/display_map/pending_line_set.rb +9 -0
- data/lib/canopus/display_map/summary.rb +23 -0
- data/lib/canopus/display_map/worker.rb +89 -0
- data/lib/canopus/display_map.rb +350 -0
- data/lib/canopus/display_point.rb +5 -0
- data/lib/canopus/editor/snippet_expandable.rb +281 -0
- data/lib/canopus/editor.rb +445 -0
- data/lib/canopus/error.rb +5 -0
- data/lib/canopus/fold_map.rb +87 -0
- data/lib/canopus/git/blame.rb +50 -0
- data/lib/canopus/git/commit.rb +7 -0
- data/lib/canopus/git/corrupt_object.rb +7 -0
- data/lib/canopus/git/diff.rb +131 -0
- data/lib/canopus/git/index.rb +94 -0
- data/lib/canopus/git/object_database.rb +45 -0
- data/lib/canopus/git/pack.rb +186 -0
- data/lib/canopus/git/repository.rb +313 -0
- data/lib/canopus/git/status.rb +74 -0
- data/lib/canopus/git/tree_entry.rb +7 -0
- data/lib/canopus/git.rb +15 -0
- data/lib/canopus/icon_theme.rb +43 -0
- data/lib/canopus/language/background_analysis/job.rb +12 -0
- data/lib/canopus/language/background_analysis/scheduler.rb +70 -0
- data/lib/canopus/language/background_analysis.rb +280 -0
- data/lib/canopus/language/definition.rb +7 -0
- data/lib/canopus/language/document.rb +143 -0
- data/lib/canopus/language/symbol.rb +7 -0
- data/lib/canopus/language/syntax_worker.rb +91 -0
- data/lib/canopus/language.rb +42 -0
- data/lib/canopus/lazy_rope.rb +218 -0
- data/lib/canopus/lsp/client.rb +299 -0
- data/lib/canopus/lsp/error.rb +7 -0
- data/lib/canopus/lsp/future/subscription.rb +9 -0
- data/lib/canopus/lsp/future.rb +87 -0
- data/lib/canopus/lsp/protocol.rb +83 -0
- data/lib/canopus/lsp/server_error.rb +13 -0
- data/lib/canopus/lsp/timeout.rb +7 -0
- data/lib/canopus/lsp/transport.rb +123 -0
- data/lib/canopus/lsp.rb +19 -0
- data/lib/canopus/markdown.rb +69 -0
- data/lib/canopus/match_data_compat.rb +17 -0
- data/lib/canopus/multi_buffer.rb +266 -0
- data/lib/canopus/pane.rb +61 -0
- data/lib/canopus/patch/composite.rb +12 -0
- data/lib/canopus/patch/reload.rb +18 -0
- data/lib/canopus/patch.rb +27 -0
- data/lib/canopus/performance_recorder.rb +207 -0
- data/lib/canopus/plugins/api.rb +50 -0
- data/lib/canopus/plugins/isolated_runtime.rb +167 -0
- data/lib/canopus/plugins/local_runtime.rb +25 -0
- data/lib/canopus/plugins/permission_denied.rb +7 -0
- data/lib/canopus/plugins/registry.rb +30 -0
- data/lib/canopus/plugins.rb +11 -0
- data/lib/canopus/project/ignore_matcher.rb +104 -0
- data/lib/canopus/project/search.rb +164 -0
- data/lib/canopus/project/search_worker/cancelled.rb +3 -0
- data/lib/canopus/project/search_worker/runner.rb +9 -0
- data/lib/canopus/project/search_worker.rb +108 -0
- data/lib/canopus/project/tree.rb +48 -0
- data/lib/canopus/project/watcher.rb +59 -0
- data/lib/canopus/project.rb +127 -0
- data/lib/canopus/regexp_compat.rb +30 -0
- data/lib/canopus/save_conflict.rb +5 -0
- data/lib/canopus/selection.rb +11 -0
- data/lib/canopus/settings.rb +118 -0
- data/lib/canopus/snippet/transform.rb +209 -0
- data/lib/canopus/snippet.rb +183 -0
- data/lib/canopus/tab_map.rb +30 -0
- data/lib/canopus/terminal/cell.rb +7 -0
- data/lib/canopus/terminal/grid.rb +321 -0
- data/lib/canopus/terminal/pty.rb +103 -0
- data/lib/canopus/terminal/scrollback.rb +38 -0
- data/lib/canopus/terminal/vt.rb +398 -0
- data/lib/canopus/terminal.rb +13 -0
- data/lib/canopus/theme.rb +43 -0
- data/lib/canopus/version.rb +5 -0
- data/lib/canopus/vim/commandable.rb +148 -0
- data/lib/canopus/vim/motionable.rb +321 -0
- data/lib/canopus/vim/operator_capable.rb +377 -0
- data/lib/canopus/vim/text_object_selectable.rb +141 -0
- data/lib/canopus/vim.rb +426 -0
- data/lib/canopus/workspace/edit/executable.rb +139 -0
- data/lib/canopus/workspace/edit/node.rb +8 -0
- data/lib/canopus/workspace/edit/plan.rb +170 -0
- data/lib/canopus/workspace/edit/resource_preparable.rb +98 -0
- data/lib/canopus/workspace/edit.rb +6 -0
- data/lib/canopus/workspace/file_change_aware.rb +40 -0
- data/lib/canopus/workspace/file_previewable.rb +31 -0
- data/lib/canopus/workspace/git_aware.rb +140 -0
- data/lib/canopus/workspace/language_aware.rb +413 -0
- data/lib/canopus/workspace/language_server_configurable.rb +181 -0
- data/lib/canopus/workspace/project_searchable.rb +208 -0
- data/lib/canopus/workspace/project_tree_editable.rb +82 -0
- data/lib/canopus/workspace/session_persistable.rb +153 -0
- data/lib/canopus/workspace/settings_aware.rb +91 -0
- data/lib/canopus/workspace/view/terminal_presentable.rb +185 -0
- data/lib/canopus/workspace/view.rb +643 -0
- data/lib/canopus/workspace.rb +525 -0
- data/lib/canopus/wrap_map.rb +108 -0
- data/lib/canopus.rb +24 -0
- data/sig/canopus.rbs +375 -0
- data/sig/controller.rbs +55 -0
- data/sig/display_stages.rbs +49 -0
- data/sig/git.rbs +140 -0
- data/sig/lsp.rbs +99 -0
- data/sig/markdown.rbs +11 -0
- data/sig/performance_recorder.rbs +23 -0
- data/sig/search_services.rbs +8 -0
- data/sig/services.rbs +81 -0
- data/sig/snippet.rbs +42 -0
- data/sig/terminal.rbs +125 -0
- data/sig/vim.rbs +27 -0
- data/sig/workspace_edits.rbs +11 -0
- data/sig/workspace_services.rbs +117 -0
- data/tools/certify_snippet_regex.rb +35 -0
- data/tools/check_dependencies.rb +80 -0
- data/tools/native_check.rb +70 -0
- data/tools/package.rb +113 -0
- data/tools/package_test.rb +32 -0
- 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,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
|