mbeditor 0.8.1 → 0.10.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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +85 -0
  3. data/README.md +31 -0
  4. data/app/assets/javascripts/mbeditor/application.js +3 -0
  5. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +281 -60
  6. data/app/assets/javascripts/mbeditor/components/LogPanel.js +50 -1
  7. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +91 -16
  8. data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +217 -0
  9. data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +34 -3
  10. data/app/assets/javascripts/mbeditor/editor_plugins.js +112 -52
  11. data/app/assets/javascripts/mbeditor/git_service.js +8 -0
  12. data/app/assets/javascripts/mbeditor/js_outline.js +110 -0
  13. data/app/assets/javascripts/mbeditor/ruby_outline.js +427 -0
  14. data/app/assets/stylesheets/mbeditor/editor.css +224 -5
  15. data/app/assets/stylesheets/mbeditor/themes.css +35 -0
  16. data/app/controllers/mbeditor/application_controller.rb +5 -11
  17. data/app/controllers/mbeditor/editors_controller.rb +34 -5
  18. data/app/controllers/mbeditor/git_controller.rb +11 -0
  19. data/app/services/mbeditor/exclusion_matcher.rb +105 -3
  20. data/app/services/mbeditor/file_tree_service.rb +1 -1
  21. data/app/services/mbeditor/git_line_diff_service.rb +99 -0
  22. data/app/services/mbeditor/git_service.rb +3 -10
  23. data/app/services/mbeditor/ruby_definition_service.rb +1 -1
  24. data/app/services/mbeditor/safe_path.rb +57 -0
  25. data/app/services/mbeditor/search_replace_service.rb +2 -2
  26. data/lib/mbeditor/configuration.rb +2 -1
  27. data/lib/mbeditor/engine.rb +3 -0
  28. data/lib/mbeditor/file_watcher.rb +136 -0
  29. data/lib/mbeditor/route_map.rb +1 -0
  30. data/lib/mbeditor/version.rb +1 -1
  31. metadata +8 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2f74579627da44a31db150cb5de08c5060ccad0a68e9ac47603039b84849fff2
4
- data.tar.gz: d2fa6b611b1a1804e26748281f367bd3f2dfc20796d0459defb9b05d65583fa1
3
+ metadata.gz: 1fe258515a6f0945da8bfcbfbe85197262af224c054924367597d97d370530e2
4
+ data.tar.gz: d3f65b3c351fef56291170d9401103d9b73d4b3a7ebad24885713b8deef5df85
5
5
  SHA512:
6
- metadata.gz: ab9d1c840c0153f07f3f1989874bca8025d59921615597be5eae9ba83b5eb9c536a554d50bb934c4d2c6202d8d89b119179241cba67c4c21bdf81f98491546d3
7
- data.tar.gz: e5328c15e5c250c55a0d02eba2a38e2d752bf8c813d9e740181cd3e0ff86e5d79fa1bfb2e5cb5ad566328cd6b17b23faf2952edc0eed4564ec1311b86ef649f3
6
+ metadata.gz: 4de0d8f9374a370f4df1339166ff648013dafb07b23eeba6936f3ebf1d492c831905d7143d5ece2ece4e397f4f4b314b7461e496cec00003c507db5701347604
7
+ data.tar.gz: 17c4ef992175fc0a5fd8f18102214ac46cce75963ba7d06661f3ffac66eb7e7d55e69bef6c6f71e3ddb9999839c78fd9a2668abb99397442ac7bfb68da6ee561
data/CHANGELOG.md CHANGED
@@ -5,6 +5,91 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.10.0] - 2026-07-27
9
+
10
+ ### Added
11
+ - **Git-status line numbers** — line numbers are tinted by what git thinks of
12
+ the line: green for added, orange for modified, red on the line a deleted
13
+ block sat after. Backed by a new `GET /mbeditor/git/line_diff` endpoint over
14
+ `git diff -U0`, and never a reason to fail a file load: a missing HEAD or a
15
+ non-repo simply leaves the numbers plain.
16
+ - **Problems panel** — error and warning counts in the status bar open a
17
+ drawer listing every diagnostic across the open files, grouped by file with
18
+ the offending source line and click-to-navigate. Counts cover the open tabs,
19
+ which is where Monaco's markers live; RuboCop, ruby-lsp and the TypeScript
20
+ worker all feed it through one subscription.
21
+ - **Outline for JS, JSX and TypeScript** — the Methods/Outline button now
22
+ works in those files, translating the TypeScript worker's own navigation
23
+ tree rather than adding a second lexer. Arrow functions and function
24
+ expressions assigned to variables are listed; data constants, object-literal
25
+ keys and anonymous callbacks are not.
26
+ - **Colour-coded Rails log** — the log drawer distinguishes request
27
+ boundaries, controller dispatch, SQL, renders, redirects and failures, with
28
+ `Completed` lines coloured by status code.
29
+ - **Optional workspace file watching** — with the host's
30
+ [`listen`](https://github.com/guard/listen) gem, changes made outside the
31
+ editor (a terminal `git checkout`, a generator, another editor) refresh the
32
+ file tree and git decorations instead of going stale. Absent the gem,
33
+ behaviour is unchanged. Configurable via `config.watch_files`.
34
+
35
+ ### Fixed
36
+ - **ruby-lsp hover showed a dead link.** ruby-lsp renders its "Definitions"
37
+ line as VS Code `file://` links, which Monaco draws as links but nothing in
38
+ the browser can open, so clicking did nothing. In-workspace links are now
39
+ rewritten to a Monaco command that opens the file at the line; gem and
40
+ stdlib links, which the editor cannot open at all, degrade to plain code
41
+ spans. The `/module_members` breakdown is also restored beneath a constant
42
+ hover — ruby-lsp's constant hover never lists what the class or module
43
+ defines, so taking its output wholesale had dropped it.
44
+ - **False-positive type errors in plain JS/JSX.** The editor kept a denylist
45
+ of TypeScript diagnostic codes to suppress; it had grown to eight and still
46
+ leaked (TS2322 on a spread carrying an extra prop). Untyped JS gives
47
+ TypeScript nothing to check against, and which way it guesses is arbitrary —
48
+ state seeded with `useState({})` errors on every key while the same object
49
+ from `JSON.parse` stays silent. JS/JSX now keeps only the checks that are
50
+ sound without annotations: syntax errors, `Cannot find name`, and unused
51
+ locals. `.ts`/`.tsx` keeps full checking, where the types are hand-written.
52
+
53
+ ### Changed
54
+ - The **Logs** button moved from the top toolbar to the status bar.
55
+ - **Quick-open ranks recently opened files** above the static file-type tier,
56
+ so a file you were just in outranks a never-opened controller. Match quality
57
+ still comes first, so a worse match cannot jump the queue.
58
+ - The title-bar search field now fills 75% of the space between the title and
59
+ the toolbar buttons instead of a fixed 340px.
60
+ - Test-suite compatibility fixes carried over from the unreleased 0.9.1/0.9.2
61
+ work: MiniRacer-dependent parser suites skip in minimal bundles, and the
62
+ Outline system tests exercise navigation through click semantics.
63
+
64
+ ---
65
+
66
+ ## [0.9.0] - 2026-07-23
67
+
68
+ ### Added
69
+ - **Test-aware Ruby Outline** — the editor now recognises Minitest and RSpec
70
+ suites, tests, and helper methods in `_test.rb` and `_spec.rb` files. It
71
+ reads the unsaved Monaco buffer, preserves nested suite depth and method
72
+ visibility, supports keyboard navigation, and safely reports truncation or
73
+ parser failures without disrupting the editor.
74
+ - **Ruby test-DSL highlighting** — Minitest/RSpec declarations, hooks, and
75
+ helpers now receive structural Monaco tokens while Ruby files retain the
76
+ existing `ruby` language identity and integrations.
77
+
78
+ ### Security
79
+ - **Dangling symlink containment** — every editor and Git path check now
80
+ rejects dangling symlinks that resolve outside the workspace or repository,
81
+ closing a write-through escape for create, save, rename, and directory
82
+ operations.
83
+ - **Filesystem-aware exclusions** — exclusion matching now normalizes Unicode
84
+ spellings and folds case only on case-insensitive filesystems, preventing
85
+ case or normalization variants from reaching excluded directories such as
86
+ `.git`.
87
+
88
+ ### Changed
89
+ - Generated `graphify-out/` artifacts are ignored by Git.
90
+
91
+ ---
92
+
8
93
  ## [0.8.1] - 2026-07-22
9
94
 
10
95
  ### Fixed
data/README.md CHANGED
@@ -9,9 +9,14 @@ Mbeditor (Mini Browser Editor) is a mountable Rails engine that adds a browser-b
9
9
  - Two-pane tabbed editor with drag-to-move tabs
10
10
  - File tree and project search
11
11
  - Git panel with working tree changes, unpushed file changes, and branch commit titles
12
+ - Line numbers tinted by git status — green for added lines, orange for modified, red where lines were removed
13
+ - Problems panel with error/warning counts in the status bar, listing every diagnostic across the open files
14
+ - Outline dropdown for Ruby (methods, and test/spec structure in test files) and for JS/JSX/TS
15
+ - Colour-coded Rails log drawer
12
16
  - Optional RuboCop lint and format endpoints (uses host app RuboCop)
13
17
  - Optional Ruby language-server integration (definitions, hover, completion, diagnostics)
14
18
  - Optional test runner with inline failure markers and a dedicated results panel (Minitest and RSpec)
19
+ - Optional workspace file watching, so changes made outside the editor refresh the tree and git decorations
15
20
 
16
21
  ## Security Warning
17
22
  Mbeditor exposes read and write access to your Rails application directory over HTTP. It is intended only for local development.
@@ -96,6 +101,7 @@ end
96
101
  | `ruby_lsp` | `:auto` | Use the host's [ruby-lsp](https://github.com/Shopify/ruby-lsp) for Ruby go-to-definition, hover, completion, and diagnostics when it's installed (a persistent process is managed per workspace). `false` disables. Without ruby-lsp everything degrades to the built-in grep/Ripper services — no behavior change. |
97
102
  | `ruby_lsp_command` | `nil` | Override the ruby-lsp launch command (String or Array). `nil` auto-resolves `bin/ruby-lsp` → installed gem → `bundle exec ruby-lsp`. |
98
103
  | `ruby_lsp_timeout` | `3` | Seconds per LSP request; on timeout (e.g. during initial indexing) the editor falls back to the built-in services for that request. |
104
+ | `watch_files` | `:auto` | Watch the workspace for changes made outside the editor (a terminal `git checkout`, a generator, another editor) and push a refresh to open clients. Requires the host's [`listen`](https://github.com/guard/listen) gem; without it the editor behaves as before and only announces its own writes. `false` disables. |
99
105
 
100
106
  ### Authentication
101
107
 
@@ -199,9 +205,34 @@ The gem keeps host/tooling responsibilities in the host app:
199
205
  - `minitest` or `rspec` in the host app's bundle (required for the test runner)
200
206
  - `actioncable` framework/gem (optional, required only for realtime file-change push + websocket state saves)
201
207
  - `ruby-lsp` gem (optional — see below)
208
+ - `listen` gem (optional — see below)
202
209
 
203
210
  All lint and test tools are auto-detected at runtime. The engine gracefully disables features if the tools are not available. Neither `rubocop`, `haml_lint`, nor any test framework are runtime dependencies of the gem itself — they are discovered from the host app's environment.
204
211
 
212
+ ### Workspace file watching (Optional)
213
+
214
+ Add [`listen`](https://github.com/guard/listen) to the host app's development
215
+ group and mbeditor watches the workspace for changes it did not make itself:
216
+
217
+ ```ruby
218
+ group :development do
219
+ gem 'listen'
220
+ end
221
+ ```
222
+
223
+ Without it, only writes made *through* the editor announce themselves, so a
224
+ `git checkout` or a generator run in a terminal leaves the file tree and the
225
+ git line-number colours stale until you reopen the file. With it, those refresh
226
+ on their own.
227
+
228
+ The watcher runs only in `allowed_environments` and only in processes that
229
+ serve requests, so rake tasks and consoles never start one. It respects
230
+ `excluded_paths`, coalesces bursts (a branch switch is one refresh, not
231
+ hundreds), and requires Action Cable to actually deliver the push. If it cannot
232
+ start — inotify limits, permissions — it logs a warning and the editor carries
233
+ on exactly as it would without the gem. Set `config.watch_files = false` to
234
+ disable it even when `listen` is present.
235
+
205
236
  ### Ruby language server (Optional)
206
237
 
207
238
  Add [ruby-lsp](https://github.com/Shopify/ruby-lsp) to the host app's
@@ -11,6 +11,8 @@
11
11
  //= require mbeditor/tab_manager
12
12
  //= require mbeditor/color_provider
13
13
  //= require mbeditor/editor_plugins
14
+ //= require mbeditor/ruby_outline
15
+ //= require mbeditor/js_outline
14
16
  //= require mbeditor/components/CollapsibleSection
15
17
  //= require mbeditor/components/ShortcutHelp
16
18
  //= require mbeditor/components/DiffViewer
@@ -20,6 +22,7 @@
20
22
  //= require mbeditor/components/FileHistoryPanel
21
23
  //= require mbeditor/components/TestResultsPanel
22
24
  //= require mbeditor/components/LogPanel
25
+ //= require mbeditor/components/ProblemsPanel
23
26
  //= require mbeditor/components/CodeReviewPanel
24
27
  //= require mbeditor/components/EditorPanel
25
28
  //= require mbeditor/components/FileTree
@@ -69,6 +69,7 @@ var EditorPanel = function EditorPanel(_ref) {
69
69
  var setIsBlameLoading = _useState8[1];
70
70
 
71
71
  var blameDecorationsRef = useRef([]);
72
+ var gitLineDecorationsRef = useRef([]);
72
73
  var blameZoneIdsRef = useRef([]);
73
74
  var testDecorationIdsRef = useRef([]);
74
75
  var testZoneIdsRef = useRef([]);
@@ -88,33 +89,46 @@ var EditorPanel = function EditorPanel(_ref) {
88
89
  var methodsList = _useState14[0];
89
90
  var setMethodsList = _useState14[1];
90
91
 
91
- var _useState15 = useState(null);
92
+ var _useState15 = useState(false);
92
93
  var _useState16 = _slicedToArray(_useState15, 2);
93
- var methodsDropdownPos = _useState16[0];
94
- var setMethodsDropdownPos = _useState16[1];
94
+ var methodsTruncated = _useState16[0];
95
+ var setMethodsTruncated = _useState16[1];
95
96
 
96
- var methodsBtnRef = useRef(null);
97
-
98
- // Local pagination state — initialized from tab props; updated on page navigation
99
- var _useState17 = useState(tab.startLine || 0);
97
+ var _useState17 = useState(false);
100
98
  var _useState18 = _slicedToArray(_useState17, 2);
101
- var pageStartLine = _useState18[0];
102
- var setPageStartLine = _useState18[1];
99
+ var methodsUnavailable = _useState18[0];
100
+ var setMethodsUnavailable = _useState18[1];
103
101
 
104
- var _useState19 = useState(tab.lineCount || 0);
102
+ var _useState19 = useState(null);
105
103
  var _useState20 = _slicedToArray(_useState19, 2);
106
- var pageLineCount = _useState20[0];
107
- var setPageLineCount = _useState20[1];
104
+ var methodsDropdownPos = _useState20[0];
105
+ var setMethodsDropdownPos = _useState20[1];
106
+
107
+ var methodsBtnRef = useRef(null);
108
+ var methodsDropdownRef = useRef(null);
109
+ // Discards a JS outline that resolves after the dropdown was closed or reopened.
110
+ var methodsRequestRef = useRef(0);
108
111
 
109
- var _useState21 = useState(tab.totalLines || 0);
112
+ // Local pagination state — initialized from tab props; updated on page navigation
113
+ var _useState21 = useState(tab.startLine || 0);
110
114
  var _useState22 = _slicedToArray(_useState21, 2);
111
- var pageTotalLines = _useState22[0];
112
- var setPageTotalLines = _useState22[1];
115
+ var pageStartLine = _useState22[0];
116
+ var setPageStartLine = _useState22[1];
113
117
 
114
- var _useState23 = useState(tab.totalBytes || 0);
118
+ var _useState23 = useState(tab.lineCount || 0);
115
119
  var _useState24 = _slicedToArray(_useState23, 2);
116
- var pageTotalBytes = _useState24[0];
117
- var setPageTotalBytes = _useState24[1];
120
+ var pageLineCount = _useState24[0];
121
+ var setPageLineCount = _useState24[1];
122
+
123
+ var _useState25 = useState(tab.totalLines || 0);
124
+ var _useState26 = _slicedToArray(_useState25, 2);
125
+ var pageTotalLines = _useState26[0];
126
+ var setPageTotalLines = _useState26[1];
127
+
128
+ var _useState27 = useState(tab.totalBytes || 0);
129
+ var _useState28 = _slicedToArray(_useState27, 2);
130
+ var pageTotalBytes = _useState28[0];
131
+ var setPageTotalBytes = _useState28[1];
118
132
 
119
133
  var onFormatRef = useRef(onFormat);
120
134
  onFormatRef.current = onFormat;
@@ -1270,6 +1284,80 @@ var EditorPanel = function EditorPanel(_ref) {
1270
1284
  }
1271
1285
  }, [tab.path]);
1272
1286
 
1287
+ // Tint line numbers by git status: green added, orange modified, red where
1288
+ // lines were removed.
1289
+ //
1290
+ // The ranges come from `git diff -U0` against HEAD, so they describe the file
1291
+ // as last written to disk. Monaco anchors decorations to the model and shifts
1292
+ // them as you type, which keeps them roughly right mid-edit; the authoritative
1293
+ // refresh happens when the file lands on disk and the server broadcasts it.
1294
+ useEffect(function () {
1295
+ if (!gitAvailable || !tab.path || tab.isDiff || tab.isCombinedDiff) return;
1296
+
1297
+ var cancelled = false;
1298
+
1299
+ function clear() {
1300
+ if (!monacoRef.current || !monacoRef.current.getModel()) return;
1301
+ gitLineDecorationsRef.current =
1302
+ monacoRef.current.deltaDecorations(gitLineDecorationsRef.current, []);
1303
+ }
1304
+
1305
+ function apply(data) {
1306
+ var editor = monacoRef.current;
1307
+ var model = editor && editor.getModel();
1308
+ if (!model || !window.monaco) return;
1309
+
1310
+ var lineCount = model.getLineCount();
1311
+ var decorations = [];
1312
+
1313
+ function push(ranges, className) {
1314
+ (ranges || []).forEach(function (range) {
1315
+ // A deletion above the first line is reported as line 0; mark line 1
1316
+ // so the file's opening line carries the flag.
1317
+ var start = Math.max(1, Math.min(range.start, lineCount));
1318
+ var end = Math.max(start, Math.min(range.end, lineCount));
1319
+ for (var line = start; line <= end; line++) {
1320
+ decorations.push({
1321
+ range: new window.monaco.Range(line, 1, line, 1),
1322
+ options: { isWholeLine: true, lineNumberClassName: className }
1323
+ });
1324
+ }
1325
+ });
1326
+ }
1327
+
1328
+ push(data.added, 'mbeditor-gitline-added');
1329
+ push(data.modified, 'mbeditor-gitline-modified');
1330
+ push(data.deleted, 'mbeditor-gitline-deleted');
1331
+
1332
+ gitLineDecorationsRef.current =
1333
+ editor.deltaDecorations(gitLineDecorationsRef.current, decorations);
1334
+ }
1335
+
1336
+ function refresh() {
1337
+ var path = tab.path;
1338
+ GitService.fetchLineDiff(path).then(function (data) {
1339
+ // The tab can be switched or closed while the request is in flight.
1340
+ if (cancelled || !data || tab.path !== path) return;
1341
+ apply(data);
1342
+ }).catch(function () {
1343
+ // Not a repo, file not readable, git missing — leave the numbers plain.
1344
+ if (!cancelled) clear();
1345
+ });
1346
+ }
1347
+
1348
+ refresh();
1349
+
1350
+ var hasSocket = typeof WebSocketService !== 'undefined' && WebSocketService.onFilesChanged;
1351
+ var onChanged = hasSocket ? function () { refresh(); } : null;
1352
+ if (onChanged) WebSocketService.onFilesChanged(onChanged);
1353
+
1354
+ return function () {
1355
+ cancelled = true;
1356
+ if (onChanged && WebSocketService.offFilesChanged) WebSocketService.offFilesChanged(onChanged);
1357
+ clear();
1358
+ };
1359
+ }, [tab.path, tab.externalContentVersion, tab.isDiff, tab.isCombinedDiff, gitAvailable]);
1360
+
1273
1361
  // Handle Blame data fetching
1274
1362
  useEffect(function () {
1275
1363
  if (!isBlameVisible) {
@@ -1601,6 +1689,15 @@ var EditorPanel = function EditorPanel(_ref) {
1601
1689
  var fileBaseName = (tab.path || '').split('/').pop().toLowerCase();
1602
1690
  var isRubyFile = ext === 'rb' || ext === 'ruby' || ext === 'gemspec' || ext === 'rake' ||
1603
1691
  fileBaseName === 'gemfile' || fileBaseName === 'gemfile.lock' || fileBaseName === 'rakefile';
1692
+ var isJsFile = ['js', 'jsx', 'mjs', 'cjs', 'ts', 'tsx'].indexOf(ext) !== -1;
1693
+ var hasOutline = isRubyFile || isJsFile;
1694
+ var isTestOutline = false;
1695
+ try {
1696
+ isTestOutline = isRubyFile && window.RubyOutline &&
1697
+ typeof window.RubyOutline.isTestPath === 'function' && window.RubyOutline.isTestPath(tab.path);
1698
+ } catch (err) {
1699
+ isTestOutline = false;
1700
+ }
1604
1701
 
1605
1702
  useEffect(function () {
1606
1703
  if (isMarkdown && window.marked) {
@@ -1638,8 +1735,8 @@ var EditorPanel = function EditorPanel(_ref) {
1638
1735
  if (!methodsOpen) return;
1639
1736
  function handleClickOutside(e) {
1640
1737
  var btn = methodsBtnRef.current;
1641
- // Close if click is not on the button (the dropdown uses onMouseDown with preventDefault)
1642
- if (btn && !btn.contains(e.target)) {
1738
+ var dropdown = methodsDropdownRef.current;
1739
+ if (btn && !btn.contains(e.target) && (!dropdown || !dropdown.contains(e.target))) {
1643
1740
  setMethodsOpen(false);
1644
1741
  }
1645
1742
  }
@@ -1667,20 +1764,41 @@ var EditorPanel = function EditorPanel(_ref) {
1667
1764
  });
1668
1765
  }
1669
1766
 
1670
- // Parse all method definitions from the current Monaco model
1671
- function parseRubyMethods(model) {
1672
- var methods = [];
1767
+ // Parse the current Monaco buffer so unsaved Ruby test declarations appear too.
1768
+ function parseRubyOutline(model, path) {
1673
1769
  var lineCount = model.getLineCount();
1674
- var DEF_RE = /^\s*def\s+(self\.)?([a-zA-Z_][a-zA-Z0-9_?!=]*)/;
1770
+ var lines = [];
1675
1771
  for (var i = 1; i <= lineCount; i++) {
1676
- var line = model.getLineContent(i);
1677
- var m = DEF_RE.exec(line);
1678
- if (m) {
1679
- var selfPrefix = m[1] ? 'self.' : '';
1680
- methods.push({ line: i, name: selfPrefix + m[2] });
1681
- }
1772
+ lines.push(model.getLineContent(i));
1682
1773
  }
1683
- return methods;
1774
+ return window.RubyOutline.parse(lines, { path: path });
1775
+ }
1776
+
1777
+ // The JS/TS outline comes from the TypeScript worker rather than a lexer of
1778
+ // our own, which makes it async — the worker is where the parse already
1779
+ // lives, and it tracks the live buffer, so unsaved edits are included.
1780
+ function loadJsOutline(model) {
1781
+ var ts = window.monaco && window.monaco.languages && window.monaco.languages.typescript;
1782
+ if (!ts || !window.JsOutline) return Promise.reject(new Error('outline unavailable'));
1783
+
1784
+ var workerFor = model.getLanguageId() === 'typescript' ? ts.getTypeScriptWorker : ts.getJavaScriptWorker;
1785
+ return workerFor().then(function (getWorker) {
1786
+ return getWorker(model.uri);
1787
+ }).then(function (client) {
1788
+ return client.getNavigationTree(model.uri.toString());
1789
+ }).then(function (tree) {
1790
+ return window.JsOutline.fromNavigationTree(tree, {
1791
+ lineAt: function (offset) { return model.getPositionAt(offset).lineNumber; },
1792
+ textAt: function (offset, length) {
1793
+ var start = model.getPositionAt(offset);
1794
+ var end = model.getPositionAt(offset + length);
1795
+ return model.getValueInRange({
1796
+ startLineNumber: start.lineNumber, startColumn: start.column,
1797
+ endLineNumber: end.lineNumber, endColumn: end.column
1798
+ });
1799
+ }
1800
+ });
1801
+ });
1684
1802
  }
1685
1803
 
1686
1804
  if (tab.fileNotFound) {
@@ -1822,27 +1940,60 @@ var EditorPanel = function EditorPanel(_ref) {
1822
1940
  React.createElement('i', { className: 'fas fa-history', style: { marginRight: editorPrefs.toolbarIconOnly ? 0 : '5px', flexShrink: 0 } }),
1823
1941
  !editorPrefs.toolbarIconOnly && React.createElement('span', { className: 'ide-toolbar-label' }, 'History')
1824
1942
  ),
1825
- isRubyFile && React.createElement(
1943
+ hasOutline && React.createElement(
1826
1944
  'button',
1827
1945
  {
1828
1946
  ref: methodsBtnRef,
1829
1947
  className: 'ide-icon-btn' + (methodsOpen ? ' active' : ''),
1830
1948
  onClick: function() {
1831
- var nextOpen = !methodsOpen;
1832
- if (nextOpen) {
1833
- var model = monacoRef.current && monacoRef.current.getModel();
1834
- setMethodsList(model ? parseRubyMethods(model) : []);
1949
+ // Bump first: a click that closes the dropdown must also cancel an
1950
+ // outline request still in flight from the click that opened it.
1951
+ var requestId = ++methodsRequestRef.current;
1952
+ if (methodsOpen) {
1953
+ setMethodsOpen(false);
1954
+ return;
1955
+ }
1956
+
1957
+ var model = monacoRef.current && monacoRef.current.getModel();
1958
+
1959
+ function openWith(entries, truncated, unavailable) {
1960
+ setMethodsList(entries);
1961
+ setMethodsTruncated(truncated);
1962
+ setMethodsUnavailable(unavailable);
1835
1963
  if (methodsBtnRef.current) {
1836
1964
  var rect = methodsBtnRef.current.getBoundingClientRect();
1837
1965
  setMethodsDropdownPos({ top: rect.bottom + 4, right: window.innerWidth - rect.right });
1838
1966
  }
1967
+ setMethodsOpen(true);
1968
+ }
1969
+
1970
+ if (isJsFile) {
1971
+ // The worker has already parsed the open buffer for diagnostics,
1972
+ // so this resolves in a few ms — opening only once it answers
1973
+ // avoids flashing "No methods found" on the way.
1974
+ (model ? loadJsOutline(model) : Promise.resolve({ entries: [], truncated: false }))
1975
+ .then(function (result) {
1976
+ if (methodsRequestRef.current !== requestId) return;
1977
+ openWith(result.entries || [], !!result.truncated, false);
1978
+ })
1979
+ .catch(function () {
1980
+ if (methodsRequestRef.current !== requestId) return;
1981
+ openWith([], false, true);
1982
+ });
1983
+ return;
1984
+ }
1985
+
1986
+ try {
1987
+ var result = model ? parseRubyOutline(model, tab.path) : { entries: [], truncated: false };
1988
+ openWith(result.entries || [], !!result.truncated, false);
1989
+ } catch (err) {
1990
+ openWith([], false, true);
1839
1991
  }
1840
- setMethodsOpen(nextOpen);
1841
1992
  },
1842
- title: 'Jump to Method'
1993
+ title: isTestOutline ? 'Jump to Outline' : 'Jump to Method'
1843
1994
  },
1844
1995
  React.createElement('i', { className: 'fas fa-list-ul', style: { marginRight: editorPrefs.toolbarIconOnly ? 0 : '5px', flexShrink: 0 } }),
1845
- !editorPrefs.toolbarIconOnly && React.createElement('span', { className: 'ide-toolbar-label' }, 'Methods')
1996
+ !editorPrefs.toolbarIconOnly && React.createElement('span', { className: 'ide-toolbar-label' }, isTestOutline ? 'Outline' : 'Methods')
1846
1997
  ),
1847
1998
  gitAvailable && React.createElement(
1848
1999
  'button',
@@ -2029,31 +2180,101 @@ var EditorPanel = function EditorPanel(_ref) {
2029
2180
  methodsOpen && methodsDropdownPos && React.createElement(
2030
2181
  'div',
2031
2182
  {
2183
+ ref: methodsDropdownRef,
2032
2184
  className: 'ide-methods-dropdown',
2033
2185
  style: { position: 'fixed', top: methodsDropdownPos.top + 'px', right: methodsDropdownPos.right + 'px', left: 'auto', zIndex: 9900 }
2034
2186
  },
2035
- methodsList.length === 0
2036
- ? React.createElement('div', { className: 'ide-methods-dropdown-empty' }, 'No methods found')
2037
- : methodsList.map(function(m) {
2038
- return React.createElement(
2039
- 'div',
2040
- {
2041
- key: m.line,
2042
- className: 'ide-methods-dropdown-item',
2043
- onMouseDown: function(e) {
2044
- e.preventDefault();
2045
- setMethodsOpen(false);
2046
- if (monacoRef.current) {
2047
- monacoRef.current.revealLineInCenter(m.line);
2048
- monacoRef.current.setPosition({ lineNumber: m.line, column: 1 });
2049
- monacoRef.current.focus();
2187
+ methodsUnavailable
2188
+ ? React.createElement('div', { className: 'ide-methods-dropdown-message' }, 'Outline unavailable')
2189
+ : methodsList.length === 0
2190
+ ? React.createElement('div', { className: 'ide-methods-dropdown-empty' }, isTestOutline ? 'No outline entries found' : 'No methods found')
2191
+ : (function() {
2192
+ var rows = [];
2193
+ var visibility = null;
2194
+ var visibilityStartLine = null;
2195
+ var visibilityRows = [];
2196
+ var icons = { method: 'fa-code', suite: 'fa-layer-group', test: 'fa-flask' };
2197
+
2198
+ function activateEntry(entry) {
2199
+ setMethodsOpen(false);
2200
+ if (monacoRef.current) {
2201
+ monacoRef.current.revealLineInCenter(entry.line);
2202
+ monacoRef.current.setPosition({ lineNumber: entry.line, column: 1 });
2203
+ monacoRef.current.focus();
2204
+ }
2205
+ }
2206
+
2207
+ function flushVisibilityRows() {
2208
+ if (visibilityRows.length === 0) return;
2209
+ rows.push(React.createElement(
2210
+ 'div',
2211
+ {
2212
+ key: 'visibility-group-' + visibilityStartLine,
2213
+ className: 'ide-methods-dropdown-visibility-group'
2214
+ },
2215
+ React.createElement(
2216
+ 'div',
2217
+ { className: 'ide-methods-dropdown-visibility' },
2218
+ visibility
2219
+ ),
2220
+ visibilityRows
2221
+ ));
2222
+ visibility = null;
2223
+ visibilityStartLine = null;
2224
+ visibilityRows = [];
2225
+ }
2226
+
2227
+ methodsList.forEach(function(entry, entryIndex) {
2228
+ var depth = typeof entry.depth === 'number' ? entry.depth : 0;
2229
+ var row = React.createElement(
2230
+ 'button',
2231
+ {
2232
+ // Index-qualified: a JS class and its first member can share
2233
+ // a line, and kind+line alone would collide.
2234
+ key: entry.kind + '-' + entry.line + '-' + entryIndex,
2235
+ type: 'button',
2236
+ className: 'ide-methods-dropdown-item ide-outline-entry ide-outline-entry-' + entry.kind,
2237
+ 'data-outline-kind': entry.kind,
2238
+ 'data-outline-depth': depth,
2239
+ 'aria-label': entry.name + ', line ' + entry.line,
2240
+ tabIndex: 0,
2241
+ autoFocus: entryIndex === 0,
2242
+ style: { paddingLeft: (12 + depth * 14) + 'px' },
2243
+ onClick: function() {
2244
+ activateEntry(entry);
2245
+ }
2246
+ },
2247
+ React.createElement('span', { className: 'ide-methods-dropdown-line' }, entry.line),
2248
+ React.createElement('i', {
2249
+ className: 'fas ' + (icons[entry.kind] || icons.method) + ' ide-outline-entry-icon',
2250
+ 'aria-hidden': 'true'
2251
+ }),
2252
+ React.createElement('span', { className: 'ide-outline-entry-name' }, entry.name)
2253
+ );
2254
+
2255
+ if (entry.kind === 'method' && entry.visibility) {
2256
+ if (visibility !== entry.visibility) {
2257
+ flushVisibilityRows();
2258
+ visibility = entry.visibility;
2259
+ visibilityStartLine = entry.line;
2050
2260
  }
2261
+ visibilityRows.push(row);
2262
+ } else {
2263
+ flushVisibilityRows();
2264
+ rows.push(row);
2051
2265
  }
2052
- },
2053
- React.createElement('span', { className: 'ide-methods-dropdown-line' }, m.line),
2054
- m.name
2055
- );
2056
- })
2266
+ });
2267
+ flushVisibilityRows();
2268
+
2269
+ if (methodsTruncated) {
2270
+ rows.push(React.createElement(
2271
+ 'div',
2272
+ { key: 'truncated', className: 'ide-methods-dropdown-message' },
2273
+ 'Results truncated at 5,000 entries'
2274
+ ));
2275
+ }
2276
+ return rows;
2277
+ })()
2057
2278
  ),
2058
2279
  React.createElement('div', { ref: vimStatusRef, className: 'vim-statusbar', style: { display: editorPrefs.vimMode ? 'flex' : 'none', height: '22px', alignItems: 'center', padding: '0 10px', fontFamily: "'JetBrains Mono', 'Fira Code', Consolas, monospace", fontSize: '12px', background: 'var(--ide-statusbar-bg, #1e1e2e)', color: 'var(--ide-statusbar-fg, #9cdcfe)', borderTop: '1px solid var(--ide-border, #3e3e3e)', flexShrink: 0, userSelect: 'none', letterSpacing: '0.02em' } })
2059
2280
  );
@@ -2073,4 +2294,4 @@ window.EditorPanel = React.memo(EditorPanel, function(prev, next) {
2073
2294
  prev.testInlineVisible === next.testInlineVisible &&
2074
2295
  prev.editorPrefs === next.editorPrefs &&
2075
2296
  prev.monacoReady === next.monacoReady;
2076
- });
2297
+ });