mbeditor 0.4.5 → 0.5.1

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.
@@ -103,6 +103,8 @@ var ShortcutHelp = function ShortcutHelp(_ref) {
103
103
  null,
104
104
  React.createElement(Row, { keys: 'Ctrl+P', desc: 'Quick-open any file by name' }),
105
105
  React.createElement(Row, { keys: 'Ctrl+S', desc: 'Save the active file' }),
106
+ React.createElement(Row, { keys: 'Ctrl+Shift+G', desc: 'Toggle git panel' }),
107
+ React.createElement(Row, { keys: 'Ctrl+Shift+Z', desc: 'Toggle zen / focus mode' }),
106
108
  React.createElement(Row, { keys: 'Escape', desc: 'Close quick-open / context menus' })
107
109
  )
108
110
  )
@@ -51,6 +51,33 @@
51
51
 
52
52
  var globalsRegistered = false;
53
53
 
54
+ // Enumerate window for user-defined (non-native) globals and return a TypeScript
55
+ // declaration string. Sprockets exposes every top-level var/function as a window
56
+ // property before Monaco initialises, so scanning window at registration time
57
+ // captures components, services, and helpers without any manual listing.
58
+ function buildWindowGlobalsShim() {
59
+ var alreadyDeclared = { React: 1, ReactDOM: 1, PropTypes: 1, MaterialUI: 1 };
60
+ var lines = [];
61
+ try {
62
+ var keys = Object.keys(window);
63
+ for (var i = 0; i < keys.length; i++) {
64
+ var key = keys[i];
65
+ if (alreadyDeclared[key]) continue;
66
+ if (!/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key)) continue;
67
+ var value;
68
+ try { value = window[key]; } catch (e) { continue; }
69
+ if (value === null || value === undefined) continue;
70
+ if (typeof value === 'function') {
71
+ try {
72
+ if (/\[native code\]/.test(Function.prototype.toString.call(value))) continue;
73
+ } catch (e) { continue; }
74
+ }
75
+ lines.push('declare var ' + key + ': any;');
76
+ }
77
+ } catch (e) {}
78
+ return lines.join('\n');
79
+ }
80
+
54
81
  function leadingWhitespace(line) {
55
82
  var match = line.match(/^\s*/);
56
83
  return match ? match[0] : '';
@@ -62,6 +89,9 @@
62
89
 
63
90
  function rubyIndentUnit(model) {
64
91
  var options = model.getOptions ? model.getOptions() : null;
92
+ if (options && options.insertSpaces === false) {
93
+ return '\t';
94
+ }
65
95
  var tabSize = options && options.tabSize ? options.tabSize : 4;
66
96
  return new Array(tabSize + 1).join(' ');
67
97
  }
@@ -350,6 +380,46 @@
350
380
  });
351
381
  }
352
382
 
