canopus 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/README.md +191 -19
  4. data/assets/keymaps/emacs.jsonc +5 -0
  5. data/assets/keymaps/jetbrains.jsonc +5 -0
  6. data/assets/keymaps/sublime.jsonc +5 -0
  7. data/assets/keymaps/vscode.jsonc +5 -0
  8. data/docs/debugging.md +143 -0
  9. data/docs/distribution.md +40 -9
  10. data/docs/lsp.md +131 -0
  11. data/docs/performance.md +18 -0
  12. data/docs/providers.md +36 -0
  13. data/docs/release-log.md +12 -0
  14. data/docs/tasks.md +64 -0
  15. data/docs/testing.md +49 -0
  16. data/docs/vim.md +3 -0
  17. data/exe/canopus +4 -1
  18. data/lib/canopus/buffer.rb +291 -50
  19. data/lib/canopus/cli.rb +5 -2
  20. data/lib/canopus/command.rb +13 -4
  21. data/lib/canopus/controller.rb +183 -40
  22. data/lib/canopus/debug/breakpoints.rb +550 -0
  23. data/lib/canopus/debug/configuration.rb +291 -0
  24. data/lib/canopus/debug/console.rb +243 -0
  25. data/lib/canopus/debug/panel.rb +487 -0
  26. data/lib/canopus/debug/session.rb +354 -0
  27. data/lib/canopus/decoration.rb +4 -2
  28. data/lib/canopus/denebola_compat.rb +27 -0
  29. data/lib/canopus/diagnostics.rb +173 -0
  30. data/lib/canopus/display_map/overlay_map.rb +6 -2
  31. data/lib/canopus/editor/snippet_expandable.rb +3 -2
  32. data/lib/canopus/editor.rb +122 -31
  33. data/lib/canopus/git/state.rb +100 -0
  34. data/lib/canopus/language/background_analysis.rb +93 -14
  35. data/lib/canopus/language/document.rb +70 -34
  36. data/lib/canopus/language/symbol.rb +1 -0
  37. data/lib/canopus/language/syntax_worker.rb +61 -9
  38. data/lib/canopus/language.rb +3 -0
  39. data/lib/canopus/minimap.rb +201 -0
  40. data/lib/canopus/patch.rb +3 -1
  41. data/lib/canopus/plugins/api.rb +4 -2
  42. data/lib/canopus/plugins/isolated_runtime.rb +64 -7
  43. data/lib/canopus/plugins/registry.rb +114 -3
  44. data/lib/canopus/plugins.rb +8 -7
  45. data/lib/canopus/project.rb +15 -6
  46. data/lib/canopus/provider.rb +192 -0
  47. data/lib/canopus/regexp_compat.rb +36 -4
  48. data/lib/canopus/selection.rb +12 -0
  49. data/lib/canopus/settings/schema.rb +201 -0
  50. data/lib/canopus/settings.rb +328 -49
  51. data/lib/canopus/task/configuration.rb +356 -0
  52. data/lib/canopus/task/problem_matcher.rb +264 -0
  53. data/lib/canopus/task/runner.rb +314 -0
  54. data/lib/canopus/test_runner/bounded_ignore.rb +144 -0
  55. data/lib/canopus/test_runner/discovery.rb +97 -0
  56. data/lib/canopus/test_runner/execution.rb +194 -0
  57. data/lib/canopus/test_runner/minitest.rb +116 -0
  58. data/lib/canopus/test_runner/rspec.rb +103 -0
  59. data/lib/canopus/test_runner/safe_walker.rb +37 -0
  60. data/lib/canopus/test_runner.rb +54 -0
  61. data/lib/canopus/theme/import.rb +131 -0
  62. data/lib/canopus/theme.rb +28 -4
  63. data/lib/canopus/version.rb +1 -1
  64. data/lib/canopus/vim/motionable.rb +1 -1
  65. data/lib/canopus/vim.rb +10 -16
  66. data/lib/canopus/workspace/auto_savable.rb +83 -0
  67. data/lib/canopus/workspace/debug_aware.rb +600 -0
  68. data/lib/canopus/workspace/edit/plan.rb +2 -2
  69. data/lib/canopus/workspace/editor_configurable.rb +113 -0
  70. data/lib/canopus/workspace/file_change_aware.rb +11 -3
  71. data/lib/canopus/workspace/git_aware.rb +80 -34
  72. data/lib/canopus/workspace/git_blame.rb +175 -0
  73. data/lib/canopus/workspace/git_conflict_resolution.rb +410 -0
  74. data/lib/canopus/workspace/git_diff_view.rb +641 -0
  75. data/lib/canopus/workspace/git_history.rb +171 -0
  76. data/lib/canopus/workspace/git_remote.rb +267 -0
  77. data/lib/canopus/workspace/git_staging.rb +583 -0
  78. data/lib/canopus/workspace/hierarchy_aware.rb +477 -0
  79. data/lib/canopus/workspace/keymap_aware.rb +66 -0
  80. data/lib/canopus/workspace/language_aware.rb +3721 -135
  81. data/lib/canopus/workspace/language_server_configurable.rb +287 -71
  82. data/lib/canopus/workspace/persistent_undo.rb +169 -0
  83. data/lib/canopus/workspace/problems_aware.rb +131 -0
  84. data/lib/canopus/workspace/project_searchable.rb +88 -11
  85. data/lib/canopus/workspace/project_tree_editable.rb +3 -1
  86. data/lib/canopus/workspace/recoverable.rb +204 -0
  87. data/lib/canopus/workspace/session_persistable.rb +85 -13
  88. data/lib/canopus/workspace/settings_aware.rb +76 -2
  89. data/lib/canopus/workspace/task_aware.rb +194 -0
  90. data/lib/canopus/workspace/test_aware.rb +394 -0
  91. data/lib/canopus/workspace/trust.rb +73 -0
  92. data/lib/canopus/workspace/view/terminal_presentable.rb +298 -47
  93. data/lib/canopus/workspace/view.rb +399 -74
  94. data/lib/canopus/workspace.rb +688 -44
  95. data/lib/canopus.rb +20 -2
  96. data/sig/canopus.rbs +138 -31
  97. data/sig/controller.rbs +10 -3
  98. data/sig/debug.rbs +121 -0
  99. data/sig/decoration.rbs +12 -2
  100. data/sig/diagnostics.rbs +26 -0
  101. data/sig/minimap.rbs +20 -0
  102. data/sig/provider.rbs +29 -0
  103. data/sig/services.rbs +1 -16
  104. data/sig/task.rbs +92 -0
  105. data/sig/test_runner.rbs +109 -0
  106. data/sig/trust.rbs +17 -0
  107. data/sig/workspace_edits.rbs +2 -2
  108. data/sig/workspace_services.rbs +234 -2
  109. data/tools/check_dependencies.rb +21 -2
  110. data/tools/package.rb +172 -14
  111. data/tools/package_signature_test.rb +33 -0
  112. data/tools/package_test.rb +29 -0
  113. data/tools/sign_package.rb +123 -0
  114. data/tools/update.rb +189 -0
  115. data/tools/update_test.rb +71 -0
  116. metadata +206 -31
  117. data/lib/canopus/lsp/client.rb +0 -307
  118. data/lib/canopus/lsp/error.rb +0 -7
  119. data/lib/canopus/lsp/future/subscription.rb +0 -9
  120. data/lib/canopus/lsp/future.rb +0 -87
  121. data/lib/canopus/lsp/protocol.rb +0 -83
  122. data/lib/canopus/lsp/server_error.rb +0 -13
  123. data/lib/canopus/lsp/timeout.rb +0 -7
  124. data/lib/canopus/lsp/transport.rb +0 -123
  125. data/lib/canopus/lsp.rb +0 -19
  126. data/lib/canopus/project/search.rb +0 -164
  127. data/lib/canopus/project/search_worker/cancelled.rb +0 -3
  128. data/lib/canopus/project/search_worker/runner.rb +0 -9
  129. data/lib/canopus/project/search_worker.rb +0 -108
  130. data/lib/canopus/terminal/cell.rb +0 -7
  131. data/lib/canopus/terminal/grid.rb +0 -321
  132. data/lib/canopus/terminal/pty.rb +0 -178
  133. data/lib/canopus/terminal/scrollback.rb +0 -38
  134. data/lib/canopus/terminal/vt.rb +0 -398
  135. data/lib/canopus/terminal.rb +0 -13
  136. data/sig/lsp.rbs +0 -99
  137. data/sig/terminal.rbs +0 -130
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zaniah/ui"
4
+
5
+ module Canopus
6
+ module Workspace::ProblemsAware
7
+ PROBLEM_LABEL_LIMIT = 200
8
+ PROBLEM_FILTER_LIMIT = 4_096
9
+
10
+ def problems_tree
11
+ @problems_tree ||= Zaniah::UI::TreeView.new(problem_nodes, height: 320).on_select do |value, _event, _context|
12
+ select_problem(value)
13
+ end
14
+ end
15
+
16
+ def filter_problems(severity: nil, source: nil, text: nil)
17
+ severity = Diagnostics::SEVERITIES.fetch(severity, severity) if severity.is_a?(Symbol)
18
+ unless severity.nil? || severity.is_a?(Integer) && severity.between?(1, 4)
19
+ raise ArgumentError, "invalid problem severity"
20
+ end
21
+ unless source.nil? || Diagnostics::SOURCES.include?(source)
22
+ raise ArgumentError, "invalid problem source"
23
+ end
24
+ unless text.nil? || text.is_a?(String) && text.encoding == Encoding::UTF_8 && text.valid_encoding? &&
25
+ text.bytesize <= PROBLEM_FILTER_LIMIT && !text.include?("\0")
26
+ raise ArgumentError, "invalid problem text"
27
+ end
28
+
29
+ @problem_filter = {severity: severity, source: source, text: text&.downcase}.freeze
30
+ refresh_problems
31
+ @problem_filter
32
+ end
33
+
34
+ def show_problem_filter
35
+ self.palette = {kind: :problem_filter, query: +(@problem_filter_query || ""), index: 0, matches: []}
36
+ end
37
+
38
+ private
39
+
40
+ def apply_problem_filter(query)
41
+ unless query.is_a?(String) && query.encoding == Encoding::UTF_8 && query.valid_encoding? &&
42
+ query.bytesize <= PROBLEM_FILTER_LIMIT && !query.include?("\0")
43
+ raise ArgumentError, "invalid problem filter"
44
+ end
45
+ severity = source = nil
46
+ text = query.gsub(/(?:\A|\s+)(severity|source):(\S+)/i) do
47
+ key, value = Regexp.last_match(1).downcase, Regexp.last_match(2).downcase
48
+ if key == "severity"
49
+ severity = value == "all" ? nil : Diagnostics::SEVERITIES.find { |name, _| name.to_s == value }&.last
50
+ raise ArgumentError, "invalid problem severity" if value != "all" && !severity
51
+ else
52
+ source = value == "all" ? nil : Diagnostics::SOURCES.find { |name| name.to_s == value }
53
+ raise ArgumentError, "invalid problem source" if value != "all" && !source
54
+ end
55
+ " "
56
+ end.gsub(/\s+/, " ").strip
57
+ @problem_filter_query = query.dup.freeze
58
+ filter_problems(severity: severity, source: source, text: text)
59
+ @panels.show("problems")
60
+ end
61
+
62
+ def diagnostics_changed(uri)
63
+ uris = uri.is_a?(Array) ? uri : [uri]
64
+ targets = uris.each_with_object({}) do |value, result|
65
+ Sadr::Protocol.path(value)
66
+ result[value] = true
67
+ end
68
+ @buffers.each_value.uniq.each do |buffer|
69
+ invalidate_diagnostics(buffer) if buffer.path && targets.key?(Sadr::Protocol.uri(buffer.path))
70
+ end
71
+ refresh_problems
72
+ rescue Sadr::Error
73
+ nil
74
+ end
75
+
76
+ def refresh_problems
77
+ @problems_tree&.replace(problem_nodes)
78
+ count = @diagnostics.counts.values.sum
79
+ @panels.badge("problems", count.zero? ? nil : count) if @panels&.key?("problems")
80
+ @window&.request_frame
81
+ nil
82
+ end
83
+
84
+ def problem_nodes
85
+ filter = @problem_filter || {}
86
+ entries = @diagnostics.all(severity: filter[:severity], source: filter[:source])
87
+ if (text = filter[:text]) && !text.empty?
88
+ entries = entries.select { |entry| entry.diagnostic.fetch("message").downcase.include?(text) }
89
+ end
90
+ entries.group_by(&:uri).map do |uri, problems|
91
+ {id: [:problem_file, uri].freeze, label: problem_path(uri), value: nil,
92
+ children: problems.map do |entry|
93
+ diagnostic = entry.diagnostic
94
+ severity = Diagnostics::SEVERITIES.key(diagnostic.fetch("severity", 1))
95
+ line = diagnostic.dig("range", "start", "line") + 1
96
+ message = diagnostic.fetch("message").gsub(/\s+/, " ").strip
97
+ label = "#{severity} [#{entry.source}] #{line}: #{message}"
98
+ label = label.each_char.first(PROBLEM_LABEL_LIMIT - 1).join + "…" if label.length > PROBLEM_LABEL_LIMIT
99
+ {id: [:problem, entry.source, uri, diagnostic.object_id].freeze,
100
+ label: label.freeze, value: entry}.freeze
101
+ end.freeze}.freeze
102
+ end.freeze
103
+ end
104
+
105
+ def problem_path(uri)
106
+ path = Sadr::Protocol.path(uri)
107
+ prefix = @root + File::SEPARATOR
108
+ label = path.start_with?(prefix) ? path.delete_prefix(prefix) : path
109
+ label.length > PROBLEM_LABEL_LIMIT ? label.each_char.first(PROBLEM_LABEL_LIMIT - 1).join + "…" : label
110
+ end
111
+
112
+ def select_problem(entry)
113
+ return false unless entry.is_a?(Diagnostics::Entry)
114
+
115
+ path = Sadr::Protocol.path(entry.uri)
116
+ raise Error, "Problem target is not a file" unless File.file?(path)
117
+ target = open(path)
118
+ position = Sadr::Protocol.range_value(entry.diagnostic.fetch("range")).start
119
+ offset = Sadr::Protocol.offset(target.buffer.rope, position)
120
+ unless Sadr::Protocol.position(target.buffer.rope, offset) == position
121
+ raise Error, "Problem target position is no longer valid"
122
+ end
123
+ target.select(offset)
124
+ target.reveal_cursor
125
+ true
126
+ rescue Error, KeyError, RangeError, TypeError, SystemCallError, Sadr::Error => error
127
+ @message = "Cannot open problem: #{error.message}"
128
+ false
129
+ end
130
+ end
131
+ end
@@ -68,8 +68,7 @@ module Canopus
68
68
  def prepare_project_search(pattern, generation, sources, settings:, paths: nil, async: true)
