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,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Canopus
|
|
4
|
+
module Workspace::ProjectSearchable
|
|
5
|
+
SearchSource = Data.define(:buffer, :version, :rope, :encoding, :bom, :digest, :line_ending, :dirty, :read_only)
|
|
6
|
+
SearchPreparation = Struct.new(:sources, :buffers, :editor, :count, :settings) do
|
|
7
|
+
def close
|
|
8
|
+
editor&.dispose
|
|
9
|
+
editor&.buffer&.close
|
|
10
|
+
buffers.each_value(&:close)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
private_constant :SearchSource, :SearchPreparation
|
|
14
|
+
|
|
15
|
+
# Capture immutable open-buffer snapshots on the foreground. File IO,
|
|
16
|
+
# scanning and the complete private result Editor are prepared off-thread.
|
|
17
|
+
def search_project(query, regexp: false, case_sensitive: true, whole_word: false, async: true)
|
|
18
|
+
raise Error, "Workspace closed" if @closed
|
|
19
|
+
raise Error, "Search query cannot be empty" if query.empty?
|
|
20
|
+
expression = regexp ? query : Regexp.escape(query)
|
|
21
|
+
expression = "\\b(?:#{expression})\\b" if whole_word
|
|
22
|
+
pattern = Regexp.new(expression, case_sensitive ? 0 : Regexp::IGNORECASE, timeout: 0.25)
|
|
23
|
+
cancel_project_search
|
|
24
|
+
@message = "Searching project…" if async
|
|
25
|
+
prepare_project_search(pattern, @search_generation, search_sources, settings: search_editor_settings, async: async)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def cancel_project_search
|
|
29
|
+
prepared = (@search_prepare_lock ||= Mutex.new).synchronize do
|
|
30
|
+
@search_generation = (@search_generation || 0) + 1
|
|
31
|
+
saved, @search_prepared = @search_prepared, nil
|
|
32
|
+
saved
|
|
33
|
+
end
|
|
34
|
+
prepared&.close
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def replace_in_buffer(pattern, replacement, **options)
|
|
39
|
+
if editor.buffer.is_a?(MultiBuffer)
|
|
40
|
+
changes = editor.replacement_edits(pattern, replacement, **options).select do |range, _text|
|
|
41
|
+
editor.buffer.excerpts.any? { |excerpt| range.begin >= excerpt.view_start && range.end <= excerpt.view_end }
|
|
42
|
+
end
|
|
43
|
+
editor.edit(changes, kind: :replace_all)
|
|
44
|
+
changes.length
|
|
45
|
+
else
|
|
46
|
+
editor.replace_all(pattern, replacement, **options)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def search_sources
|
|
53
|
+
@buffers.values.each_with_object({}) do |buffer, sources|
|
|
54
|
+
path = buffer.path
|
|
55
|
+
next unless path&.start_with?(@root + File::SEPARATOR)
|
|
56
|
+
sources[path] = SearchSource.new(buffer, buffer.version, buffer.rope, buffer.encoding,
|
|
57
|
+
buffer.bom, buffer.disk_digest, buffer.line_ending, buffer.dirty?, buffer.read_only)
|
|
58
|
+
end.freeze
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def search_cancelled?(generation) = @closed || generation != @search_generation
|
|
62
|
+
|
|
63
|
+
def search_editor_settings
|
|
64
|
+
settings = @settings.for_language("text")
|
|
65
|
+
[settings["tab_size"], settings["use_tabs"], settings["soft_wrap"]].freeze
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def prepare_project_search(pattern, generation, sources, settings:, paths: nil, async: true)
|
|
69
|
+
work = lambda do
|
|
70
|
+
prepared = nil
|
|
71
|
+
paths ||= @project.search(pattern, workers: 4, limit: 10_000,
|
|
72
|
+
cancelled: -> { search_cancelled?(generation) }).map(&:path).uniq.freeze
|
|
73
|
+
next if search_cancelled?(generation)
|
|
74
|
+
prepared = build_search_preparation(pattern, generation, sources, paths, settings)
|
|
75
|
+
next unless prepared
|
|
76
|
+
if async
|
|
77
|
+
accepted = @search_prepare_lock.synchronize do
|
|
78
|
+
@search_prepared = prepared unless search_cancelled?(generation)
|
|
79
|
+
end
|
|
80
|
+
if accepted
|
|
81
|
+
prepared = nil # Queued callbacks must not retain disposed private buffers.
|
|
82
|
+
queue_search_install(pattern, generation, paths)
|
|
83
|
+
end
|
|
84
|
+
else
|
|
85
|
+
result = install_search_preparation(prepared, pattern, generation, paths, async: false)
|
|
86
|
+
prepared = nil
|
|
87
|
+
result
|
|
88
|
+
end
|
|
89
|
+
rescue StandardError => error
|
|
90
|
+
raise unless async
|
|
91
|
+
queue_search_error(generation, error.message)
|
|
92
|
+
ensure
|
|
93
|
+
prepared&.close
|
|
94
|
+
end
|
|
95
|
+
async ? @search_job = Thread.new(&work) : work.call
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def build_search_preparation(pattern, generation, sources, paths, settings)
|
|
99
|
+
prepared = SearchPreparation.new(sources, {}, nil, 0, settings)
|
|
100
|
+
excerpts = []
|
|
101
|
+
candidates = paths.map { |relative| File.join(@root, relative) } | sources.keys
|
|
102
|
+
candidates.sort.each do |candidate|
|
|
103
|
+
return if search_cancelled?(generation)
|
|
104
|
+
path = sources.key?(candidate) ? candidate : File.realpath(candidate)
|
|
105
|
+
next unless path.start_with?(@root + File::SEPARATOR)
|
|
106
|
+
next if prepared.buffers.key?(path) # Canonical aliases share one source.
|
|
107
|
+
snapshot = sources[path]
|
|
108
|
+
next if snapshot && (snapshot.read_only || snapshot.rope.bytesize > 10 << 20)
|
|
109
|
+
next if !snapshot && File.size(path) > 10 << 20
|
|
110
|
+
buffer = if snapshot
|
|
111
|
+
# Nonempty text supplies the source's original line-ending metadata;
|
|
112
|
+
# rope: shares the immutable snapshot, without materializing its text.
|
|
113
|
+
Buffer.new(snapshot.line_ending, path: path, rope: snapshot.rope, encoding: snapshot.encoding,
|
|
114
|
+
bom: snapshot.bom, disk_digest: snapshot.digest, draft: snapshot.dirty)
|
|
115
|
+
else
|
|
116
|
+
Buffer.open(path)
|
|
117
|
+
end
|
|
118
|
+
prepared.buffers[path] = buffer
|
|
119
|
+
if buffer.read_only || buffer.rope.bytesize > 10 << 20
|
|
120
|
+
prepared.buffers.delete(path)
|
|
121
|
+
buffer.close
|
|
122
|
+
next
|
|
123
|
+
end
|
|
124
|
+
ranges, matches = search_excerpt_ranges(buffer.rope, pattern, 10_000 - prepared.count, generation)
|
|
125
|
+
return if search_cancelled?(generation)
|
|
126
|
+
prepared.count += matches
|
|
127
|
+
relative = path.delete_prefix(@root + File::SEPARATOR)
|
|
128
|
+
ranges.each { |range| excerpts << [buffer, range, "#{relative}:#{buffer.rope.point_at(range.begin).row + 1}"] }
|
|
129
|
+
break if prepared.count >= 10_000
|
|
130
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
131
|
+
next
|
|
132
|
+
end
|
|
133
|
+
projection = MultiBuffer.new(excerpts: excerpts)
|
|
134
|
+
return if search_cancelled?(generation)
|
|
135
|
+
prepared.editor = Editor.new(projection, tab_size: settings[0], wrap_width: settings[2] ? 100 : nil)
|
|
136
|
+
prepared.editor.use_tabs = settings[1]
|
|
137
|
+
result, prepared = prepared, nil
|
|
138
|
+
result
|
|
139
|
+
ensure
|
|
140
|
+
projection&.close if prepared && !prepared.editor
|
|
141
|
+
prepared&.close
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def search_excerpt_ranges(rope, pattern, limit, generation)
|
|
145
|
+
ranges, count = [], 0
|
|
146
|
+
rope.to_s.to_enum(:scan, pattern).each do
|
|
147
|
+
break if search_cancelled?(generation)
|
|
148
|
+
match = Regexp.last_match
|
|
149
|
+
row, last_row = rope.point_at(match.bytebegin(0)).row, rope.point_at(match.byteend(0)).row
|
|
150
|
+
first = rope.line_start([row - 1, 0].max)
|
|
151
|
+
last = last_row + 2 < rope.line_count ? rope.line_start(last_row + 2) : rope.bytesize
|
|
152
|
+
ranges.last && first <= ranges.last.end ? ranges[-1] = ranges.last.begin...[ranges.last.end, last].max : ranges << (first...last)
|
|
153
|
+
count += 1
|
|
154
|
+
break if count >= limit
|
|
155
|
+
end
|
|
156
|
+
[ranges, count]
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def queue_search_install(pattern, generation, paths)
|
|
160
|
+
post do
|
|
161
|
+
prepared = @search_prepare_lock.synchronize do
|
|
162
|
+
next if search_cancelled?(generation)
|
|
163
|
+
saved, @search_prepared = @search_prepared, nil
|
|
164
|
+
saved
|
|
165
|
+
end
|
|
166
|
+
install_search_preparation(prepared, pattern, generation, paths) if prepared
|
|
167
|
+
rescue StandardError => error
|
|
168
|
+
@message = error.message unless search_cancelled?(generation)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def queue_search_error(generation, message)
|
|
173
|
+
post { @message = message unless search_cancelled?(generation) }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def install_search_preparation(prepared, pattern, generation, paths, async: true)
|
|
177
|
+
return if search_cancelled?(generation)
|
|
178
|
+
current = search_sources
|
|
179
|
+
settings = search_editor_settings
|
|
180
|
+
unchanged = settings == prepared.settings && current.keys == prepared.sources.keys && current.all? do |path, source|
|
|
181
|
+
before = prepared.sources.fetch(path)
|
|
182
|
+
source.buffer.equal?(before.buffer) && source.version == before.version && source.rope.equal?(before.rope) && source.read_only == before.read_only
|
|
183
|
+
end
|
|
184
|
+
unless unchanged
|
|
185
|
+
prepared.close
|
|
186
|
+
prepared = nil
|
|
187
|
+
return prepare_project_search(pattern, generation, current, settings: settings, paths: paths, async: async)
|
|
188
|
+
end
|
|
189
|
+
projection = prepared.editor.buffer
|
|
190
|
+
bound = prepared.sources.empty? ? {} : projection.excerpts.to_h { |excerpt| [excerpt.buffer, true] }
|
|
191
|
+
replacements = prepared.sources.each_with_object({}) do |(path, source), result|
|
|
192
|
+
clone = prepared.buffers[path]
|
|
193
|
+
result[clone] = source.buffer if clone && bound.key?(clone)
|
|
194
|
+
end
|
|
195
|
+
projection.attach_sources(replacements)
|
|
196
|
+
prepared.buffers.each { |path, buffer| @buffers[path] ||= buffer unless prepared.sources.key?(path) }
|
|
197
|
+
@buffers[projection.object_id] = projection
|
|
198
|
+
@active_pane.editors << prepared.editor
|
|
199
|
+
@active_pane.activate(@active_pane.editors.length - 1)
|
|
200
|
+
@message = "#{prepared.count} matches — edit excerpts, then Save to save source files"
|
|
201
|
+
@window&.request_frame
|
|
202
|
+
prepared = nil
|
|
203
|
+
projection
|
|
204
|
+
ensure
|
|
205
|
+
prepared&.close
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Canopus
|
|
6
|
+
module Workspace::ProjectTreeEditable
|
|
7
|
+
def project_tree
|
|
8
|
+
files
|
|
9
|
+
@project_tree ||= Project::Tree.new(@project_entries || [], expanded: @expanded_directories ||= Set.new).tap do |tree|
|
|
10
|
+
tree.reveal(editor.buffer.path.delete_prefix(@root + File::SEPARATOR)) if editor&.buffer&.path
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
def project_path(relative)
|
|
14
|
+
path = @project.path(relative)
|
|
15
|
+
parent = path
|
|
16
|
+
parent = File.dirname(parent) until File.exist?(parent) || File.symlink?(parent)
|
|
17
|
+
actual = File.realpath(parent)
|
|
18
|
+
raise Error, "project operation follows a symlink outside the project" unless actual == @root || actual.start_with?(@root + File::SEPARATOR)
|
|
19
|
+
raise Error, "cannot modify project root or Git metadata" if path == @root || path.delete_prefix(@root + "/").split("/").include?(".git")
|
|
20
|
+
path
|
|
21
|
+
end
|
|
22
|
+
def create_project_entry(relative, directory: false)
|
|
23
|
+
path = project_path(relative)
|
|
24
|
+
raise Error, "path already exists" if File.exist?(path) || File.symlink?(path)
|
|
25
|
+
directory ? Dir.mkdir(path) : File.open(path, File::WRONLY | File::CREAT | File::EXCL, 0o644, &:close)
|
|
26
|
+
refresh_files
|
|
27
|
+
open(path) unless directory
|
|
28
|
+
path
|
|
29
|
+
end
|
|
30
|
+
def rename_project_entry(relative, destination)
|
|
31
|
+
source, target = project_path(relative), project_path(destination)
|
|
32
|
+
raise Error, "source does not exist" unless File.exist?(source) || File.symlink?(source)
|
|
33
|
+
raise Error, "destination exists" if File.exist?(target) || File.symlink?(target)
|
|
34
|
+
raise Error, "cannot move a directory inside itself" if target.start_with?(source + File::SEPARATOR)
|
|
35
|
+
if @buffers.values.any? { |buffer| buffer.path && (buffer.path == target || buffer.path.start_with?(target + File::SEPARATOR)) }
|
|
36
|
+
raise Error, "destination is open in another buffer"
|
|
37
|
+
end
|
|
38
|
+
File.rename(source, target)
|
|
39
|
+
invalidate_git
|
|
40
|
+
@buffers.keys.grep(String).each do |old|
|
|
41
|
+
next unless old == source || old.start_with?(source + File::SEPARATOR)
|
|
42
|
+
buffer = @buffers.delete(old)
|
|
43
|
+
new_path = target + old.delete_prefix(source)
|
|
44
|
+
begin
|
|
45
|
+
close_language_documents(buffer)
|
|
46
|
+
rescue StandardError => error
|
|
47
|
+
@message = "Path moved; language server close failed: #{error.message}"
|
|
48
|
+
@opened_lsp_documents&.delete_if { |(_, document), _| document.equal?(buffer) }
|
|
49
|
+
end
|
|
50
|
+
buffer.relocate(new_path)
|
|
51
|
+
@buffers[new_path] = buffer
|
|
52
|
+
@panes.each do |pane|
|
|
53
|
+
pane.editors.each do |current|
|
|
54
|
+
next unless current.buffer.equal?(buffer)
|
|
55
|
+
current.language = definition_for(new_path)
|
|
56
|
+
apply_editor_settings(current)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
refresh_files
|
|
61
|
+
target
|
|
62
|
+
end
|
|
63
|
+
# Deletion is recoverable and never follows directory symlinks.
|
|
64
|
+
def trash_project_entry(relative)
|
|
65
|
+
source = @project.path(relative)
|
|
66
|
+
raise Error, "cannot trash project root or Git metadata" if source == @root || relative.split("/").include?(".git")
|
|
67
|
+
raise Error, "cannot trash the recovery directory" if source == @project.path(".canopus") || source == @project.path(".canopus/trash")
|
|
68
|
+
affected = @buffers.values.select { |buffer| buffer.path && (buffer.path == source || buffer.path.start_with?(source + File::SEPARATOR)) }
|
|
69
|
+
raise Error, "save or discard changes before deleting this path" if affected.any?(&:dirty?)
|
|
70
|
+
parent = File.realpath(File.dirname(source))
|
|
71
|
+
raise Error, "path outside project" unless parent == @root || parent.start_with?(@root + File::SEPARATOR)
|
|
72
|
+
trash = @project.path(".canopus/trash")
|
|
73
|
+
project_path(".canopus/trash")
|
|
74
|
+
FileUtils.mkdir_p(trash)
|
|
75
|
+
destination = File.join(trash, "#{Time.now.utc.strftime('%Y%m%dT%H%M%S')}-#{SecureRandom.hex(4)}-#{File.basename(source)}")
|
|
76
|
+
File.rename(source, destination)
|
|
77
|
+
@message = "Moved to #{destination}; recoverable by moving it back"
|
|
78
|
+
refresh_files
|
|
79
|
+
destination
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Canopus
|
|
4
|
+
module Workspace::SessionPersistable
|
|
5
|
+
def save_session(path)
|
|
6
|
+
records = {}
|
|
7
|
+
record = lambda do |buffer|
|
|
8
|
+
id = buffer.object_id.to_s
|
|
9
|
+
return id if records.key?(id)
|
|
10
|
+
records[id] = if buffer.is_a?(MultiBuffer)
|
|
11
|
+
{excerpts: buffer.excerpts.map do |excerpt|
|
|
12
|
+
{buffer: record.call(excerpt.buffer), first: excerpt.buffer.resolve(excerpt.first),
|
|
13
|
+
last: excerpt.buffer.resolve(excerpt.last), title: excerpt.title}
|
|
14
|
+
end}
|
|
15
|
+
else
|
|
16
|
+
{path: buffer.path, draft: buffer.dirty? || !buffer.path ? buffer.text : nil,
|
|
17
|
+
digest: buffer.disk_digest, encoding: buffer.encoding.name, bom: buffer.bom.unpack1("H*"), read_only: buffer.read_only}
|
|
18
|
+
end
|
|
19
|
+
id
|
|
20
|
+
end
|
|
21
|
+
state = {version: 2, root: @root, layout: encode_layout(@layout), recent_files: @recent_files || [],
|
|
22
|
+
active_pane: @panes.index(@active_pane), docks: @docks,
|
|
23
|
+
panes: @panes.map do |pane|
|
|
24
|
+
{active: pane.active_index, tabs: pane.editors.map do |current|
|
|
25
|
+
{buffer_id: record.call(current.buffer), cursor: current.primary.head, pinned: pane.pinned.include?(current),
|
|
26
|
+
selections: current.selections.map { |selection| {anchor: selection.anchor, head: selection.head} },
|
|
27
|
+
scroll_x: current.scroll_x, scroll_y: current.scroll_y}
|
|
28
|
+
end}
|
|
29
|
+
end, buffers: records}
|
|
30
|
+
state[:background_buffers] = @buffers.values.select(&:dirty?).map { |buffer| record.call(buffer) }
|
|
31
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
32
|
+
Tempfile.create([".session-", ".json"], File.dirname(path)) do |file|
|
|
33
|
+
file.write(JSON.pretty_generate(state))
|
|
34
|
+
file.flush
|
|
35
|
+
file.fsync
|
|
36
|
+
file.close
|
|
37
|
+
File.rename(file.path, path)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def restore_session(path)
|
|
42
|
+
data = JSON.parse(File.read(path))
|
|
43
|
+
raise Error, "unsupported session" unless [1, 2].include?(data["version"]) && data["panes"].is_a?(Array) && data["panes"].length.between?(1, 100)
|
|
44
|
+
records = data.fetch("buffers", {})
|
|
45
|
+
raise Error, "invalid session buffers" unless records.is_a?(Hash) && records.length <= 10_000
|
|
46
|
+
loaded, restored_buffers, restored_panes = {}, {}, []
|
|
47
|
+
load_buffer = lambda do |id|
|
|
48
|
+
return loaded[id] if loaded.key?(id)
|
|
49
|
+
entry = records.fetch(id)
|
|
50
|
+
raise Error, "invalid session buffer" unless entry.is_a?(Hash)
|
|
51
|
+
if entry["excerpts"]
|
|
52
|
+
excerpts = entry["excerpts"]
|
|
53
|
+
raise Error, "invalid session excerpts" unless excerpts.is_a?(Array) && excerpts.length <= 10_000
|
|
54
|
+
sources = excerpts.map do |excerpt|
|
|
55
|
+
source_id = excerpt.fetch("buffer")
|
|
56
|
+
raise Error, "nested or cyclic excerpt source" if records.fetch(source_id).key?("excerpts")
|
|
57
|
+
source = load_buffer.call(source_id)
|
|
58
|
+
raise Error, "excerpt source no longer exists" unless source
|
|
59
|
+
first, last = excerpt.values_at("first", "last")
|
|
60
|
+
raise Error, "invalid excerpt range" unless first.is_a?(Integer) && last.is_a?(Integer) && first <= last
|
|
61
|
+
[source, first...last, excerpt["title"]]
|
|
62
|
+
end
|
|
63
|
+
buffer = MultiBuffer.new(excerpts: sources)
|
|
64
|
+
elsif entry["draft"]
|
|
65
|
+
buffer = Buffer.new(entry["draft"], path: entry["path"], draft: true, disk_digest: entry["digest"],
|
|
66
|
+
encoding: Encoding.find(entry.fetch("encoding", "UTF-8")), bom: [entry.fetch("bom", "")].pack("H*"), read_only: entry.fetch("read_only", false))
|
|
67
|
+
elsif entry["path"] && File.file?(entry["path"])
|
|
68
|
+
buffer = Buffer.open(entry["path"])
|
|
69
|
+
end
|
|
70
|
+
loaded[id] = buffer
|
|
71
|
+
if buffer
|
|
72
|
+
key = buffer.path ? canonical_path(buffer.path) : buffer.object_id
|
|
73
|
+
if restored_buffers.key?(key)
|
|
74
|
+
buffer.close
|
|
75
|
+
raise Error, "duplicate session buffer path"
|
|
76
|
+
end
|
|
77
|
+
restored_buffers[key] = buffer
|
|
78
|
+
end
|
|
79
|
+
buffer
|
|
80
|
+
end
|
|
81
|
+
data["panes"].each do |saved|
|
|
82
|
+
pane = Pane.new
|
|
83
|
+
restored_panes << pane
|
|
84
|
+
tabs = saved.fetch("tabs")
|
|
85
|
+
raise Error, "invalid session tabs" unless tabs.is_a?(Array) && tabs.length <= 1000
|
|
86
|
+
tabs.each do |tab|
|
|
87
|
+
if data["version"] == 1
|
|
88
|
+
tab["buffer_id"] = tab["path"] || "draft:#{tab.object_id}"
|
|
89
|
+
records[tab["buffer_id"]] ||= tab
|
|
90
|
+
end
|
|
91
|
+
buffer = load_buffer.call(tab.fetch("buffer_id"))
|
|
92
|
+
next unless buffer
|
|
93
|
+
current = pane.open(buffer)
|
|
94
|
+
current.language = definition_for(buffer.path)
|
|
95
|
+
apply_editor_settings(current)
|
|
96
|
+
if tab["selections"]
|
|
97
|
+
selections = tab["selections"]
|
|
98
|
+
raise Error, "invalid session selections" unless selections.is_a?(Array) && selections.length.between?(1, 10_000)
|
|
99
|
+
selections.each_with_index do |selection, index|
|
|
100
|
+
anchor, head = selection.values_at("anchor", "head")
|
|
101
|
+
raise Error, "invalid session cursor" unless anchor.is_a?(Integer) && head.is_a?(Integer)
|
|
102
|
+
current.select(anchor, head, add: index.positive?)
|
|
103
|
+
end
|
|
104
|
+
else
|
|
105
|
+
begin
|
|
106
|
+
current.select(tab.fetch("cursor", 0).clamp(0, buffer.rope.bytesize))
|
|
107
|
+
rescue RangeError
|
|
108
|
+
current.select(0)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
coordinates = [tab.fetch("scroll_x", 0), tab.fetch("scroll_y", 0)]
|
|
112
|
+
raise Error, "invalid session scroll position" unless coordinates.all? { |value| value.is_a?(Numeric) && value.finite? && value >= 0 }
|
|
113
|
+
current.scroll(dx: coordinates.first, dy: coordinates.last)
|
|
114
|
+
pane.pin(current) if tab["pinned"]
|
|
115
|
+
end
|
|
116
|
+
pane.active_index = saved.fetch("active", 0).clamp(0, [pane.editors.length - 1, 0].max)
|
|
117
|
+
end
|
|
118
|
+
restored_layout = decode_layout(data.fetch("layout"), panes: restored_panes)
|
|
119
|
+
layout_panes, pending = [], [restored_layout]
|
|
120
|
+
until pending.empty?
|
|
121
|
+
node = pending.pop
|
|
122
|
+
node[:pane] ? layout_panes << node[:pane] : pending.concat(node[:children])
|
|
123
|
+
end
|
|
124
|
+
raise Error, "session layout must include each pane exactly once" unless layout_panes.length == restored_panes.length && layout_panes.uniq.length == restored_panes.length
|
|
125
|
+
background = data.fetch("background_buffers", [])
|
|
126
|
+
raise Error, "invalid background buffers" unless background.is_a?(Array) && background.length <= 10_000
|
|
127
|
+
background.each { |id| load_buffer.call(id) }
|
|
128
|
+
restored_docks = @docks.transform_values(&:dup)
|
|
129
|
+
data.fetch("docks", {}).each do |side, value|
|
|
130
|
+
next unless restored_docks.key?(side.to_sym)
|
|
131
|
+
raise Error, "invalid session dock" unless value.is_a?(Hash) && [true, false].include?(value["visible"]) && value["size"].is_a?(Numeric) && value["size"].finite? && value["size"].positive?
|
|
132
|
+
restored_docks[side.to_sym] = {visible: value["visible"], size: value["size"].clamp(40, 4000)}
|
|
133
|
+
end
|
|
134
|
+
active = data.fetch("active_pane", 0)
|
|
135
|
+
raise Error, "invalid active pane" unless active.is_a?(Integer) && active.between?(0, restored_panes.length - 1)
|
|
136
|
+
clear_vim_states
|
|
137
|
+
close_language_documents
|
|
138
|
+
@panes.each { |pane| pane.editors.each(&:dispose) }
|
|
139
|
+
@buffers.each_value(&:close)
|
|
140
|
+
@panes, @buffers, @layout, @docks = restored_panes, restored_buffers, restored_layout, restored_docks
|
|
141
|
+
@active_pane = @panes[active]
|
|
142
|
+
@recent_files = Array(data["recent_files"]).select { |item| item.is_a?(String) && File.file?(item) }.first(100)
|
|
143
|
+
new_buffer unless editor
|
|
144
|
+
self
|
|
145
|
+
rescue StandardError
|
|
146
|
+
unless @panes.equal?(restored_panes)
|
|
147
|
+
restored_panes&.each { |pane| pane.editors.each(&:dispose) }
|
|
148
|
+
restored_buffers&.each_value(&:close)
|
|
149
|
+
end
|
|
150
|
+
raise
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Canopus
|
|
4
|
+
module Workspace::SettingsAware
|
|
5
|
+
def icon_theme
|
|
6
|
+
require_relative "../icon_theme"
|
|
7
|
+
@icon_theme ||= IconTheme.new(@settings["icon_theme"] && File.expand_path(@settings["icon_theme"], @root))
|
|
8
|
+
end
|
|
9
|
+
def settings_path = File.join(@root, ".canopus", "settings.jsonc")
|
|
10
|
+
def open_settings
|
|
11
|
+
FileUtils.mkdir_p(File.dirname(settings_path))
|
|
12
|
+
current = open(settings_path)
|
|
13
|
+
current.buffer.edit([[0...0, "// Project settings override user settings.\n{\n \"tab_size\": #{@settings['tab_size']}\n}\n"]], kind: :settings) if current.buffer.rope.empty?
|
|
14
|
+
@message = "Edit JSONC and save; Ctrl-Space completes setting names"
|
|
15
|
+
current
|
|
16
|
+
end
|
|
17
|
+
def settings_completions
|
|
18
|
+
raise Error, "Open a settings document first" unless settings_document?(editor.buffer)
|
|
19
|
+
self.palette = {kind: :settings_keys, query: +"", index: 0, matches: Settings::DEFAULTS.keys}
|
|
20
|
+
end
|
|
21
|
+
def settings_document?(buffer)
|
|
22
|
+
buffer.path && (buffer.path == settings_path || @settings.paths.any? { |path| File.expand_path(path) == buffer.path })
|
|
23
|
+
end
|
|
24
|
+
def configure_text_system(cache_dir: @window.text_system&.cache_dir)
|
|
25
|
+
database = Zaniah::TextSystem::FontDB.new
|
|
26
|
+
system = Zaniah::TextSystem::Renderer.new(font_db: database, font: database.find(family: @settings["font_family"]), cache_dir: cache_dir)
|
|
27
|
+
system.scale_factor = @window.scale_factor
|
|
28
|
+
old, @window.text_system = @window.text_system, system
|
|
29
|
+
old&.close
|
|
30
|
+
@applied_font_family = @settings["font_family"]
|
|
31
|
+
system
|
|
32
|
+
end
|
|
33
|
+
def apply_settings
|
|
34
|
+
servers = language_server_settings_plan
|
|
35
|
+
clear_vim_states unless @settings["vim_mode"]
|
|
36
|
+
name = @settings["theme"]
|
|
37
|
+
if name == "auto"
|
|
38
|
+
name = @window&.respond_to?(:appearance) && @window.appearance == :light ? "Canopus Light" : "Canopus Dark"
|
|
39
|
+
end
|
|
40
|
+
target_theme = File.file?(File.expand_path(name, @root)) ? Theme.load(File.expand_path(name, @root)) : Theme.new(name: name)
|
|
41
|
+
if @applied_icon_theme != @settings["icon_theme"]
|
|
42
|
+
require_relative "../icon_theme"
|
|
43
|
+
@icon_theme = IconTheme.new(@settings["icon_theme"] && File.expand_path(@settings["icon_theme"], @root))
|
|
44
|
+
end
|
|
45
|
+
@panes.each do |pane|
|
|
46
|
+
pane.editors.each do |current|
|
|
47
|
+
apply_editor_settings(current)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
self.theme = target_theme
|
|
51
|
+
if @window&.text_system && !@window.is_a?(Zaniah::Platform::TUI::Window) && @applied_font_family != @settings["font_family"]
|
|
52
|
+
configure_text_system
|
|
53
|
+
end
|
|
54
|
+
@applied_font_family = @settings["font_family"]
|
|
55
|
+
@applied_icon_theme = @settings["icon_theme"]
|
|
56
|
+
reload_language_servers(servers)
|
|
57
|
+
@window&.request_frame
|
|
58
|
+
end
|
|
59
|
+
def poll_settings(force: false)
|
|
60
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
61
|
+
return if !force && @last_settings_poll && now - @last_settings_poll < 1
|
|
62
|
+
@last_settings_poll = now
|
|
63
|
+
state = @settings.paths.map do |path|
|
|
64
|
+
stat = File.stat(path)
|
|
65
|
+
[path, stat.mtime, stat.size, stat.ino]
|
|
66
|
+
rescue Errno::ENOENT
|
|
67
|
+
[path, nil]
|
|
68
|
+
end
|
|
69
|
+
return if state == @settings_state && !force
|
|
70
|
+
initial = !defined?(@settings_state)
|
|
71
|
+
@settings_state = state
|
|
72
|
+
return if initial && !force
|
|
73
|
+
replacement = @settings.reload
|
|
74
|
+
old, @settings = @settings, replacement
|
|
75
|
+
apply_settings
|
|
76
|
+
@message = "Settings reloaded"
|
|
77
|
+
rescue StandardError => error
|
|
78
|
+
@settings = old if old
|
|
79
|
+
@message = "Settings unchanged: #{error.message}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
def apply_editor_settings(current)
|
|
84
|
+
values = @settings.for_language(current.language_document.definition.name)
|
|
85
|
+
current.tab_size = values["tab_size"]
|
|
86
|
+
current.use_tabs = values["use_tabs"]
|
|
87
|
+
current.display_map.tab_size = current.tab_size
|
|
88
|
+
current.display_map.wrap_width = values["soft_wrap"] ? 100 : nil unless current.buffer.read_only
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|