383
+ // Unused method dimming — grey out `def method_name` for methods with no
384
+ // call-sites anywhere in the workspace.
385
+ var unusedDecIds = [];
386
+ var unusedTimer = null;
387
+ var unusedSaveDisposable = null;
388
+
389
+ if (language === 'ruby' && typeof FileService !== 'undefined' && FileService.getUnusedMethods) {
390
+ function refreshUnused() {
391
+ var path = model._mbeditorPath;
392
+ if (!path) return;
393
+ FileService.getUnusedMethods(path).then(function(data) {
394
+ var unused = data && Array.isArray(data.unused) ? data.unused : [];
395
+ var newDecs = unused.map(function(m) {
396
+ var lineContent = model.getLineContent(m.line);
397
+ var defIdx = lineContent.indexOf('def ');
398
+ if (defIdx < 0) return null;
399
+ var nameCol = defIdx + 5; // 1-based column of method name
400
+ return {
401
+ range: new monaco.Range(m.line, nameCol, m.line, nameCol + m.name.length),
402
+ options: {
403
+ inlineClassName: 'mbeditor-unused-def',
404
+ stickiness: monaco.editor.TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges,
405
+ hoverMessage: { value: '**Unused method** — `' + m.name + '` is never called in this application.' }
406
+ }
407
+ };
408
+ }).filter(Boolean);
409
+ unusedDecIds = editor.deltaDecorations(unusedDecIds, newDecs);
410
+ }).catch(function() {});
411
+ }
412
+
413
+ // Initial check after a short delay (server cache may not be warm yet).
414
+ unusedTimer = setTimeout(refreshUnused, 3000);
415
+
416
+ // Re-check after each save event (model content stops changing).
417
+ unusedSaveDisposable = model.onDidChangeContent(function() {
418
+ clearTimeout(unusedTimer);
419
+ unusedTimer = setTimeout(refreshUnused, 5000);
420
+ });
421
+ }
422
+
353
423
  var contentDisposable = model.onDidChangeContent(function (event) {
354
424
  if (suppressInternalEdit) return;
355
425
  if (event.isUndoing || event.isRedoing) return;
@@ -381,6 +451,9 @@
381
451
  if (gotoMouseDisposable) gotoMouseDisposable.dispose();
382
452
  if (gotoActionDisposable) gotoActionDisposable.dispose();
383
453
  contentDisposable.dispose();
454
+ if (unusedSaveDisposable) unusedSaveDisposable.dispose();
455
+ clearTimeout(unusedTimer);
456
+ if (unusedDecIds.length > 0) { editor.deltaDecorations(unusedDecIds, []); }
384
457
  }
385
458
  };
386
459
  }
@@ -391,6 +464,68 @@
391
464
 
392
465
  globalsRegistered = true;
393
466
 