69
69
  work = lambda do
70
70
  prepared = nil
71
- paths ||= @project.search(pattern, workers: 4, limit: 10_000,
72
- cancelled: -> { search_cancelled?(generation) }).map(&:path).uniq.freeze
71
+ paths ||= search_project_paths(pattern, generation)
73
72
  next if search_cancelled?(generation)
74
73
  prepared = build_search_preparation(pattern, generation, sources, paths, settings)
75
74
  next unless prepared
@@ -95,6 +94,81 @@ module Canopus
95
94
  async ? @search_job = Thread.new(&work) : work.call
96
95
  end
97
96
 
97
+ def search_project_paths(pattern, generation)
98
+ project_search_matches(pattern, cancelled: -> { search_cancelled?(generation) }).map(&:path).uniq.freeze
99
+ end
100
+
101
+ def workspace_symbol_fallback(query, generation, deadline)
102
+ matches = project_search_matches(query, ignore_case: true,
103
+ cancelled: -> { workspace_symbol_cancelled?(generation, deadline) })
104
+ cancelled = -> { workspace_symbol_cancelled?(generation, deadline) }
105
+ lines, paths, symbols = {}.compare_by_identity, {}, []
106
+ matches.each do |match|
107
+ break if cancelled.call
108
+ symbol = fallback_workspace_symbol(match, lines: lines, paths: paths, cancelled: cancelled)
109
+ symbols << symbol if symbol
110
+ end
111
+ symbols.freeze
112
+ end
113
+
114
+ def project_search_matches(pattern, ignore_case: false, cancelled:)
115
+ Alkaid::Search.new(@root, pattern: pattern, ignore_case: ignore_case, ignore: @project.ignore_matcher, workers: 4,
116
+ max_file_size: 10 * 1024 * 1024, max_matches: 10_000, hidden: true, cancelled: cancelled).run
117
+ end
118
+
119
+ def fallback_workspace_symbol(match, lines: {}.compare_by_identity, paths: {}, cancelled: -> { false })
120
+ absolute = if paths.key?(match.path)
121
+ paths[match.path]
122
+ else
123
+ paths[match.path] = File.realpath(File.join(@root, match.path))
124
+ end
125
+ prefix = @root.end_with?(File::SEPARATOR) ? @root : @root + File::SEPARATOR
126
+ return unless absolute.start_with?(prefix)
127
+
128
+ range = match.ranges.first
129
+ state = lines[match.line] ||= {valid: match.line.valid_encoding?, offset: 0, units: 0}
130
+ return unless state[:valid] && range && range.begin >= 0 && range.end <= match.line.bytesize &&
131
+ match.line_number.positive? && !cancelled.call
132
+ first = workspace_symbol_utf16_column(match.line, range.begin, state, cancelled)
133
+ last = workspace_symbol_utf16_column(match.line, range.end, state, cancelled)
134
+ return unless first && last
135
+ preview = fallback_workspace_symbol_preview(match.line, range)
136
+ return if preview.empty?
137
+ {"name" => preview.freeze, "kind" => 13, "containerName" => match.path.dup.freeze,
138
+ "location" => {"uri" => Sadr::Protocol.uri(absolute).freeze, "range" => {
139
+ "start" => {"line" => match.line_number - 1, "character" => first}.freeze,
140
+ "end" => {"line" => match.line_number - 1, "character" => last}.freeze
141
+ }.freeze}.freeze}.freeze
142
+ rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP, EncodingError
143
+ nil
144
+ end
145
+
146
+ def workspace_symbol_utf16_column(line, target, state, cancelled)
147
+ offset, units = target < state[:offset] ? [0, 0] : state.values_at(:offset, :units)
148
+ update = offset == state[:offset]
149
+ while offset < target
150
+ return if cancelled.call
151
+ finish = [offset + Workspace::LanguageAware::WORKSPACE_SYMBOL_UTF16_CHUNK_BYTES, target].min
152
+ if finish < target
153
+ finish -= 1 while finish > offset && line.getbyte(finish)&.between?(0x80, 0xBF)
154
+ end
155
+ return if finish <= offset
156
+ units += line.byteslice(offset, finish - offset).encode(Encoding::UTF_16LE).bytesize / 2
157
+ offset = finish
158
+ end
159
+ state[:offset], state[:units] = offset, units if update
160
+ units
161
+ end
162
+
163
+ def fallback_workspace_symbol_preview(line, range)
164
+ first = [range.begin - 96, 0].max
165
+ first += 1 while first < range.begin && line.getbyte(first)&.between?(0x80, 0xBF)
166
+ last = [first + Workspace::LanguageAware::WORKSPACE_SYMBOL_PREVIEW_BYTES, line.bytesize].min
167
+ last -= 1 while last > range.begin && line.getbyte(last)&.between?(0x80, 0xBF)
168
+ preview = line.byteslice(first, last - first).to_s.gsub(/\s+/, " ").strip
169
+ bounded_workspace_symbol_text(preview, Workspace::LanguageAware::WORKSPACE_SYMBOL_PREVIEW_BYTES)
170
+ end
171
+
98
172
  def build_search_preparation(pattern, generation, sources, paths, settings)
