mbeditor 0.2.6 → 0.2.7

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: db6694abc38135f7512bba6ce762546b295b079a6e966448a29083b3f5609985
4
- data.tar.gz: fa9b0131d5f3309ae5f1f46e837e6bb72c8e378454d4194843b95ba6414e2e00
3
+ metadata.gz: 07df95cd9d266964f70a477f60b2bf31ae3f1b5f9fd2e24a407cf0ed8b677742
4
+ data.tar.gz: 2ddf51af23a7fc55466759f8349e0e56dd509777f29ef53653636b81157de562
5
5
  SHA512:
6
- metadata.gz: 64a1601edc2a1ee6e04854f3a4dff9aeefc400b2d6d91120972514c30a4ed19a7ccd6d44666892bc2043228ac3e48f5ec8096dd2cf620760161927a8ef2a5292
7
- data.tar.gz: b6d217f52ab8b0e820d78e43cd51861b4ca86dd02bde6b53f497bd7d743a17b1b405eda0aa540636de4db99b29fdc738322b08271c6093e49d8836eb5b8e76ab
6
+ metadata.gz: f61404e12d84ebea5e125ab3fd359d802160f8637de8c38256e571d471bfe823d060bb895bbcb495e803de4916bdf0a3e59c974e808fde67d1117a9905ecbea8
7
+ data.tar.gz: 705206cdada3581c56834307340ce9d184c2a299ed32fa788be97e5067d5fb3b5c0dea01f8fedba7ec1ea070b2c111b47210c1c800f8d7887886d8048eb5dc12
@@ -639,7 +639,7 @@ var EditorPanel = function EditorPanel(_ref) {
639
639
  if (onSaveRef.current) onSaveRef.current();
640
640
  });
641
641
  // Wire Ctrl+P to quick-open (VIM intercepts the key before the window listener sees it)