467
+ // JavaScript: enable semantic checking (off by default in Monaco) and JSX support.
468
+ // checkJs catches undefined variables, noUnusedLocals catches dead assignments.
469
+ if (monaco.languages.typescript && monaco.languages.typescript.javascriptDefaults) {
470
+ monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
471
+ noSemanticValidation: false,
472
+ noSyntaxValidation: false,
473
+ noSuggestionDiagnostics: false
474
+ });
475
+ monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
476
+ target: monaco.languages.typescript.ScriptTarget.ES2020,
477
+ allowNonTsExtensions: true,
478
+ allowJs: true,
479
+ checkJs: true,
480
+ jsx: monaco.languages.typescript.JsxEmit.React,
481
+ noUnusedLocals: true
482
+ });
483
+ }
484
+
485
+ // Declare globals that the sprockets asset pipeline injects at runtime so
486
+ // checkJs doesn't flag them as undefined. `interface Window` augmentation
487
+ // covers `window.myAppGlobal` access patterns. For app-specific component
488
+ // names not listed here, add `/* global MyComponent */` at the top of the
489
+ // file — TypeScript's checkJs mode respects that directive.
490
+ if (monaco.languages.typescript && monaco.languages.typescript.javascriptDefaults) {
491
+ monaco.languages.typescript.javascriptDefaults.addExtraLib(
492
+ [
493
+ 'declare var React: any;',
494
+ 'declare var ReactDOM: any;',
495
+ 'declare var PropTypes: any;',
496
+ 'declare var MaterialUI: any;',
497
+ 'interface Window { [key: string]: any; }'
498
+ ].join('\n'),
499
+ 'inmemory://mbeditor/sprockets-globals.d.ts'
500
+ );
501
+
502
+ var dynamicShim = buildWindowGlobalsShim();
503
+ if (dynamicShim) {
504
+ monaco.languages.typescript.javascriptDefaults.addExtraLib(
505
+ dynamicShim,
506
+ 'inmemory://mbeditor/window-globals.d.ts'
507
+ );
508
+ }
509
+ }
510
+
511
+ // TypeScript: enable JSX for .tsx files and catch unused locals.
512
+ if (monaco.languages.typescript && monaco.languages.typescript.typescriptDefaults) {
513
+ monaco.languages.typescript.typescriptDefaults.setCompilerOptions({
514
+ target: monaco.languages.typescript.ScriptTarget.ES2020,
515
+ allowNonTsExtensions: true,
516
+ jsx: monaco.languages.typescript.JsxEmit.React,
517
+ noUnusedLocals: true
518
+ });
519
+ }
520
+
521
+ // CSS for greyed-out unused method names (applied via decoration inlineClassName).
522
+ if (!document.getElementById('mbeditor-unused-style')) {
523
+ var unusedStyle = document.createElement('style');
524
+ unusedStyle.id = 'mbeditor-unused-style';
525
+ unusedStyle.textContent = '.mbeditor-unused-def { opacity: 0.35; }';
526
+ document.head.appendChild(unusedStyle);
527
+ }
528
+
394
529
  monaco.languages.setLanguageConfiguration('ruby', {
395
530
  comments: { lineComment: '#', blockComment: ['=begin', '=end'] },
396
531
  brackets: [['(', ')'], ['{', '}'], ['[', ']']],
@@ -730,6 +865,34 @@
730
865
  if (RUBY_CORE_METHODS[word]) return null;
731
866
  if (typeof FileService === 'undefined' || !FileService.getDefinition) return null;
732
867
 
868
+ // Uppercase first letter → likely a module/class name.
869
+ // Look up the module's exposed methods via /module_members.
870
+ if (/^[A-Z]/.test(word) && FileService.getModuleMembers) {
871
+ var modKey = '__mod__' + word;
872
+ var modCached = hoverCache[modKey];
873
+ if (modCached && (Date.now() - modCached.ts) < HOVER_CACHE_TTL_MS) {
874
+ return modCached.result || null;
875
+ }
876
+ var modController = typeof AbortController !== 'undefined' ? new AbortController() : null;
877
+ if (modController && token && token.onCancellationRequested) {
878
+ token.onCancellationRequested(function() { modController.abort(); });
879
+ }
880
+ return FileService.getModuleMembers(word, modController ? { signal: modController.signal } : {}).then(function(data) {
881
+ if (token && token.isCancellationRequested) return null;
882
+ if (!data || !data.methods || data.methods.length === 0) {
883
+ hoverCache[modKey] = { ts: Date.now(), result: null };
884
+ return null;
885
+ }
886
+ var lines = ['**' + data.name + '** `' + (data.file || '') + '`\n'];
887
+ data.methods.slice(0, 20).forEach(function(m) {
888
+ lines.push('- `' + (m.signature || m.name) + '`');
889
+ });
890
+ var result = { contents: [{ value: lines.join('\n') }] };
891
+ hoverCache[modKey] = { ts: Date.now(), result: result };
892
+ return result;
893
+ }).catch(function() { return null; });
894
+ }
895
+
733
896
  var currentFile = model._mbeditorPath || null;
734
897
 
735
898
  // Return cached result immediately if still fresh.
@@ -801,6 +964,69 @@
801
964
  };
802
965
  }
803
966
 