99
173
  prepared = SearchPreparation.new(sources, {}, nil, 0, settings)
100
174
  excerpts = []
@@ -143,15 +217,17 @@ module Canopus
143
217
 
144
218
  def search_excerpt_ranges(rope, pattern, limit, generation)
145
219
  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
220
+ Canopus.with_regexp_timeout(pattern) do
221
+ rope.to_s.to_enum(:scan, pattern).each do
222
+ break if search_cancelled?(generation)
223
+ match = Regexp.last_match
224
+ row, last_row = rope.point_at(match.bytebegin(0)).row, rope.point_at(match.byteend(0)).row
225
+ first = rope.line_start([row - 1, 0].max)
226
+ last = last_row + 2 < rope.line_count ? rope.line_start(last_row + 2) : rope.bytesize
227
+ ranges.last && first <= ranges.last.end ? ranges[-1] = ranges.last.begin...[ranges.last.end, last].max : ranges << (first...last)
228
+ count += 1
229
+ break if count >= limit
230
+ end
155
231
  end
156
232
  [ranges, count]
157
233
  end
@@ -197,6 +273,7 @@ module Canopus
197
273
  @buffers[projection.object_id] = projection
198
274
  @active_pane.editors << prepared.editor
199
275
  @active_pane.activate(@active_pane.editors.length - 1)
