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,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