967
+ // Include-aware completion provider for Ruby.
968
+ // Suggests methods from modules included/extended/prepended in the current file,
969
+ // with Notepad++-style snippet tab stops for method parameters.
970
+ var includesCache = {};
971
+ var INCLUDES_CACHE_TTL_MS = 30000;
972
+
973
+ function parseMethodParams(signature) {
974
+ var m = /\(([^)]+)\)/.exec(signature || '');
975
+ if (!m) return [];
976
+ return m[1].split(',').map(function(p) {
977
+ return p.trim().replace(/^[*&]+/, '').replace(/\s*=.*$/, '').trim();
978
+ }).filter(function(p) { return p.length > 0; });
979
+ }
980
+
981
+ monaco.languages.registerCompletionItemProvider('ruby', {
982
+ triggerCharacters: ['.'],
983
+ provideCompletionItems: function(model, position) {
984
+ var path = model._mbeditorPath;
985
+ if (!path || typeof FileService === 'undefined' || !FileService.getFileIncludes) {
986
+ return { suggestions: [] };
987
+ }
988
+
989
+ var lineUpToCursor = model.getValueInRange({
990
+ startLineNumber: position.lineNumber, startColumn: 1,
991
+ endLineNumber: position.lineNumber, endColumn: position.column
992
+ });
993
+ var isDot = lineUpToCursor.slice(-1) === '.';
994
+
995
+ function buildSuggestions(data) {
996
+ var suggestions = [];
997
+ (data.includes || []).forEach(function(mod) {
998
+ (mod.methods || []).forEach(function(m) {
999
+ var params = parseMethodParams(m.signature);
1000
+ var snippet = params.length > 0
1001
+ ? m.name + '(' + params.map(function(p, i) {
1002
+ return '${' + (i + 1) + ':' + p + '}';
1003
+ }).join(', ') + ')$0'
1004
+ : m.name + '$0';
1005
+ suggestions.push({
1006
+ label: m.name,
1007
+ kind: monaco.languages.CompletionItemKind.Method,
1008
+ detail: mod.name + (mod.file ? ' ' + mod.file : ''),
1009
+ insertText: snippet,
1010
+ insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
1011
+ sortText: (isDot ? '0' : '1') + m.name
1012
+ });
1013
+ });
1014
+ });
1015
+ return { suggestions: suggestions };
1016
+ }
1017
+
1018
+ var cached = includesCache[path];
1019
+ if (cached && (Date.now() - cached.ts) < INCLUDES_CACHE_TTL_MS) {
1020
+ return buildSuggestions(cached.data);
1021
+ }
1022
+
1023
+ return FileService.getFileIncludes(path).then(function(result) {
1024
+ includesCache[path] = { ts: Date.now(), data: result };
1025
+ return buildSuggestions(result);
1026
+ }).catch(function() { return { suggestions: [] }; });
1027
+ }
1028
+ });
1029
+
804
1030
  // Vim-style fold-marker folding provider.
805
1031
  // Recognises {{{ (open) and }}} (close) anywhere in a line, matching the
806
1032
  // convention used by vim's `foldmethod=marker`. Registered for every
@@ -31,6 +31,12 @@ axios.interceptors.response.use(null, function(error) {
31
31
  return Promise.reject(error);
32
32
  });
33
33
 