642
- MonacoVim.VimMode.Vim.defineEx('mbeditorquickopen', 'mbqo', function() {
642
+ MonacoVim.VimMode.Vim.defineEx('mbeditorquickopen', null, function() {
643
643
  EditorStore.setState({ isQuickOpenVisible: true });
644
644
  });
645
645
  MonacoVim.VimMode.Vim.map('<C-p>', ':mbeditorquickopen<CR>', 'normal');
@@ -695,6 +695,10 @@
695
695
  // Ruby method definition hover provider.
696
696
  // Calls the backend /definition endpoint (Ripper-based) and renders
697
697
  // the method signature and any preceding # comments as hover markdown.
698
+ // Results are cached client-side for 60 s to make re-hovers instantaneous.
699
+ var hoverCache = {};
700
+ var HOVER_CACHE_TTL_MS = 60000;
701
+
698
702
  monaco.languages.registerHoverProvider('ruby', {
699
703
  provideHover: function provideHover(model, position, token) {
700
704
  var wordInfo = model.getWordAtPosition(position);
@@ -707,51 +711,74 @@
707
711
 
708
712
  var currentFile = model._mbeditorPath || null;
709
713
 
710
- return FileService.getDefinition(word, 'ruby').then(function(data) {
714
+ // Return cached result immediately if still fresh.
715
+ var cached = hoverCache[word];
716
+ if (cached && (Date.now() - cached.ts) < HOVER_CACHE_TTL_MS) {
717
+ var cachedResults = cached.results;
718
+ if (currentFile) {
719
+ cachedResults = cachedResults.filter(function(r) { return r.file !== currentFile; });
720
+ }
721
+ return cachedResults.length > 0 ? buildHoverResult(cachedResults) : null;
722
+ }
723
+
724
+ // Cancel the underlying HTTP request when Monaco cancels the hover
725
+ // (e.g. user moved the mouse away before the response arrived).
726
+ var controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
727
+ if (controller && token && token.onCancellationRequested) {
728
+ token.onCancellationRequested(function() { controller.abort(); });
729
+ }
730
+ var extraOptions = controller ? { signal: controller.signal } : {};
731
+
732
+ return FileService.getDefinition(word, 'ruby', extraOptions).then(function(data) {
711
733
  // If the hover was cancelled while the request was in flight (e.g. the
712
734
  // user moved the mouse away), return null so Monaco's CancelablePromise
713
735
  // wrapper resolves cleanly instead of throwing "Canceled".
714
736
  if (token && token.isCancellationRequested) return null;
715
737
 
716
738
  var results = data && Array.isArray(data.results) ? data.results : [];
717
- // Filter out results that are in the file currently being edited —
718
- // showing a hover for a method you can already see adds no value.
739
+ // Cache the raw results (before current-file filter).
740
+ hoverCache[word] = { ts: Date.now(), results: results };
741
+
719
742
  if (currentFile) {
720
743
  results = results.filter(function(r) { return r.file !== currentFile; });
721
744
  }
722
745
  if (results.length === 0) return null;
723
746
 
724
- var first = results[0];
747
+ return buildHoverResult(results);
748
+ }).catch(function() { return null; });
749
+ }
750
+ });
751
+
752
+ function buildHoverResult(results) {
753
+ var first = results[0];
725
754
 
726
- // Build two separate MarkdownString sections so Monaco renders a
727
- // visual divider between the code block and the documentation.
728
- var codeParts = ['```ruby'];
755
+ // Build two separate MarkdownString sections so Monaco renders a
756
+ // visual divider between the code block and the documentation.
757
+ var codeParts = ['```ruby'];
729
758
 
730
- // Include a trimmed comment block as a Ruby comment inside the code
731
- // fence so the whole thing looks like source you'd read in an editor.
732
- if (first.comments && first.comments.length > 0) {
733
- first.comments.split('\n').forEach(function(l) {
734
- codeParts.push(l.trim() || '#');
735
- });
736
- }
759
+ // Include a trimmed comment block as a Ruby comment inside the code
760
+ // fence so the whole thing looks like source you'd read in an editor.
761
+ if (first.comments && first.comments.length > 0) {
762
+ first.comments.split('\n').forEach(function(l) {
763
+ codeParts.push(l.trim() || '#');
764
+ });
765
+ }
737
766
 
738
- codeParts.push(first.signature);
739
- codeParts.push('```');
767
+ codeParts.push(first.signature);
768
+ codeParts.push('```');
740
769
 
741
- var fileRef = first.line > 0 ? first.file + ':' + first.line : first.file;
742
- var locationParts = results.length > 1
743
- ? fileRef + ' _(+' + (results.length - 1) + ' more)_'
744
- : fileRef;
770
+ var fileRef = first.line > 0 ? first.file + ':' + first.line : first.file;
771
+ var locationParts = results.length > 1
772
+ ? fileRef + ' _(+' + (results.length - 1) + ' more)_'
773
+ : fileRef;
745
774
 
746
- return {
747
- contents: [
748
- { value: codeParts.join('\n'), isTrusted: true },
749
- { value: '<span style="opacity:0.55;font-size:0.9em;">' + locationParts + '</span>', isTrusted: true, supportHtml: true }
750
- ]
751
- };
752
- }).catch(function() { return null; });
753
- }
754
- });
775
+ return {
776
+ contents: [
777
+ { value: codeParts.join('\n'), isTrusted: true },
778
+ { value: '<span style="opacity:0.55;font-size:0.9em;">' + locationParts + '</span>', isTrusted: true, supportHtml: true }
779
+ ]
780
+ };
781
+ }
755
782
  }
756
783
 