276
+ invalidate_hidden_selection_ranges
200
277
  @message = "#{prepared.count} matches — edit excerpts, then Save to save source files"
201
278
  @window&.request_frame
202
279
  prepared = nil
@@ -45,7 +45,9 @@ module Canopus
45
45
  close_language_documents(buffer)
46
46
  rescue StandardError => error
47
47
  @message = "Path moved; language server close failed: #{error.message}"
48
- @opened_lsp_documents&.delete_if { |(_, document), _| document.equal?(buffer) }
48
+ @opened_lsp_documents&.keys&.each do |client, document|
49
+ forget_language_document(client, document) if document.equal?(buffer)
50
+ end
49
51
  end
50
52
  buffer.relocate(new_path)
51
53
  @buffers[new_path] = buffer
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Canopus
6
+ module Workspace::Recoverable
7
+ MAX_DRAFT_BYTES = 10 << 20
8
+ MAX_SNAPSHOT_BYTES = 64 << 20
9
+ SNAPSHOT_OVERHEAD_BYTES = 4 << 20
10
+ JSON_ESCAPE_FACTOR = 6
11
+
12
+ def recovery_directory = File.join(@root, ".canopus", "recovery")
13
+
14
+ def recovery_candidates
15
+ return [] unless @settings["recovery"]["enabled"] && recovery_directory_safe?
16
+ ignored = @ignored_recovery_candidates || []
17
+ Dir.glob(File.join(recovery_directory, "*.json")).reject do |path|
18
+ path == @recovery_path || ignored.include?(path) || recovery_candidate_locked?(path) || !valid_recovery_candidate?(path)
19
+ end.sort_by { |path| -File.mtime(path).to_f }
20
+ rescue SystemCallError
21
+ []
22
+ end
23
+
24
+ def offer_recovery
25
+ return false if @palette || !(candidate = recovery_candidates.first)
26
+ time = File.mtime(candidate).getlocal.strftime("%Y-%m-%d %H:%M:%S")
27
+ self.palette = {kind: :recovery, query: "Recover unsaved changes from #{time}?", index: 0,
28
+ matches: ["Restore", "Discard"], candidate: candidate}
29
+ true
30
+ rescue SystemCallError
31
+ false
32
+ end
33
+
34
+ def resolve_recovery(choice)
35
+ prompt = @palette
36
+ return false unless prompt&.dig(:kind) == :recovery
37
+ candidate = prompt.fetch(:candidate)
38
+ self.palette = nil
39
+ unless !recovery_candidate_locked?(candidate) && valid_recovery_candidate?(candidate)
40
+ raise Error, "recovery snapshot is no longer safe to read"
41
+ end
42
+ if choice == :restore
43
+ open_paths = @panes.flat_map { |pane| pane.editors.filter_map { |current| current.buffer.path } }.uniq
44
+ restore_session(candidate)
45
+ open_paths.each { |path| open(path) }
46
+ discard_recovery(candidate) if !@settings["recovery"]["enabled"] || poll_recovery(force: true)
47
+ self.message = "Recovered unsaved changes"
48
+ else
49
+ raise Error, "recovery snapshot is no longer safe to discard" unless discard_recovery(candidate)
50
+ self.message = "Discarded recovered changes"
51
+ end
52
+ true
53
+ rescue StandardError => error
54
+ (@ignored_recovery_candidates ||= []) << candidate if candidate
55
+ notify("Recovery ignored: #{error.message}")
56
+ false
57
+ end
58
+
59
+ def poll_recovery(now: Process.clock_gettime(Process::CLOCK_MONOTONIC), force: false)
60
+ recovery = @settings["recovery"]
61
+ return false if @closed
62
+ interval = recovery["interval"] / 1000.0
63
+ return false if !force && @last_recovery_poll && now - @last_recovery_poll < interval
64
+ @last_recovery_poll = now
65
+ (@recovery_mutex ||= Mutex.new).synchronize do
66
+ dirty = recovery_buffers.select(&:dirty?)
67
+ eligible = dirty.reject { |buffer| buffer.rope.bytesize > MAX_DRAFT_BYTES }
68
+ state = [recovery["enabled"], *dirty.map { |buffer| [buffer.object_id, buffer.version, buffer.path] }]
69
+ return false if !force && state == @recovery_state
70
+ if !recovery["enabled"] || eligible.empty?
71
+ remove_recovery_snapshot
72
+ notify("Crash recovery skipped #{dirty.length} buffer(s) larger than 10 MiB") if recovery["enabled"] && !dirty.empty?
73
+ @recovery_state = state
74
+ return false
75
+ end
76
+ if recovery_snapshot_too_large?(eligible)
77
+ remove_recovery_snapshot
78
+ notify("Crash recovery drafts exceeded the 64 MiB snapshot budget and were not retained")
79
+ @recovery_state = state
80
+ return false
81
+ end
82
+ ensure_recovery_storage
83
+ save_session(@recovery_path, max_draft_bytes: MAX_DRAFT_BYTES)
84
+ if File.size(@recovery_path) > MAX_SNAPSHOT_BYTES
85
+ remove_recovery_snapshot
86
+ notify("Crash recovery snapshot exceeded 64 MiB and was not retained")
87
+ @recovery_state = state
88
+ return false
89
+ end
90
+ notify("Crash recovery skipped #{dirty.length - eligible.length} buffer(s) larger than 10 MiB") if dirty.length != eligible.length
91
+ @recovery_state = state
92
+ true
93
+ end
94
+ rescue StandardError => error
95
+ notify("Crash recovery failed: #{error.message}")
96
+ false
97
+ end
98
+
99
+ def close_recovery(clean: true)
100
+ (@recovery_mutex ||= Mutex.new).synchronize do
101
+ remove_recovery_snapshot if clean
102
+ @recovery_lock&.close
103
+ @recovery_lock = nil
104
+ remove_recovery_lock if clean
105
+ end
106
+ end
107
+
108
+ def preserve_recovery! = @preserve_recovery = true
109
+
110
+ private
111
+
112
+ def recovery_buffers
113
+ @buffers.values.flat_map { |buffer| buffer.is_a?(MultiBuffer) ? buffer.excerpts.map(&:buffer) : buffer }.uniq
114
+ end
115
+
116
+ def ensure_recovery_storage
117
+ parent = File.dirname(recovery_directory)
118
+ raise Error, "recovery path is a symbolic link" if File.symlink?(parent) || File.symlink?(recovery_directory)
119
+ FileUtils.mkdir_p(parent)
120
+ begin
121
+ Dir.mkdir(recovery_directory, 0o700) unless File.directory?(recovery_directory)
122
+ rescue Errno::EEXIST
123
+ raise unless File.directory?(recovery_directory)
124
+ end
125
+ File.chmod(0o700, recovery_directory)
126
+ ignore = File.join(recovery_directory, ".gitignore")
127
+ begin
128
+ File.open(ignore, File::WRONLY | File::CREAT | File::EXCL, 0o600) { |file| file.write("*\n") }
129
+ rescue Errno::EEXIST
130
+ nil
131
+ end
132
+ initialize_recovery_lock
133
+ end
134
+
135
+ def initialize_recovery_lock
136
+ token = @recovery_token ||= "#{Process.pid}-#{SecureRandom.hex(12)}"
137
+ @recovery_path ||= File.join(recovery_directory, "#{token}.json")
138
+ @recovery_lock_path ||= File.join(recovery_directory, "#{token}.lock")
139
+ return if @recovery_lock
140
+ raise Error, "recovery lock is a symbolic link" if File.symlink?(@recovery_lock_path)
141
+ flags = File::RDWR | File::CREAT
142
+ flags |= File::NOFOLLOW if defined?(File::NOFOLLOW)
143
+ @recovery_lock = File.open(@recovery_lock_path, flags, 0o600)
144
+ raise Error, "recovery lock is already held" unless @recovery_lock.flock(File::LOCK_EX | File::LOCK_NB)
145
+ end
146
+
147
+ def valid_recovery_candidate?(path)
148
+ return false unless recovery_path_safe?(path) && File.file?(path) && File.size(path) <= MAX_SNAPSHOT_BYTES
149
+ data = JSON.parse(File.binread(path))
150
+ buffers = data["buffers"]
151
+ [1, 2].include?(data["version"]) && data["root"] == @root && buffers.is_a?(Hash) &&
152
+ buffers.length <= 10_000 && buffers.any? { |_, value| value.is_a?(Hash) && value["draft"].is_a?(String) }
153
+ rescue JSON::ParserError, JSON::NestingError, SystemCallError
154
+ false
155
+ end
156
+
157
+ def recovery_candidate_locked?(path)
158
+ return true unless recovery_path_safe?(path)
159
+ lock_path = path.sub(/\.json\z/, ".lock")
160
+ return true if File.symlink?(lock_path)
161
+ return false unless File.file?(lock_path)
162
+ File.open(lock_path, File::RDWR) do |lock|
163
+ acquired = lock.flock(File::LOCK_EX | File::LOCK_NB)
164
+ lock.flock(File::LOCK_UN) if acquired
165
+ !acquired
166
+ end
167
+ rescue Errno::EACCES, Errno::EAGAIN
168
+ true
169
+ rescue SystemCallError
170
+ false
171
+ end
172
+
173
+ def discard_recovery(path)
174
+ return false unless recovery_path_safe?(path) && !recovery_candidate_locked?(path)
175
+ lock_path = path.sub(/\.json\z/, ".lock")
176
+ return false if File.symlink?(lock_path)
177
+ File.delete(path) if File.file?(path)
178
+ File.delete(lock_path) if File.file?(lock_path)
179
+ true
180
+ end
181
+
182
+ def recovery_snapshot_too_large?(buffers)
183
+ SNAPSHOT_OVERHEAD_BYTES + buffers.sum { |buffer| buffer.rope.bytesize * JSON_ESCAPE_FACTOR } > MAX_SNAPSHOT_BYTES
184
+ end
185
+
186
+ def remove_recovery_snapshot
187
+ File.delete(@recovery_path) if @recovery_path && recovery_path_safe?(@recovery_path) && File.file?(@recovery_path)
188
+ end
189
+
190
+ def remove_recovery_lock
191
+ return unless @recovery_lock_path && recovery_directory_safe? && File.dirname(@recovery_lock_path) == recovery_directory
192
+ File.delete(@recovery_lock_path) if !File.symlink?(@recovery_lock_path) && File.file?(@recovery_lock_path)
193
+ end
194
+
195
+ def recovery_path_safe?(path)
196
+ recovery_directory_safe? && File.dirname(File.expand_path(path)) == recovery_directory && !File.symlink?(path)
197
+ end
198
+
199
+ def recovery_directory_safe?
200
+ parent = File.dirname(recovery_directory)
201
+ !File.symlink?(parent) && !File.symlink?(recovery_directory) && File.directory?(recovery_directory)
202
+ end
203
+ end
204
+ end
@@ -2,36 +2,53 @@
2
2
 
