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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +23 -0
- data/app/assets/javascripts/mbeditor/application_iife_tail.js +1 -0
- data/app/assets/javascripts/mbeditor/components/EditorPanel.js +205 -18
- data/app/assets/javascripts/mbeditor/components/FileTree.js +23 -1
- data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +324 -48
- data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +2 -0
- data/app/assets/javascripts/mbeditor/editor_plugins.js +226 -0
- data/app/assets/javascripts/mbeditor/file_service.js +93 -1
- data/app/assets/javascripts/mbeditor/git_service.js +7 -3
- data/app/assets/javascripts/mbeditor/search_service.js +91 -2
- data/app/assets/javascripts/mbeditor/tab_manager.js +78 -2
- data/app/assets/stylesheets/mbeditor/editor.css +29 -0
- data/app/controllers/mbeditor/editors_controller.rb +318 -41
- data/app/services/mbeditor/ruby_definition_service.rb +163 -21
- data/app/services/mbeditor/unused_methods_service.rb +139 -0
- data/app/views/layouts/mbeditor/application.html.erb +86 -56
- data/config/routes.rb +4 -0
- data/lib/mbeditor/version.rb +1 -1
- metadata +3 -2
|
@@ -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.
|
|
5
|
-
|
|
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
|
|
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
|
-
|
|
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
|
})();
|