34
+ // Prefetch cache: path -> { controller: AbortController, promise: Promise<{content,language}>, resolvedAt: number|null }
35
+ // Entries are consumed once (getPrefetched deletes them) or cancelled on mouseleave.
36
+ // Settled entries that are never consumed expire after 30 s (TTL).
37
+ var prefetchCache = new Map();
38
+ var PREFETCH_TTL_MS = 30000;
39
+
34
40
  var FileService = (function () {
35
41
  function getWorkspace() {
36
42
  return axios.get(window.mbeditorBasePath() + '/workspace').then(function(res) { return res.data; });
@@ -48,6 +54,14 @@ var FileService = (function () {
48
54
  return axios.get(window.mbeditorBasePath() + '/file', { params: params }).then(function(res) { return res.data; });
49
55
  }
50
56
 
57
+ function getFileChunk(path, startLine, lineCount) {
58
+ if (lineCount === undefined) {
59
+ lineCount = 500;
60
+ }
61
+ var params = { path: path, start_line: startLine, line_count: lineCount };
62
+ return axios.get(window.mbeditorBasePath() + '/file', { params: params }).then(function(res) { return res.data; });
63
+ }
64
+
51
65
  function saveFile(path, code) {
52
66
  return axios.post(window.mbeditorBasePath() + '/file', { path: path, code: code }).then(function(res) { return res.data; });
53
67
  }
@@ -119,10 +133,82 @@ var FileService = (function () {
119
133
  return axios.get(window.mbeditorBasePath() + '/definition', config).then(function(res) { return res.data; });
120
134
  }
121
135
 
136
+ // Prefetch file content and store in prefetchCache. Uses native fetch + AbortController
137
+ // so the in-flight request can be cancelled on mouseleave without touching axios.
138
+ function prefetch(path) {
139
+ if (prefetchCache.has(path)) return; // already in-flight or cached
140
+
141
+ // Evict stale settled entries before adding a new one
142
+ var now = Date.now();
143
+ prefetchCache.forEach(function(entry, key) {
144
+ if (entry.resolvedAt !== null && now - entry.resolvedAt > PREFETCH_TTL_MS) {
145
+ prefetchCache.delete(key);
146
+ }
147
+ });
148
+
149
+ var controller = new AbortController();
150
+ var entry = { controller: controller, promise: null, resolvedAt: null };
151
+ // allow_missing=1 so a 404 resolves to { missing:true } instead of rejecting
152
+ var url = window.mbeditorBasePath() + '/file?path=' + encodeURIComponent(path) + '&allow_missing=1';
153
+ entry.promise = fetch(url, {
154
+ signal: controller.signal,
155
+ headers: { 'X-Mbeditor-Client': '1' }
156
+ }).then(function(res) {
157
+ if (!res.ok) throw new Error('prefetch failed: ' + res.status);
158
+ return res.json();
159
+ }).then(function(data) {
160
+ entry.resolvedAt = Date.now();
161
+ return data;
162
+ }).catch(function(err) {
163
+ // Remove from cache on failure/abort so a real open falls back to a fresh fetch
164
+ prefetchCache.delete(path);
165
+ return null;
166
+ });
167
+ prefetchCache.set(path, entry);
168
+ }
169
+
170
+ // Returns a Promise for the cached result and removes the entry (consume-once),
171
+ // or returns null if no prefetch is in-flight / completed for this path.
172
+ // Settled entries older than PREFETCH_TTL_MS are treated as expired.
173
+ function getPrefetched(path) {
174
+ var entry = prefetchCache.get(path);
175
+ if (!entry) return null;
176
+ if (entry.resolvedAt !== null && Date.now() - entry.resolvedAt > PREFETCH_TTL_MS) {
177
+ prefetchCache.delete(path);
178
+ return null;
179
+ }
180
+ prefetchCache.delete(path);
181
+ return entry.promise;
182
+ }
183
+
184
+ // Abort any in-flight prefetch for path and remove it from the cache.
185
+ function cancelPrefetch(path) {
186
+ var entry = prefetchCache.get(path);
187
+ if (!entry) return;
188
+ entry.controller.abort();
189
+ prefetchCache.delete(path);
190
+ }
191
+
192
+ function getModuleMembers(name, extraOptions) {
193
+ var config = Object.assign({ params: { name: name }, timeout: 8000 }, extraOptions || {});
194
+ return axios.get(window.mbeditorBasePath() + '/module_members', config).then(function(res) { return res.data; });
195
+ }
196
+
197
+ function getFileIncludes(path, extraOptions) {
198
+ var config = Object.assign({ params: { path: path }, timeout: 15000 }, extraOptions || {});
199
+ return axios.get(window.mbeditorBasePath() + '/file_includes', config).then(function(res) { return res.data; });
200
+ }
201
+
202
+ function getUnusedMethods(path, extraOptions) {
203
+ var config = Object.assign({ params: { path: path }, timeout: 30000 }, extraOptions || {});
204
+ return axios.get(window.mbeditorBasePath() + '/unused_methods', config).then(function(res) { return res.data; });
205
+ }
206
+
122
207
  return {
123
208
  getWorkspace: getWorkspace,
124
209
  getTree: getTree,
125
210
  getFile: getFile,
211
+ getFileChunk: getFileChunk,
126
212
  saveFile: saveFile,
127
213
  createFile: createFile,
128
214
  createDir: createDir,
@@ -138,6 +224,12 @@ var FileService = (function () {
138
224
  getBranchState: getBranchState,
139
225
  saveBranchState: saveBranchState,
140
226
  pruneBranchStates: pruneBranchStates,
141
- getDefinition: getDefinition
227
+ getDefinition: getDefinition,
228
+ prefetch: prefetch,
229
+ getPrefetched: getPrefetched,
230
+ cancelPrefetch: cancelPrefetch,
231
+ getModuleMembers: getModuleMembers,
232
+ getFileIncludes: getFileIncludes,
233
+ getUnusedMethods: getUnusedMethods
142
234
  };
143
235
  })();
@@ -1,12 +1,16 @@
1
1
  var GitService = (function () {
2
2
  function applyGitInfo(data) {
3
3
  var files = data.workingTree || data.files || [];
4
- EditorStore.setState({
5
- gitFiles: files,
4
+ var current = EditorStore.getState().gitFiles;
5
+ var stateUpdate = {
6
6
  gitBranch: data.branch || "",
7
7
  gitInfo: data,
8
8
  gitInfoError: null
9
- });
9
+ };
10
+ if (JSON.stringify(files) !== JSON.stringify(current)) {
11
+ stateUpdate.gitFiles = files;
12
+ }
13
+ EditorStore.setState(stateUpdate);
10
14
  }
11
15
 
12
16
  function fetchInfo() {
@@ -1,6 +1,44 @@
1
1
  var SearchService = (function () {
2
2
  var SEARCH_PAGE_SIZE = 50;
3
3
 
4
+ // --- Result cache ---
5
+ var CACHE_TTL = 30000; // 30 seconds
6
+ var CACHE_MAX_SIZE = 20;
7
+ var _searchCache = new Map(); // key -> { data, expiresAt }
8
+
9
+ function _cacheKey(query, options, offset) {
10
+ return JSON.stringify([
11
+ query,
12
+ !!options.regex,
13
+ !!options.matchCase,
14
+ !!options.wholeWord,
15
+ offset || 0
16
+ ]);
17
+ }
18
+
19
+ function _cacheGet(key) {
20
+ var entry = _searchCache.get(key);
21
+ if (!entry) return null;
22
+ if (Date.now() > entry.expiresAt) {
23
+ _searchCache.delete(key);
24
+ return null;
25
+ }
26
+ return entry.data;
27
+ }
28
+
29
+ function _cacheSet(key, data) {
30
+ // FIFO approximation: evict oldest-inserted entry if at capacity (Map preserves insertion order, hits don't move to back)
31
+ if (_searchCache.size >= CACHE_MAX_SIZE && !_searchCache.has(key)) {
32
+ var oldest = _searchCache.keys().next().value;
33
+ _searchCache.delete(oldest);
34
+ }
35
+ _searchCache.set(key, { data: data, expiresAt: Date.now() + CACHE_TTL });
36
+ }
37
+
38
+ function invalidate() {
39
+ _searchCache.clear();
40
+ }
41
+
4
42
  var _miniSearch = new MiniSearch({
5
43
  fields: ['path', 'name'], // indexed fields
6
44
  storeFields: ['path', 'name', 'type'] // returned fields (type: 'file'|'dir')
@@ -9,6 +47,9 @@ var SearchService = (function () {
9
47
  // Flat doc list kept in sync with _miniSearch so we can do substring lookups.
10
48
  var _allDocs = [];
11
49
 
50
+ // Tracks the in-flight projectSearch request so stale requests can be aborted.
51
+ var _searchController = null;
52
+
12
53
  function buildIndex(treeData) {
13
54
  // Capture the tree data immediately so a subsequent refresh doesn't
14
55
  // clobber us before the idle callback fires.
@@ -68,23 +109,48 @@ var SearchService = (function () {
68
109
  var matchCase = !!(options && options.matchCase);
69
110
  var wholeWord = !!(options && options.wholeWord);
70
111
 
112
+ // Check cache before hitting the network
113
+ var key = _cacheKey(query, options, off);
114
+ var cached = _cacheGet(key);
115
+ if (cached) {
116
+ if (off === 0) {
117
+ EditorStore.setState({ searchResults: cached.results, searchHasMore: cached.hasMore });
118
+ } else {
119
+ var prevResults = EditorStore.getState().searchResults || [];
120
+ EditorStore.setState({ searchResults: prevResults.concat(cached.results), searchHasMore: cached.hasMore });
121
+ }
122
+ return Promise.resolve(cached);
123
+ }
124
+
125
+ if (_searchController) { _searchController.abort(); }
126
+ var controller = new AbortController();
127
+ _searchController = controller;
128
+
71
129
  return axios.get(window.mbeditorBasePath() + '/search', {
72
- params: { q: query, offset: off, limit: lim, regex: useRegex ? 'true' : 'false', match_case: matchCase ? 'true' : 'false', whole_word: wholeWord ? 'true' : 'false' }
130
+ params: { q: query, offset: off, limit: lim, regex: useRegex ? 'true' : 'false', match_case: matchCase ? 'true' : 'false', whole_word: wholeWord ? 'true' : 'false' },
131
+ signal: controller.signal
73
132
  }).then(function(res) {
133
+ _searchController = null;
74
134
  var data = res.data;
75
135
  var results = Array.isArray(data) ? data : (data && data.results || []);
76
136
  var hasMore = !Array.isArray(data) && !!(data && data.has_more);
77
137
  var totalCount = (data && data.total_count != null) ? data.total_count : null;
78
138
 
139
+ var payload = { results: results, hasMore: hasMore, totalCount: totalCount };
140
+ _cacheSet(key, payload);
141
+
79
142
  if (off === 0) {
80
143
  EditorStore.setState({ searchResults: results, searchHasMore: hasMore });
81
144
  } else {
82
145
  var prev = EditorStore.getState().searchResults || [];
83
146
  EditorStore.setState({ searchResults: prev.concat(results), searchHasMore: hasMore });
84
147
  }
85
- return { results: results, hasMore: hasMore, totalCount: totalCount };
148
+ return payload;
86
149
  })
87
150
  .catch(function(err) {
151
+ if (axios.isCancel(err) || (err && err.name === 'CanceledError')) {
152
+ return { results: [], hasMore: false, totalCount: null };
153
+ }
88
154
  EditorStore.setStatus("Search failed: " + err.message, "error");
89
155
  return { results: [], hasMore: false, totalCount: null };
90
156
  });
@@ -108,11 +174,34 @@ var SearchService = (function () {
108
174
  });
109
175
  }
110
176
 
177
+ // POST /replace_in_files — replace query with replacement across all matching files.
178
+ // options: { regex, matchCase, wholeWord }
179
+ // Returns a promise resolving to { replaced_count, files_affected, errors }.
180
+ function replaceInFiles(query, replacement, options) {
181
+ var useRegex = !!(options && options.regex);
182
+ var matchCase = !!(options && options.matchCase);
183
+ var wholeWord = !!(options && options.wholeWord);
184
+
185
+ return axios.post(window.mbeditorBasePath() + '/replace_in_files', {
186
+ query: query,
187
+ replacement: replacement,
188
+ regex: useRegex ? 'true' : 'false',
189
+ match_case: matchCase ? 'true' : 'false',
190
+ whole_word: wholeWord ? 'true' : 'false'
191
+ }, {
192
+ headers: { 'X-Mbeditor-Client': '1' }
193
+ }).then(function(res) {
194
+ return res.data;
195
+ });
196
+ }
197
+
111
198
  return {
112
199
  buildIndex: buildIndex,
113
200
  searchFiles: searchFiles,
114
201
  projectSearch: projectSearch,
115
202
  fetchPage: fetchPage,
203
+ invalidate: invalidate,
204
+ replaceInFiles: replaceInFiles,
116
205
  PAGE_SIZE: SEARCH_PAGE_SIZE
117
206
  };
118
207
  })();
@@ -1,4 +1,46 @@
1
1
  var TabManager = (function () {
2
+ var MAX_MODELS = 15;
3
+
4
+ // Evict the least-recently-used Monaco model that is not currently open in
5
+ // any pane. Call this before creating a new model entry.
6
+ function _evictLruModel() {
7
+ if (!window.__mbeditorModels) return;
8
+ var keys = Object.keys(window.__mbeditorModels);
9
+ if (keys.length < MAX_MODELS) return; // room available — evict one to make room for one new entry
10
+
11
+ // Collect the set of paths currently open in any pane.
12
+ var state = EditorStore.getState();
13
+ var openPaths = {};
14
+ state.panes.forEach(function(pane) {
15
+ pane.tabs.forEach(function(tab) {
16
+ openPaths[tab.path] = true;
17
+ });
18
+ });
19
+
20
+ // Find the eviction candidate: oldest lastAccessed that is not open.
21
+ var candidate = null;
22
+ var candidateTime = Infinity;
23
+ keys.forEach(function(path) {
24
+ if (openPaths[path]) return; // skip currently-open files
25
+ var entry = window.__mbeditorModels[path];
26
+ var t = entry.lastAccessed || 0; // || 0 treats pre-existing entries (no lastAccessed) as oldest, so they evict first
27
+ if (t < candidateTime) {
28
+ candidateTime = t;
29
+ candidate = path;
30
+ }
31
+ });
32
+
33
+ // If every cached model is currently open in a pane, skip eviction — never evict an active file.
34
+ // The cache may temporarily exceed MAX_MODELS; this is acceptable.
35
+ if (candidate) {
36
+ var entry = window.__mbeditorModels[candidate];
37
+ if (entry.model && !entry.model.isDisposed()) {
38
+ entry.model.dispose();
39
+ }
40
+ delete window.__mbeditorModels[candidate];
41
+ }
42
+ }
43
+
2
44
  function _isImagePath(path) {
3
45
  return /\.(png|jpe?g|gif|svg|ico|webp|bmp|avif)$/i.test(path || "");
4
46
  }
@@ -137,7 +179,17 @@ var TabManager = (function () {
137
179
  return;
138
180
  }
139
181
 
140
- FileService.getFile(path, { allowMissing: true }).then(function(data) {
182
+ // Use a prefetched result if available (hover-prefetch hit), otherwise fetch normally.
183
+ var prefetchPromise = FileService.getPrefetched(path);
184
+ var filePromise = prefetchPromise || FileService.getFile(path, { allowMissing: true });
185
+
186
+ filePromise.then(function(data) {
187
+ // getPrefetched can resolve to null if the in-flight request failed/was aborted.
188
+ // In that case fall back to a fresh fetch.
189
+ if (!data) return FileService.getFile(path, { allowMissing: true });
190
+ return data;
191
+ }).then(function(data) {
192
+ if (!data) { closeTab(paneId, path); return; }
141
193
  var loadedContent = typeof data.content === 'string' ? data.content : "";
142
194
  var fileNotFound = data && data.missing === true;
143
195
  _updateTab(paneId, path, {
@@ -155,6 +207,29 @@ var TabManager = (function () {
155
207
  }
156
208
  }).catch(function(err) {
157
209
  if (path.startsWith('diff://')) return; // diff tabs handle their own loading
210
+ if (err.response && err.response.status === 413) {
211
+ FileService.getFileChunk(path, 0, 500).then(function(data) {
212
+ var loadedContent = typeof data.content === 'string' ? data.content : "";
213
+ _updateTab(paneId, path, {
214
+ content: loadedContent,
215
+ cleanContent: loadedContent,
216
+ externalContentVersion: 1,
217
+ isImage: false,
218
+ fileNotFound: false,
219
+ dirty: false,
220
+ loading: false,
221
+ truncated: true,
222
+ startLine: data.start_line || 0,
223
+ lineCount: data.line_count || 0,
224
+ totalLines: data.total_lines || 0,
225
+ totalBytes: data.total_bytes || 0
226
+ });
227
+ }).catch(function(chunkErr) {
228
+ EditorStore.setStatus("Failed to load large file: " + ((chunkErr.response && chunkErr.response.data && chunkErr.response.data.error) || chunkErr.message), "error");
229
+ closeTab(paneId, path);
230
+ });
231
+ return;
232
+ }
158
233
  EditorStore.setStatus("Failed to load file: " + ((err.response && err.response.data && err.response.data.error) || err.message), "error");
159
234
  closeTab(paneId, path);
160
235
  });
@@ -497,6 +572,7 @@ var TabManager = (function () {
497
572
  clearGotoLine: clearGotoLine,
498
573
  closeAllTabsInPane: closeAllTabsInPane,
499
574
  closeAllTabs: closeAllTabs,
500
- syncMarkdownPreview: _syncMarkdownPreviewContent
575
+ syncMarkdownPreview: _syncMarkdownPreviewContent,
576
+ evictLruModel: _evictLruModel
501
577
  };
502
578
  })();