3
3
  module Canopus
4
4
  module Workspace::SessionPersistable
5
- def save_session(path)
5
+ def save_session(path, max_draft_bytes: nil)
6
6
  records = {}
7
7
  record = lambda do |buffer|
8
8
  id = buffer.object_id.to_s
9
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),
10
+ entry = if buffer.is_a?(MultiBuffer)
11
+ excerpts = buffer.excerpts.filter_map do |excerpt|
12
+ source = record.call(excerpt.buffer)
13
+ next unless source
14
+ {buffer: source, first: excerpt.buffer.resolve(excerpt.first),
13
15
  last: excerpt.buffer.resolve(excerpt.last), title: excerpt.title}
14
- end}
16
+ end
17
+ next if excerpts.length != buffer.excerpts.length
18
+ {excerpts: excerpts}
15
19
  else
20
+ next if max_draft_bytes && (buffer.dirty? || !buffer.path) && buffer.rope.bytesize > max_draft_bytes
16
21
  {path: buffer.path, draft: buffer.dirty? || !buffer.path ? buffer.text : nil,
17
22
  digest: buffer.disk_digest, encoding: buffer.encoding.name, bom: buffer.bom.unpack1("H*"), read_only: buffer.read_only}
18
23
  end
24
+ records[id] = entry
19
25
  id