757
784
  window.MbeditorEditorPlugins = {
@@ -71,8 +71,9 @@ var FileService = (function () {
71
71
  return axios.post(basePath() + '/state', { state: state }).then(function(res) { return res.data; });
72
72
  }
73
73
 
74
- function getDefinition(symbol, language) {
75
- return axios.get(basePath() + '/definition', { params: { symbol: symbol, language: language } }).then(function(res) { return res.data; });
74
+ function getDefinition(symbol, language, extraOptions) {
75
+ var config = Object.assign({ params: { symbol: symbol, language: language }, timeout: 5000 }, extraOptions || {});
76
+ return axios.get(basePath() + '/definition', config).then(function(res) { return res.data; });
76
77
  }
77
78
 
78
79
  return {
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "find"
4
+ require "json"
4
5
  require "ripper"
5
6
 
6
7
  module Mbeditor
@@ -22,10 +23,72 @@ module Mbeditor
22
23
  MAX_RESULTS = 20
23
24
  MAX_COMMENT_LOOKAHEAD = 15
24
25
 
25
- def self.call(workspace_root, symbol, excluded_dirnames: [], excluded_paths: [])
26
- new(workspace_root, symbol,
27
- excluded_dirnames: excluded_dirnames,
28
- excluded_paths: excluded_paths).call
26
+ # In-process file-index cache.
27
+ # Structure: { absolute_path => { mtime: Float, lines: [String], all_defs: { method_name => [line, ...] } } }
28
+ # Protected by a Mutex; entries are invalidated lazily via mtime comparison.
29
+ # When +cache_path+ is set (done automatically by the engine initializer), the
30
+ # cache is persisted to disk as JSON so it survives process restarts.
31
+ @file_cache = {}
32
+ @mutex = Mutex.new
33
+ @cache_path = nil
34
+ @cache_loaded = false
35
+
36
+ class << self
37
+ attr_reader :file_cache, :mutex
38
+ attr_accessor :cache_path
39
+
40
+ def call(workspace_root, symbol, excluded_dirnames: [], excluded_paths: [])
41
+ new(workspace_root, symbol,
42
+ excluded_dirnames: excluded_dirnames,
43
+ excluded_paths: excluded_paths).call
44
+ end
45
+
46
+ # Load the JSON cache from disk exactly once per process (double-checked
47
+ # under the mutex so concurrent first-calls don't double-load).
48
+ def load_disk_cache_once
49
+ return if @cache_loaded
50
+
51
+ @mutex.synchronize do
52
+ return if @cache_loaded
53
+
54
+ @cache_loaded = true
55
+ path = @cache_path.to_s
56
+ return if path.empty? || !File.exist?(path)
57
+
58
+ raw = JSON.parse(File.read(path))
59
+ raw.each do |abs_path, entry|
60
+ @file_cache[abs_path] = {
61
+ mtime: entry["mtime"].to_f,
62
+ lines: entry["lines"],
63
+ all_defs: entry["all_defs"]
64
+ }
65
+ end
66
+ rescue StandardError
67
+ nil # corrupted or incompatible cache file — start fresh
68
+ end
69
+ end
70
+
71
+ # Atomically write the in-memory cache to disk (tmp-file + rename).
72
+ def persist_cache
73
+ path = @cache_path.to_s
74
+ return if path.empty?
75
+
76
+ snapshot = @mutex.synchronize { @file_cache.dup }
77
+ tmp_path = "#{path}.tmp"
78
+ File.write(tmp_path, JSON.generate(snapshot))
79
+ File.rename(tmp_path, path)
80
+ rescue StandardError
81
+ nil
82
+ end
83
+
84
+ # Exposed for tests.
85
+ def clear_cache!
86
+ @mutex.synchronize { @file_cache.clear; @cache_loaded = false }
87
+ path = @cache_path.to_s
88
+ File.delete(path) if !path.empty? && File.exist?(path)
89
+ rescue StandardError
90
+ nil
91
+ end
29
92
  end
30
93
 
31
94
  def initialize(workspace_root, symbol, excluded_dirnames: [], excluded_paths: [])
@@ -36,7 +99,10 @@ module Mbeditor
36
99
  end
37
100
 
38
101
  def call
39
- results = []
102
+ self.class.load_disk_cache_once
103
+
104
+ results = []
105
+ @new_entries = false
40
106
 
41
107
  Find.find(@workspace_root) do |path|
42
108
  # Prune excluded directories
@@ -55,16 +121,18 @@ module Mbeditor
55
121
  next if excluded_rel_path?(rel, File.basename(path))
56
122
 
57
123
  begin
58
- source = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace)
59
- lines = source.split("\n", -1)
60
- hits = find_definitions(source, @symbol)
124
+ cached = cache_entry_for(path)
125
+ next unless cached
61
126
 
62
- hits.each do |def_line|
127
+ hit_lines = cached[:all_defs][@symbol]
128
+ next unless hit_lines && hit_lines.any?
129
+
130
+ hit_lines.each do |def_line|
63
131
  results << {
64
132
  file: rel,
65
133
  line: def_line,
66
- signature: (lines[def_line - 1] || "").strip,
67
- comments: extract_comments(lines, def_line)
134
+ signature: (cached[:lines][def_line - 1] || "").strip,
135
+ comments: extract_comments(cached[:lines], def_line)
68
136
  }
69
137
  return results if results.length >= MAX_RESULTS
70
138
  end
@@ -73,50 +141,61 @@ module Mbeditor
73
141
  end
74
142
  end
75
143
 
144
+ self.class.persist_cache if @new_entries
76
145
  results
77
146
  end
78
147
 
79
148
  private
80
149
 
81
- # Parse `source` with Ripper and return sorted array of 1-based line numbers
82
- # where a method named `symbol` is defined (handles both `def foo` and `def self.foo`).
83
- def find_definitions(source, symbol)
84
- sexp = Ripper.sexp(source)
85
- return [] unless sexp
150
+ # Returns the cached index entry for +path+, rebuilding it if the file has
151
+ # been modified since the last parse. Returns nil on any read/parse error.
152
+ def cache_entry_for(path)
153
+ mtime = File.mtime(path).to_f
154
+ cached = self.class.mutex.synchronize { self.class.file_cache[path] }
155
+ return cached if cached && cached[:mtime] == mtime
156
+
157
+ source = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace)
158
+ lines = source.split("\n", -1)
159
+ sexp = Ripper.sexp(source)
160
+ all_defs = sexp ? collect_all_defs(sexp) : {}
161
+ entry = { mtime: mtime, lines: lines, all_defs: all_defs }
162
+ self.class.mutex.synchronize { self.class.file_cache[path] = entry }
163
+ @new_entries = true
164
+ entry
165
+ rescue StandardError
166
+ nil
167
+ end
86
168
 
87
- lines = []
88
- walk(sexp, symbol, lines)
89
- lines.sort
169
+ # Walk the Ripper sexp once and collect every method definition, returning
170
+ # a hash of the form { "method_name" => [line_number, ...] }.
171
+ def collect_all_defs(sexp)
172
+ defs = Hash.new { |h, k| h[k] = [] }
173
+ walk_all(sexp, defs)
174
+ defs
90
175
  end
91
176
 
92
- # Recursive sexp walker. Ripper represents:
93
- # def foo(...) as [:def, [:@ident, "foo", [line, col]], ...]
94
- # def self.foo as [:defs, receiver, [:@ident, "foo", [line, col]], ...]
95
- def walk(node, symbol, lines)
177
+ def walk_all(node, defs)
96
178
  return unless node.is_a?(Array)
97
179
 
98
180
  case node[0]
99
181
  when :def
100
- # node[1] is the method name node: [:@ident, "name", [line, col]]
101
182
  name_node = node[1]
102
- if name_node.is_a?(Array) && name_node[1].to_s == symbol
183
+ if name_node.is_a?(Array) && name_node[1].is_a?(String)
103
184
  line = name_node[2]&.first
104
- lines << line if line
185
+ defs[name_node[1]] << line if line
105
186
  end
106
- # Still walk children in case of nested defs
107
- node[1..].each { |child| walk(child, symbol, lines) }
187
+ node[1..].each { |child| walk_all(child, defs) }
108
188
 
109
189
  when :defs
110
- # node[3] is the method name node for `def self.foo`
111
190
  name_node = node[3]
112
- if name_node.is_a?(Array) && name_node[1].to_s == symbol
191
+ if name_node.is_a?(Array) && name_node[1].is_a?(String)
113
192
  line = name_node[2]&.first
114
- lines << line if line
193
+ defs[name_node[1]] << line if line
115
194
  end
116
- node[1..].each { |child| walk(child, symbol, lines) }
195
+ node[1..].each { |child| walk_all(child, defs) }
117
196
 
118
197
  else
119
- node.each { |child| walk(child, symbol, lines) }
198
+ node.each { |child| walk_all(child, defs) }
120
199
  end
121
200
  end
122
201
 
@@ -8,6 +8,11 @@ module Mbeditor
8
8
  app.middleware.insert_before Rails::Rack::Logger, Mbeditor::Rack::SilencePingRequest
9
9
  end
10
10
 
11
+ config.after_initialize do
12
+ Mbeditor::RubyDefinitionService.cache_path =
13
+ Rails.root.join("tmp", "mbeditor_ruby_defs.json").to_s
14
+ end
15
+
11
16
  initializer "mbeditor.assets.precompile" do |app|
12
17
  app.config.assets.precompile += %w[
13
18
  mbeditor/application.css
@@ -1,3 +1,3 @@
1
1
  module Mbeditor
2
- VERSION = "0.2.6"
2
+ VERSION = "0.2.7"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mbeditor
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.6
4
+ version: 0.2.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Oliver Noonan