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
@@ -3,6 +3,49 @@
3
3
  // LogPanel — bottom drawer that renders the live Rails log. Auto-scrolls to the
4
4
  // tail, pauses auto-scroll when the user scrolls up, and supports a substring
5
5
  // filter and clear. Driven entirely by LogService.
6
+
7
+ // Classify one Rails log line so CSS can colour it. First match wins, so the
8
+ // order matters: "Completed 500" has to be read as an error before the generic
9
+ // /error/ sweep at the bottom claims it, and a SQL line mentioning "error" in a
10
+ // string literal must not turn the whole row red.
11
+ //
12
+ // Rails writes these lines without ANSI codes when the log goes to a file, so
13
+ // there is nothing to parse — this is pattern matching on the text, and an
14
+ // unrecognised line simply renders in the default colour.
15
+ var LOG_LINE_RULES = [
16
+ // Request lifecycle. The status code decides the colour of a Completed line.
17
+ [/^Completed [45]\d\d\b/, 'error'],
18
+ [/^Completed 3\d\d\b/, 'muted'],
19
+ [/^Completed 2\d\d\b/, 'success'],
20
+ [/^Started [A-Z]+ /, 'request'],
21
+ [/^Processing by /, 'controller'],
22
+ [/^Redirected to /, 'muted'],
23
+ [/^\s*(Rendering|Rendered) /, 'render'],
24
+ // Queries: "User Load (0.3ms) SELECT ..." and its CACHE/TRANSACTION variants.
25
+ [/^\s*(CACHE\s+)?[\w:]+\s*(Load|Create|Update|Destroy|Exists\?|Count|Pluck|Sum|Delete)?\s*\(\d+(\.\d+)?ms\)\s+(SELECT|INSERT|UPDATE|DELETE|BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE)\b/i, 'sql'],
26
+ [/^\s*(TRANSACTION|SQL)\s+\(/, 'sql'],
27
+ // Failures and noise.
28
+ [/^\s*(FATAL|ERROR)\b/, 'error'],
29
+ [/^\s*[\w/.]+:\d+:in [`']/, 'trace'],
30
+ [/DEPRECATION WARNING/, 'warn'],
31
+ [/^\s*(WARN|WARNING)\b/, 'warn'],
32
+ // Rails prints an unhandled exception as "Some::ConstantName (message):",
33
+ // which carries no Error/Exception in its name often enough to need its own
34
+ // rule (ActiveRecord::RecordNotFound, ActionController::RoutingError…).
35
+ [/^\s*[A-Z][A-Za-z0-9_]*(::[A-Z][A-Za-z0-9_]*)*\s+\(.*\):\s*$/, 'error'],
36
+ [/\b\w*(Error|Exception)\b\s*[:(]/, 'error'],
37
+ // mbeditor's own diagnostics, so they stand out from host app noise.
38
+ [/^\s*\[mbeditor\]/, 'mbeditor']
39
+ ];
40
+
41
+ function classifyLogLine(line) {
42
+ var text = String(line == null ? '' : line);
43
+ for (var i = 0; i < LOG_LINE_RULES.length; i++) {
44
+ if (LOG_LINE_RULES[i][0].test(text)) return LOG_LINE_RULES[i][1];
45
+ }
46
+ return null;
47
+ }
48
+
6
49
  var LogPanel = function LogPanel(_ref) {
7
50
  var onClose = _ref.onClose;
8
51
 
@@ -118,10 +161,16 @@ var LogPanel = function LogPanel(_ref) {
118
161
  'div',
119
162
  { className: 'ide-log-body', ref: bodyRef, onScroll: onScroll },
120
163
  shown.map(function (line, i) {
121
- return React.createElement('div', { className: 'ide-log-line', key: i }, line);
164
+ var kind = classifyLogLine(line);
165
+ return React.createElement('div', {
166
+ className: 'ide-log-line' + (kind ? ' ide-log-line-' + kind : ''),
167
+ key: i
168
+ }, line);
122
169
  })
123
170
  )
124
171
  );
125
172
  };
126
173
 
174
+ LogPanel.classifyLine = classifyLogLine;
175
+
127
176
  window.LogPanel = LogPanel;
@@ -440,6 +440,17 @@ var MbeditorApp = function MbeditorApp() {
440
440
  var showLogPanel = _useStateLog2[0];
441
441
  var setShowLogPanel = _useStateLog2[1];
442
442
 
443
+ var _useStateProblems = useState(false);
444
+ var _useStateProblems2 = _slicedToArray(_useStateProblems, 2);
445
+ var showProblemsPanel = _useStateProblems2[0];
446
+ var setShowProblemsPanel = _useStateProblems2[1];
447
+
448
+ // Error/warning tallies across the open tabs, mirrored into the status bar.
449
+ var _useStateProblemCounts = useState({ errors: 0, warnings: 0 });
450
+ var _useStateProblemCounts2 = _slicedToArray(_useStateProblemCounts, 2);
451
+ var problemCounts = _useStateProblemCounts2[0];
452
+ var setProblemCounts = _useStateProblemCounts2[1];
453
+
443
454
  var _useState18g = useState(320);
444
455
  var _useState18g2 = _slicedToArray(_useState18g, 2);
445
456
  var gitPanelWidth = _useState18g2[0];
@@ -1536,6 +1547,31 @@ var MbeditorApp = function MbeditorApp() {
1536
1547
  };
1537
1548
  }, []);
1538
1549
 
1550
+ // Keep the status-bar error/warning tallies in step with Monaco's markers.
1551
+ // Every diagnostic source in the editor — rubocop, ruby-lsp, the TypeScript
1552
+ // worker — lands here, so one subscription covers all of them. Markers change
1553
+ // on every keystroke for JS, so the recount is debounced.
1554
+ useEffect(function () {
1555
+ if (!monacoReady || !window.monaco || !window.monaco.editor || !window.ProblemsPanel) return;
1556
+
1557
+ var timer = null;
1558
+ var recount = function () {
1559
+ if (timer) clearTimeout(timer);
1560
+ timer = setTimeout(function () {
1561
+ timer = null;
1562
+ setProblemCounts(window.ProblemsPanel.counts());
1563
+ }, 250);
1564
+ };
1565
+
1566
+ var sub = window.monaco.editor.onDidChangeMarkers(recount);
1567
+ recount();
1568
+
1569
+ return function () {
1570
+ if (timer) clearTimeout(timer);
1571
+ sub.dispose();
1572
+ };
1573
+ }, [monacoReady]);
1574
+
1539
1575
  var handleSelectFile = function handleSelectFile(path, name, line, col) {
1540
1576
  TabManager.openTab(path, name, line, null, false, col);
1541
1577
  handleNodeSelect({ path: path, name: name || path.split('/').pop(), type: 'file' });
@@ -2747,6 +2783,10 @@ var MbeditorApp = function MbeditorApp() {
2747
2783
  setShowLogPanel(function (prev) { return !prev; });
2748
2784
  };
2749
2785
 
2786
+ var toggleProblemsPanel = function toggleProblemsPanel() {
2787
+ setShowProblemsPanel(function (prev) { return !prev; });
2788
+ };
2789
+
2750
2790
  var toggleZenMode = function toggleZenMode() {
2751
2791
  setZenMode(function (prev) {
2752
2792
  var next = !prev;
@@ -3382,16 +3422,24 @@ var MbeditorApp = function MbeditorApp() {
3382
3422
  "Mini Browser Editor — ",
3383
3423
  window.location.host
3384
3424
  ),
3425
+ // The slot claims all the room between the title and the buttons; the
3426
+ // search pill then takes 75% of it, centred. Sizing the pill against a
3427
+ // slot rather than against the title bar keeps it proportional to the
3428
+ // actual gap as the toolbar's button labels grow and shrink.
3385
3429
  React.createElement(
3386
- 'button',
3387
- {
3388
- type: 'button',
3389
- className: 'ide-titlebar-search',
3390
- title: 'Search files (Ctrl/Cmd+P)',
3391
- onClick: function () { setQuickOpen(true); }
3392
- },
3393
- React.createElement('i', { className: 'fas fa-search', style: { marginRight: '6px', opacity: 0.7 } }),
3394
- React.createElement('span', { className: 'ide-titlebar-search-text' }, 'Search files…')
3430
+ 'div',
3431
+ { className: 'ide-titlebar-search-slot' },
3432
+ React.createElement(
3433
+ 'button',
3434
+ {
3435
+ type: 'button',
3436
+ className: 'ide-titlebar-search',
3437
+ title: 'Search files (Ctrl/Cmd+P)',
3438
+ onClick: function () { setQuickOpen(true); }
3439
+ },
3440
+ React.createElement('i', { className: 'fas fa-search', style: { marginRight: '6px', opacity: 0.7 } }),
3441
+ React.createElement('span', { className: 'ide-titlebar-search-text' }, 'Search files…')
3442
+ )
3395
3443
  ),
3396
3444
  React.createElement(
3397
3445
  "div",
@@ -3455,13 +3503,6 @@ var MbeditorApp = function MbeditorApp() {
3455
3503
  )
3456
3504
  ),
3457
3505
  React.createElement("div", { className: "statusbar-sep" }),
3458
- React.createElement(
3459
- "button",
3460
- { type: "button", className: "statusbar-btn", onClick: toggleLogPanel, title: "Toggle Rails log (Ctrl+Shift+L)" },
3461
- React.createElement("i", { className: "fas fa-stream" }),
3462
- !editorPrefs.toolbarIconOnly && " Logs"
3463
- ),
3464
- React.createElement("div", { className: "statusbar-sep" }),
3465
3506
  React.createElement(
3466
3507
  "button",
3467
3508
  { type: "button", className: "statusbar-btn", onClick: function () { return setShowHelp(true); }, title: "Keyboard shortcuts & help" },
@@ -4974,6 +5015,12 @@ var MbeditorApp = function MbeditorApp() {
4974
5015
  ),
4975
5016
  showLogPanel && !zenMode && React.createElement(window.LogPanel || LogPanel, {
4976
5017
  onClose: function () { setShowLogPanel(false); }
5018
+ }),
5019
+ showProblemsPanel && !zenMode && React.createElement(window.ProblemsPanel || ProblemsPanel, {
5020
+ onClose: function () { setShowProblemsPanel(false); },
5021
+ onOpenFile: function (path, line, col) {
5022
+ handleSelectFile(path, path.split('/').pop(), line, col);
5023
+ }
4977
5024
  })
4978
5025
  ),
4979
5026
  React.createElement(
@@ -4998,6 +5045,23 @@ var MbeditorApp = function MbeditorApp() {
4998
5045
  state.gitInfo.behind
4999
5046
  )
5000
5047
  ),
5048
+ React.createElement(
5049
+ "button",
5050
+ {
5051
+ type: "button",
5052
+ className: "statusbar-btn statusbar-problems" + (showProblemsPanel ? " active" : ""),
5053
+ onClick: toggleProblemsPanel,
5054
+ title: problemCounts.errors + " error(s), " + problemCounts.warnings +
5055
+ " warning(s) in open files — click to open Problems"
5056
+ },
5057
+ React.createElement("i", { className: "fas fa-bug statusbar-problems-error-icon" }),
5058
+ React.createElement("span", { className: "statusbar-problems-count" }, problemCounts.errors),
5059
+ React.createElement("i", {
5060
+ className: "fas fa-exclamation-triangle statusbar-problems-warning-icon",
5061
+ style: { marginLeft: "8px" }
5062
+ }),
5063
+ React.createElement("span", { className: "statusbar-problems-count" }, problemCounts.warnings)
5064
+ ),
5001
5065
  !serverOnline && (function () {
5002
5066
  var dirtyCount = state.panes.reduce(function (acc, p) {
5003
5067
  return acc + p.tabs.filter(function (t) { return t.dirty; }).length;
@@ -5027,6 +5091,17 @@ var MbeditorApp = function MbeditorApp() {
5027
5091
  { className: "statusbar-msg " + state.statusMessage.kind },
5028
5092
  state.statusMessage.text
5029
5093
  ),
5094
+ React.createElement(
5095
+ "button",
5096
+ {
5097
+ type: "button",
5098
+ className: "statusbar-btn statusbar-logs-btn" + (showLogPanel ? " active" : ""),
5099
+ onClick: toggleLogPanel,
5100
+ title: "Toggle Rails log (Ctrl+Shift+L)"
5101
+ },
5102
+ React.createElement("i", { className: "fas fa-stream" }),
5103
+ " Logs"
5104
+ ),
5030
5105
  activeEOL && React.createElement(
5031
5106
  "button",
5032
5107
  {
@@ -0,0 +1,217 @@
1
+ 'use strict';
2
+
3
+ // ProblemsPanel — bottom drawer listing the diagnostics Monaco is holding for
4
+ // the files you have open: rubocop and ruby-lsp for Ruby, the TypeScript
5
+ // worker's syntax errors for JS/JSX, and anything else that writes markers.
6
+ //
7
+ // Scope is deliberately "open tabs", not the workspace: markers only exist for
8
+ // models Monaco has loaded, so a count over anything wider would be a lie.
9
+ // Closing a tab drops its problems, same as VS Code with an unopened file.
10
+ var ProblemsPanel = (function () {
11
+ var SEVERITY_ERROR = 8;
12
+ var SEVERITY_WARNING = 4;
13
+ // Long enough to recognise the statement, short enough not to push the
14
+ // location off the end of the row.
15
+ var CODE_PREVIEW_LIMIT = 120;
16
+
17
+ // The offending source line, trimmed, for display beside the message. Markers
18
+ // can outlive the edit that invalidated them by a frame, so a line number
19
+ // past the end of the buffer yields nothing rather than throwing.
20
+ function codePreview(model, lineNumber) {
21
+ if (!lineNumber || lineNumber < 1 || lineNumber > model.getLineCount()) return '';
22
+
23
+ var text = model.getLineContent(lineNumber).trim();
24
+ return text.length > CODE_PREVIEW_LIMIT ? text.slice(0, CODE_PREVIEW_LIMIT) + '…' : text;
25
+ }
26
+
27
+ // Reading markers means walking every model, so callers that only want the
28
+ // counts share this one pass. Exposed on the component for the status bar.
29
+ function collect() {
30
+ var monaco = window.monaco;
31
+ if (!monaco || !monaco.editor) return { errors: [], warnings: [], byFile: [] };
32
+
33
+ var errors = [];
34
+ var warnings = [];
35
+ var byFile = [];
36
+
37
+ monaco.editor.getModels().forEach(function (model) {
38
+ var path = model._mbeditorPath;
39
+ if (!path || model.isDisposed()) return;
40
+
41
+ var markers = monaco.editor.getModelMarkers({ resource: model.uri }).filter(function (m) {
42
+ return m.severity === SEVERITY_ERROR || m.severity === SEVERITY_WARNING;
43
+ });
44
+ if (markers.length === 0) return;
45
+
46
+ markers.sort(function (a, b) { return a.startLineNumber - b.startLineNumber; });
47
+
48
+ // Carry the source line alongside the marker: read here, while the model
49
+ // is in hand, rather than looking the model up again at render time.
50
+ var entries = markers.map(function (m) {
51
+ (m.severity === SEVERITY_ERROR ? errors : warnings).push(m);
52
+ return { marker: m, code: codePreview(model, m.startLineNumber) };
53
+ });
54
+
55
+ byFile.push({ path: path, markers: entries });
56
+ });
57
+
58
+ byFile.sort(function (a, b) { return a.path < b.path ? -1 : a.path > b.path ? 1 : 0; });
59
+ return { errors: errors, warnings: warnings, byFile: byFile };
60
+ }
61
+
62
+ function counts() {
63
+ var all = collect();
64
+ return { errors: all.errors.length, warnings: all.warnings.length };
65
+ }
66
+
67
+ var Panel = function ProblemsPanelComponent(_ref) {
68
+ var onClose = _ref.onClose;
69
+ var onOpenFile = _ref.onOpenFile;
70
+
71
+ var _problems = React.useState(collect);
72
+ var problems = _problems[0], setProblems = _problems[1];
73
+ var _filter = React.useState('');
74
+ var filter = _filter[0], setFilter = _filter[1];
75
+
76
+ var MIN_HEIGHT = 120;
77
+ var _height = React.useState(function () {
78
+ var saved = parseInt(window.localStorage.getItem('mbeditorProblemsHeight'), 10);
79
+ return (saved && saved >= MIN_HEIGHT) ? saved : 240;
80
+ });
81
+ var height = _height[0], setHeight = _height[1];
82
+ var heightRef = React.useRef(height);
83
+ heightRef.current = height;
84
+
85
+ // Same delta-based resize as the log drawer.
86
+ var onResizeMouseDown = function (e) {
87
+ e.preventDefault();
88
+ var startY = e.clientY;
89
+ var startHeight = heightRef.current;
90
+ var onMove = function (ev) {
91
+ var vh = window.innerHeight || document.documentElement.clientHeight || 0;
92
+ var maxH = vh > 0 ? Math.round(vh * 0.85) : Infinity;
93
+ setHeight(Math.min(maxH, Math.max(MIN_HEIGHT, startHeight + (startY - ev.clientY))));
94
+ };
95
+ var onUp = function () {
96
+ document.removeEventListener('mousemove', onMove);
97
+ document.removeEventListener('mouseup', onUp);
98
+ window.localStorage.setItem('mbeditorProblemsHeight', String(heightRef.current));
99
+ };
100
+ document.addEventListener('mousemove', onMove);
101
+ document.addEventListener('mouseup', onUp);
102
+ };
103
+
104
+ React.useEffect(function () {
105
+ if (!window.monaco || !window.monaco.editor) return;
106
+ var refresh = function () { setProblems(collect()); };
107
+ var sub = window.monaco.editor.onDidChangeMarkers(refresh);
108
+ refresh();
109
+ return function () { sub.dispose(); };
110
+ }, []);
111
+
112
+ var needle = filter.trim().toLowerCase();
113
+ var shown = needle
114
+ ? problems.byFile.map(function (entry) {
115
+ return {
116
+ path: entry.path,
117
+ markers: entry.markers.filter(function (item) {
118
+ return (item.marker.message + ' ' + item.code + ' ' + entry.path)
119
+ .toLowerCase().indexOf(needle) !== -1;
120
+ })
121
+ };
122
+ }).filter(function (entry) { return entry.markers.length > 0; })
123
+ : problems.byFile;
124
+
125
+ var total = problems.errors.length + problems.warnings.length;
126
+
127
+ return React.createElement(
128
+ 'div',
129
+ { className: 'ide-problems-drawer', style: { height: height + 'px' } },
130
+ React.createElement('div', {
131
+ className: 'ide-problems-resize',
132
+ title: 'Drag to resize',
133
+ onMouseDown: onResizeMouseDown
134
+ }),
135
+ React.createElement(
136
+ 'div',
137
+ { className: 'ide-problems-header' },
138
+ React.createElement('i', { className: 'fas fa-bug' }),
139
+ React.createElement('span', { className: 'ide-problems-title' }, 'Problems'),
140
+ React.createElement(
141
+ 'span',
142
+ { className: 'ide-problems-summary' },
143
+ problems.errors.length + ' error' + (problems.errors.length === 1 ? '' : 's') +
144
+ ', ' + problems.warnings.length + ' warning' + (problems.warnings.length === 1 ? '' : 's') +
145
+ ' in open files'
146
+ ),
147
+ React.createElement('input', {
148
+ className: 'ide-problems-filter',
149
+ type: 'text',
150
+ placeholder: 'Filter…',
151
+ value: filter,
152
+ onChange: function (e) { setFilter(e.target.value); }
153
+ }),
154
+ React.createElement('button', {
155
+ type: 'button', className: 'ide-problems-btn',
156
+ title: 'Close', onClick: onClose
157
+ }, React.createElement('i', { className: 'fas fa-times' }))
158
+ ),
159
+ React.createElement(
160
+ 'div',
161
+ { className: 'ide-problems-body' },
162
+ total === 0
163
+ ? React.createElement('div', { className: 'ide-problems-empty' }, 'No problems in the open files')
164
+ : shown.length === 0
165
+ ? React.createElement('div', { className: 'ide-problems-empty' }, 'No problems match the filter')
166
+ : shown.map(function (entry) {
167
+ return React.createElement(
168
+ 'div',
169
+ { className: 'ide-problems-file', key: entry.path },
170
+ React.createElement(
171
+ 'div',
172
+ { className: 'ide-problems-file-name' },
173
+ entry.path,
174
+ React.createElement('span', { className: 'ide-problems-file-count' }, entry.markers.length)
175
+ ),
176
+ entry.markers.map(function (item, index) {
177
+ var marker = item.marker;
178
+ var isError = marker.severity === SEVERITY_ERROR;
179
+ return React.createElement(
180
+ 'button',
181
+ {
182
+ type: 'button',
183
+ className: 'ide-problems-item ide-problems-item-' + (isError ? 'error' : 'warning'),
184
+ key: entry.path + ':' + marker.startLineNumber + ':' + index,
185
+ 'aria-label': (isError ? 'Error' : 'Warning') + ': ' + marker.message +
186
+ ', ' + entry.path + ' line ' + marker.startLineNumber +
187
+ (item.code ? ', source: ' + item.code : ''),
188
+ onClick: function () {
189
+ if (onOpenFile) onOpenFile(entry.path, marker.startLineNumber, marker.startColumn);
190
+ }
191
+ },
192
+ React.createElement('i', {
193
+ className: 'fas ' + (isError ? 'fa-bug' : 'fa-exclamation-triangle') + ' ide-problems-icon',
194
+ 'aria-hidden': 'true'
195
+ }),
196
+ React.createElement('span', { className: 'ide-problems-msg' }, marker.message),
197
+ item.code && React.createElement('code', { className: 'ide-problems-code' }, item.code),
198
+ marker.source && React.createElement('span', { className: 'ide-problems-source' }, marker.source),
199
+ React.createElement(
200
+ 'span',
201
+ { className: 'ide-problems-loc' },
202
+ '[' + marker.startLineNumber + ', ' + marker.startColumn + ']'
203
+ )
204
+ );
205
+ })
206
+ );
207
+ })
208
+ )
209
+ );
210
+ };
211
+
212
+ Panel.collect = collect;
213
+ Panel.counts = counts;
214
+ return Panel;
215
+ })();
216
+
217
+ window.ProblemsPanel = ProblemsPanel;
@@ -94,6 +94,20 @@ var QuickOpenDialog = function QuickOpenDialog(_ref) {
94
94
  return 50;
95
95
  }
96
96
 
97
+ // Recently opened files, most recent first, as a path -> rank lookup. Built
98
+ // once per query rather than per comparison, since the sort calls this for
99
+ // every pair.
100
+ function recentRanks() {
101
+ var ranks = {};
102
+ var recent = (typeof TabManager !== 'undefined' && TabManager.getRecentFiles)
103
+ ? TabManager.getRecentFiles() : [];
104
+ recent.forEach(function (entry, index) {
105
+ var path = typeof entry === 'string' ? entry : (entry && entry.path);
106
+ if (path && !(path in ranks)) ranks[path] = index;
107
+ });
108
+ return ranks;
109
+ }
110
+
97
111
  // Match relevance within a priority tier: exact basename > prefix > substring > other.
98
112
  function getMatchRelevance(result, q) {
99
113
  if (!q) return 3;
@@ -128,13 +142,30 @@ var QuickOpenDialog = function QuickOpenDialog(_ref) {
128
142
  var res = SearchService.searchFiles(query);
129
143
  // Filter by type: always include files; include dirs only when showFolders is on
130
144
  var filtered = showFolders ? res : res.filter(function(r) { return r.type !== 'dir'; });
131
- // Sort: files always beat dirs; within the same tier exact basename > prefix > substring > other.
132
- // JS sort is stable in modern engines so MiniSearch relevance score order is the tiebreaker
133
- // when match relevance is equal.
145
+ // Sort, in order of precedence:
146
+ // 1. match quality exact basename > prefix > substring > other, so a
147
+ // worse match can never jump the queue however recently it was opened
148
+ // 2. how recently the file was opened, most recent first
149
+ // 3. the static file-type tier (controller > model > … > noise)
150
+ //
151
+ // Recency sits above the type tier deliberately: when two files match a
152
+ // query equally well, the one you were just working in is almost always
153
+ // the one you meant, and that beats a guess made from the directory name.
154
+ // Files never opened all tie here and fall through to the type tier, which
155
+ // is what orders the bulk of a cold result list.
156
+ //
157
+ // Directories keep their +100 penalty inside the type tier so files still
158
+ // come first. JS sort is stable in modern engines, so MiniSearch's own
159
+ // relevance order remains the final tiebreaker.
160
+ var ranks = recentRanks();
161
+ var NEVER_OPENED = Infinity;
134
162
  filtered.sort(function(a, b) {
135
163
  var aRelevance = getMatchRelevance(a, query);
136
164
  var bRelevance = getMatchRelevance(b, query);
137
165
  if (aRelevance !== bRelevance) return aRelevance - bRelevance;
166
+ var aRecent = a.path in ranks ? ranks[a.path] : NEVER_OPENED;
167
+ var bRecent = b.path in ranks ? ranks[b.path] : NEVER_OPENED;
168
+ if (aRecent !== bRecent) return aRecent - bRecent;
138
169
  var aPriority = getFilePriority(a.path) + (a.type === 'dir' ? 100 : 0);
139
170
  var bPriority = getFilePriority(b.path) + (b.type === 'dir' ? 100 : 0);
140
171
  return aPriority - bPriority;