20
26
  end
21
27
  state = {version: 2, root: @root, layout: encode_layout(@layout), recent_files: @recent_files || [],
22
28
  active_pane: @panes.index(@active_pane), docks: @docks, panels: @panels.state,
23
- terminals: @terminals.map { |current| {cwd: current.vt.cwd || (current.respond_to?(:initial_cwd) ? current.initial_cwd : @root), title: @terminal_names[current]} },
24
- active_terminal: @active_terminal_index, terminal_visible: terminal_visible,
29
+ terminals: @terminals.map do |current|
30
+ cwd = current.respond_to?(:cwd) ? current.cwd : current.vt.cwd
31
+ {cwd: cwd || (current.respond_to?(:initial_cwd) ? current.initial_cwd : @root), title: @terminal_names[current],
32
+ profile: terminal_profile(current)}
33
+ end,
34
+ active_terminal: @active_terminal_index, terminal_layout: encode_terminal_layout(@terminal_layout),
35
+ terminal_visible: terminal_visible,
25
36
  panes: @panes.map do |pane|
26
- {active: pane.active_index, tabs: pane.editors.map do |current|
27
- {buffer_id: record.call(current.buffer), cursor: current.primary.head, pinned: pane.pinned.include?(current),
37
+ {active: pane.active_index, tabs: pane.editors.filter_map do |current|
38
+ buffer_id = record.call(current.buffer)
39
+ next unless buffer_id
40
+ {buffer_id: buffer_id, cursor: current.primary.head, pinned: pane.pinned.include?(current),
28
41
  selections: current.selections.map { |selection| {anchor: selection.anchor, head: selection.head} },
29
42
  scroll_x: current.scroll_x, scroll_y: current.scroll_y}
30
43
  end}
31
44
  end, buffers: records}
32
- state[:background_buffers] = @buffers.values.select(&:dirty?).map { |buffer| record.call(buffer) }
45
+ state[:background_buffers] = @buffers.values.select(&:dirty?).filter_map { |buffer| record.call(buffer) }
46
+ if max_draft_bytes && state[:panes][state[:active_pane]][:tabs].empty?
47
+ state[:active_pane] = state[:panes].index { |pane| !pane[:tabs].empty? } || state[:active_pane]
48
+ end
33
49
  FileUtils.mkdir_p(File.dirname(path))
34
50
  Tempfile.create([".session-", ".json"], File.dirname(path)) do |file|
51
+ file.chmod(0o600)
35
52
  file.write(JSON.pretty_generate(state))
36
53
  file.flush
37
54
  file.fsync
@@ -69,6 +86,7 @@ module Canopus
69
86
  elsif entry["path"] && File.file?(entry["path"])
70
87
  buffer = Buffer.open(entry["path"])
71
88
  end
89
+ load_persistent_undo(buffer) if buffer && buffer.path && !buffer.dirty?
72
90
  loaded[id] = buffer
73
91
  if buffer
74
92
  key = buffer.path ? canonical_path(buffer.path) : buffer.object_id
@@ -146,10 +164,13 @@ module Canopus
146
164
  terminal_records = validate_session_terminals(data) if @settings["terminal"]["restore_on_startup"]
147
165
  clear_vim_states
148
166
  close_language_documents
167
+ @buffers.each_value { |buffer| detach_breakpoints(buffer) }
149
168
  @panes.each { |pane| pane.editors.each(&:dispose) }
150
169
  @buffers.each_value(&:close)
151
170
  @panels.restore(restored_panels, docks: restored_docks)
171
+ @panels.hide("hierarchy") if @panels.key?("hierarchy")
152
172
  @panes, @buffers, @layout = restored_panes, restored_buffers, restored_layout
173
+ @buffers.each_value { |buffer| attach_breakpoints(buffer) unless buffer.is_a?(MultiBuffer) }
153
174
  @active_pane = @panes[active]
154
175
  @recent_files = Array(data["recent_files"]).select { |item| item.is_a?(String) && File.file?(item) }.first(100)
155
176
  restore_terminals(data, terminal_records) if terminal_records
@@ -166,29 +187,80 @@ module Canopus
166
187
 
167
188
  def validate_session_terminals(data)
168
189
  records = data.fetch("terminals", [])
169
- raise Error, "invalid session terminals" unless records.is_a?(Array) && records.length <= 100 && records.all? { |item| item.is_a?(Hash) && item["cwd"].is_a?(String) }
190
+ valid = records.is_a?(Array) && records.length <= 100 && records.all? do |item|
191
+ item.is_a?(Hash) && item["cwd"].is_a?(String) && (item["profile"].nil? || item["profile"].is_a?(String))
192
+ end
193
+ raise Error, "invalid session terminals" unless valid
170
194
  active = data.fetch("active_terminal", 0)
171
195
  raise Error, "invalid active terminal" unless records.empty? || active.is_a?(Integer) && active.between?(0, records.length - 1)
196
+ if data["terminal_layout"]
197
+ visible = []
198
+ validate_terminal_layout(data["terminal_layout"], records.length, visible)
199
+ raise Error, "active terminal missing from layout" unless visible.include?(active)
200
+ end
172
201
  records
173
202
  end
174
203
 
175
204
  def restore_terminals(data, records)
176
- old, old_index, old_visible = @terminals, @active_terminal_index, terminal_visible
205
+ old, old_index, old_visible, old_layout = @terminals, @active_terminal_index, terminal_visible, @terminal_layout
206
+ old_profiles = @terminal_profiles
177
207
  @terminals = []
208
+ @terminal_profiles = {}.compare_by_identity
209
+ @terminal_layout = nil
178
210
  records.each do |record|
179
211
  cwd = File.directory?(record["cwd"]) ? record["cwd"] : @root
180
- created = new_terminal(cwd: cwd)
212
+ profile = record["profile"] if @settings["terminal"]["profiles"].key?(record["profile"])
213
+ created = new_terminal(cwd: cwd, profile: profile)
181
214
  rename_terminal(record["title"], created) if record["title"].is_a?(String)
182
215
  end
183
216
  @active_terminal_index = @terminals.empty? ? 0 : data.fetch("active_terminal", 0)
217
+ @terminal_layout = if @terminals.empty?
218
+ nil
219
+ elsif data["terminal_layout"]
220
+ decode_terminal_layout(data["terminal_layout"], @terminals)
221
+ else
222
+ {terminal: terminal}
223
+ end
224
+ invalidate_terminal_layout_resize
184
225
  self.terminal_visible = !!data["terminal_visible"] && !@terminals.empty?
185
226
  old.each { |current| current.close if current.respond_to?(:close) }
186
227
  rescue StandardError
187
228
  @terminals.each { |current| current.close if current.respond_to?(:close) }
188
229
  @terminals = old
189
230
  @active_terminal_index = old_index
231
+ @terminal_profiles = old_profiles
232
+ @terminal_layout = old_layout
190
233
  self.terminal_visible = old_visible
191
234
  @message = "Session restored; terminals could not start"
192
235
  end
236
+
237
+ def encode_terminal_layout(node)
238
+ return unless node
239
+ return {terminal: @terminals.index(node[:terminal])} if node[:terminal]
240
+ {direction: node[:direction], ratio: node.fetch(:ratio, 0.5),
241
+ children: node[:children].map { |child| encode_terminal_layout(child) }}
242
+ end
243
+
244
+ def validate_terminal_layout(node, count, seen = [], depth = 0)
245
+ raise Error, "invalid terminal layout" unless node.is_a?(Hash) && depth < 100
246
+ if node.key?("terminal")
247
+ index = node["terminal"]
248
+ raise Error, "invalid terminal layout" unless index.is_a?(Integer) && index.between?(0, count - 1) && !seen.include?(index)
249
+ seen << index
250
+ return true
251
+ end
252
+ direction, ratio, children = node.values_at("direction", "ratio", "children")
253
+ valid = %w[horizontal vertical].include?(direction) && ratio.is_a?(Numeric) && ratio.finite? &&
254
+ ratio.between?(0.1, 0.9) && children.is_a?(Array) && children.length == 2
255
+ raise Error, "invalid terminal layout" unless valid
256
+ children.each { |child| validate_terminal_layout(child, count, seen, depth + 1) }
257
+ true
258
+ end
259
+
260
+ def decode_terminal_layout(node, terminals)
261
+ return {terminal: terminals.fetch(node.fetch("terminal"))} if node.key?("terminal")
262
+ {direction: node.fetch("direction").to_sym, ratio: node.fetch("ratio"),
263
+ children: node.fetch("children").map { |child| decode_terminal_layout(child, terminals) }}
264
+ end
193
265
  end
194
266
  end