mbeditor 0.4.5 → 0.5.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +17 -0
- data/app/assets/javascripts/mbeditor/application_iife_tail.js +1 -0
- data/app/assets/javascripts/mbeditor/components/EditorPanel.js +35 -16
- 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 +173 -0
- data/app/assets/javascripts/mbeditor/file_service.js +84 -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 +55 -2
- data/app/assets/stylesheets/mbeditor/editor.css +29 -0
- data/app/controllers/mbeditor/editors_controller.rb +295 -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
|
)
|
|
@@ -62,6 +62,9 @@
|
|
|
62
62
|
|
|
63
63
|
function rubyIndentUnit(model) {
|
|
64
64
|
var options = model.getOptions ? model.getOptions() : null;
|
|
65
|
+
if (options && options.insertSpaces === false) {
|
|
66
|
+
return '\t';
|
|
67
|
+
}
|
|
65
68
|
var tabSize = options && options.tabSize ? options.tabSize : 4;
|
|
66
69
|
return new Array(tabSize + 1).join(' ');
|
|
67
70
|
}
|
|
@@ -350,6 +353,46 @@
|
|
|
350
353
|
});
|
|
351
354
|
}
|
|
352
355
|
|
|
356
|
+
// Unused method dimming — grey out `def method_name` for methods with no
|
|
357
|
+
// call-sites anywhere in the workspace.
|
|
358
|
+
var unusedDecIds = [];
|
|
359
|
+
var unusedTimer = null;
|
|
360
|
+
var unusedSaveDisposable = null;
|
|
361
|
+
|
|
362
|
+
if (language === 'ruby' && typeof FileService !== 'undefined' && FileService.getUnusedMethods) {
|
|
363
|
+
function refreshUnused() {
|
|
364
|
+
var path = model._mbeditorPath;
|
|
365
|
+
if (!path) return;
|
|
366
|
+
FileService.getUnusedMethods(path).then(function(data) {
|
|
367
|
+
var unused = data && Array.isArray(data.unused) ? data.unused : [];
|
|
368
|
+
var newDecs = unused.map(function(m) {
|
|
369
|
+
var lineContent = model.getLineContent(m.line);
|
|
370
|
+
var defIdx = lineContent.indexOf('def ');
|
|
371
|
+
if (defIdx < 0) return null;
|
|
372
|
+
var nameCol = defIdx + 5; // 1-based column of method name
|
|
373
|
+
return {
|
|
374
|
+
range: new monaco.Range(m.line, nameCol, m.line, nameCol + m.name.length),
|
|
375
|
+
options: {
|
|
376
|
+
inlineClassName: 'mbeditor-unused-def',
|
|
377
|
+
stickiness: monaco.editor.TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges,
|
|
378
|
+
hoverMessage: { value: '**Unused method** — `' + m.name + '` is never called in this application.' }
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
}).filter(Boolean);
|
|
382
|
+
unusedDecIds = editor.deltaDecorations(unusedDecIds, newDecs);
|
|
383
|
+
}).catch(function() {});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Initial check after a short delay (server cache may not be warm yet).
|
|
387
|
+
unusedTimer = setTimeout(refreshUnused, 3000);
|
|
388
|
+
|
|
389
|
+
// Re-check after each save event (model content stops changing).
|
|
390
|
+
unusedSaveDisposable = model.onDidChangeContent(function() {
|
|
391
|
+
clearTimeout(unusedTimer);
|
|
392
|
+
unusedTimer = setTimeout(refreshUnused, 5000);
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
353
396
|
var contentDisposable = model.onDidChangeContent(function (event) {
|
|
354
397
|
if (suppressInternalEdit) return;
|
|
355
398
|
if (event.isUndoing || event.isRedoing) return;
|
|
@@ -381,6 +424,9 @@
|
|
|
381
424
|
if (gotoMouseDisposable) gotoMouseDisposable.dispose();
|
|
382
425
|
if (gotoActionDisposable) gotoActionDisposable.dispose();
|
|
383
426
|
contentDisposable.dispose();
|
|
427
|
+
if (unusedSaveDisposable) unusedSaveDisposable.dispose();
|
|
428
|
+
clearTimeout(unusedTimer);
|
|
429
|
+
if (unusedDecIds.length > 0) { editor.deltaDecorations(unusedDecIds, []); }
|
|
384
430
|
}
|
|
385
431
|
};
|
|
386
432
|
}
|
|
@@ -391,6 +437,42 @@
|
|
|
391
437
|
|
|
392
438
|
globalsRegistered = true;
|
|
393
439
|
|
|
440
|
+
// JavaScript: enable semantic checking (off by default in Monaco) and JSX support.
|
|
441
|
+
// checkJs catches undefined variables, noUnusedLocals catches dead assignments.
|
|
442
|
+
if (monaco.languages.typescript && monaco.languages.typescript.javascriptDefaults) {
|
|
443
|
+
monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
|
|
444
|
+
noSemanticValidation: false,
|
|
445
|
+
noSyntaxValidation: false,
|
|
446
|
+
noSuggestionDiagnostics: false
|
|
447
|
+
});
|
|
448
|
+
monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
|
|
449
|
+
target: monaco.languages.typescript.ScriptTarget.ES2020,
|
|
450
|
+
allowNonTsExtensions: true,
|
|
451
|
+
allowJs: true,
|
|
452
|
+
checkJs: true,
|
|
453
|
+
jsx: monaco.languages.typescript.JsxEmit.React,
|
|
454
|
+
noUnusedLocals: true
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// TypeScript: enable JSX for .tsx files and catch unused locals.
|
|
459
|
+
if (monaco.languages.typescript && monaco.languages.typescript.typescriptDefaults) {
|
|
460
|
+
monaco.languages.typescript.typescriptDefaults.setCompilerOptions({
|
|
461
|
+
target: monaco.languages.typescript.ScriptTarget.ES2020,
|
|
462
|
+
allowNonTsExtensions: true,
|
|
463
|
+
jsx: monaco.languages.typescript.JsxEmit.React,
|
|
464
|
+
noUnusedLocals: true
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// CSS for greyed-out unused method names (applied via decoration inlineClassName).
|
|
469
|
+
if (!document.getElementById('mbeditor-unused-style')) {
|
|
470
|
+
var unusedStyle = document.createElement('style');
|
|
471
|
+
unusedStyle.id = 'mbeditor-unused-style';
|
|
472
|
+
unusedStyle.textContent = '.mbeditor-unused-def { opacity: 0.35; }';
|
|
473
|
+
document.head.appendChild(unusedStyle);
|
|
474
|
+
}
|
|
475
|
+
|
|
394
476
|
monaco.languages.setLanguageConfiguration('ruby', {
|
|
395
477
|
comments: { lineComment: '#', blockComment: ['=begin', '=end'] },
|
|
396
478
|
brackets: [['(', ')'], ['{', '}'], ['[', ']']],
|
|
@@ -730,6 +812,34 @@
|
|
|
730
812
|
if (RUBY_CORE_METHODS[word]) return null;
|
|
731
813
|
if (typeof FileService === 'undefined' || !FileService.getDefinition) return null;
|
|
732
814
|
|
|
815
|
+
// Uppercase first letter → likely a module/class name.
|
|
816
|
+
// Look up the module's exposed methods via /module_members.
|
|
817
|
+
if (/^[A-Z]/.test(word) && FileService.getModuleMembers) {
|
|
818
|
+
var modKey = '__mod__' + word;
|
|
819
|
+
var modCached = hoverCache[modKey];
|
|
820
|
+
if (modCached && (Date.now() - modCached.ts) < HOVER_CACHE_TTL_MS) {
|
|
821
|
+
return modCached.result || null;
|
|
822
|
+
}
|
|
823
|
+
var modController = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
|
824
|
+
if (modController && token && token.onCancellationRequested) {
|
|
825
|
+
token.onCancellationRequested(function() { modController.abort(); });
|
|
826
|
+
}
|
|
827
|
+
return FileService.getModuleMembers(word, modController ? { signal: modController.signal } : {}).then(function(data) {
|
|
828
|
+
if (token && token.isCancellationRequested) return null;
|
|
829
|
+
if (!data || !data.methods || data.methods.length === 0) {
|
|
830
|
+
hoverCache[modKey] = { ts: Date.now(), result: null };
|
|
831
|
+
return null;
|
|
832
|
+
}
|
|
833
|
+
var lines = ['**' + data.name + '** `' + (data.file || '') + '`\n'];
|
|
834
|
+
data.methods.slice(0, 20).forEach(function(m) {
|
|
835
|
+
lines.push('- `' + (m.signature || m.name) + '`');
|
|
836
|
+
});
|
|
837
|
+
var result = { contents: [{ value: lines.join('\n') }] };
|
|
838
|
+
hoverCache[modKey] = { ts: Date.now(), result: result };
|
|
839
|
+
return result;
|
|
840
|
+
}).catch(function() { return null; });
|
|
841
|
+
}
|
|
842
|
+
|
|
733
843
|
var currentFile = model._mbeditorPath || null;
|
|
734
844
|
|
|
735
845
|
// Return cached result immediately if still fresh.
|
|
@@ -801,6 +911,69 @@
|
|
|
801
911
|
};
|
|
802
912
|
}
|
|
803
913
|
|
|
914
|
+
// Include-aware completion provider for Ruby.
|
|
915
|
+
// Suggests methods from modules included/extended/prepended in the current file,
|
|
916
|
+
// with Notepad++-style snippet tab stops for method parameters.
|
|
917
|
+
var includesCache = {};
|
|
918
|
+
var INCLUDES_CACHE_TTL_MS = 30000;
|
|
919
|
+
|
|
920
|
+
function parseMethodParams(signature) {
|
|
921
|
+
var m = /\(([^)]+)\)/.exec(signature || '');
|
|
922
|
+
if (!m) return [];
|
|
923
|
+
return m[1].split(',').map(function(p) {
|
|
924
|
+
return p.trim().replace(/^[*&]+/, '').replace(/\s*=.*$/, '').trim();
|
|
925
|
+
}).filter(function(p) { return p.length > 0; });
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
monaco.languages.registerCompletionItemProvider('ruby', {
|
|
929
|
+
triggerCharacters: ['.'],
|
|
930
|
+
provideCompletionItems: function(model, position) {
|
|
931
|
+
var path = model._mbeditorPath;
|
|
932
|
+
if (!path || typeof FileService === 'undefined' || !FileService.getFileIncludes) {
|
|
933
|
+
return { suggestions: [] };
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
var lineUpToCursor = model.getValueInRange({
|
|
937
|
+
startLineNumber: position.lineNumber, startColumn: 1,
|
|
938
|
+
endLineNumber: position.lineNumber, endColumn: position.column
|
|
939
|
+
});
|
|
940
|
+
var isDot = lineUpToCursor.slice(-1) === '.';
|
|
941
|
+
|
|
942
|
+
function buildSuggestions(data) {
|
|
943
|
+
var suggestions = [];
|
|
944
|
+
(data.includes || []).forEach(function(mod) {
|
|
945
|
+
(mod.methods || []).forEach(function(m) {
|
|
946
|
+
var params = parseMethodParams(m.signature);
|
|
947
|
+
var snippet = params.length > 0
|
|
948
|
+
? m.name + '(' + params.map(function(p, i) {
|
|
949
|
+
return '${' + (i + 1) + ':' + p + '}';
|
|
950
|
+
}).join(', ') + ')$0'
|
|
951
|
+
: m.name + '$0';
|
|
952
|
+
suggestions.push({
|
|
953
|
+
label: m.name,
|
|
954
|
+
kind: monaco.languages.CompletionItemKind.Method,
|
|
955
|
+
detail: mod.name + (mod.file ? ' ' + mod.file : ''),
|
|
956
|
+
insertText: snippet,
|
|
957
|
+
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
|
958
|
+
sortText: (isDot ? '0' : '1') + m.name
|
|
959
|
+
});
|
|
960
|
+
});
|
|
961
|
+
});
|
|
962
|
+
return { suggestions: suggestions };
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
var cached = includesCache[path];
|
|
966
|
+
if (cached && (Date.now() - cached.ts) < INCLUDES_CACHE_TTL_MS) {
|
|
967
|
+
return buildSuggestions(cached.data);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
return FileService.getFileIncludes(path).then(function(result) {
|
|
971
|
+
includesCache[path] = { ts: Date.now(), data: result };
|
|
972
|
+
return buildSuggestions(result);
|
|
973
|
+
}).catch(function() { return { suggestions: [] }; });
|
|
974
|
+
}
|
|
975
|
+
});
|
|
976
|
+
|
|
804
977
|
// Vim-style fold-marker folding provider.
|
|
805
978
|
// Recognises {{{ (open) and }}} (close) anywhere in a line, matching the
|
|
806
979
|
// 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; });
|
|
@@ -119,6 +125,77 @@ var FileService = (function () {
|
|
|
119
125
|
return axios.get(window.mbeditorBasePath() + '/definition', config).then(function(res) { return res.data; });
|
|
120
126
|
}
|
|
121
127
|
|
|
128
|
+
// Prefetch file content and store in prefetchCache. Uses native fetch + AbortController
|
|
129
|
+
// so the in-flight request can be cancelled on mouseleave without touching axios.
|
|
130
|
+
function prefetch(path) {
|
|
131
|
+
if (prefetchCache.has(path)) return; // already in-flight or cached
|
|
132
|
+
|
|
133
|
+
// Evict stale settled entries before adding a new one
|
|
134
|
+
var now = Date.now();
|
|
135
|
+
prefetchCache.forEach(function(entry, key) {
|
|
136
|
+
if (entry.resolvedAt !== null && now - entry.resolvedAt > PREFETCH_TTL_MS) {
|
|
137
|
+
prefetchCache.delete(key);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
var controller = new AbortController();
|
|
142
|
+
var entry = { controller: controller, promise: null, resolvedAt: null };
|
|
143
|
+
// allow_missing=1 so a 404 resolves to { missing:true } instead of rejecting
|
|
144
|
+
var url = window.mbeditorBasePath() + '/file?path=' + encodeURIComponent(path) + '&allow_missing=1';
|
|
145
|
+
entry.promise = fetch(url, {
|
|
146
|
+
signal: controller.signal,
|
|
147
|
+
headers: { 'X-Mbeditor-Client': '1' }
|
|
148
|
+
}).then(function(res) {
|
|
149
|
+
if (!res.ok) throw new Error('prefetch failed: ' + res.status);
|
|
150
|
+
return res.json();
|
|
151
|
+
}).then(function(data) {
|
|
152
|
+
entry.resolvedAt = Date.now();
|
|
153
|
+
return data;
|
|
154
|
+
}).catch(function(err) {
|
|
155
|
+
// Remove from cache on failure/abort so a real open falls back to a fresh fetch
|
|
156
|
+
prefetchCache.delete(path);
|
|
157
|
+
return null;
|
|
158
|
+
});
|
|
159
|
+
prefetchCache.set(path, entry);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Returns a Promise for the cached result and removes the entry (consume-once),
|
|
163
|
+
// or returns null if no prefetch is in-flight / completed for this path.
|
|
164
|
+
// Settled entries older than PREFETCH_TTL_MS are treated as expired.
|
|
165
|
+
function getPrefetched(path) {
|
|
166
|
+
var entry = prefetchCache.get(path);
|
|
167
|
+
if (!entry) return null;
|
|
168
|
+
if (entry.resolvedAt !== null && Date.now() - entry.resolvedAt > PREFETCH_TTL_MS) {
|
|
169
|
+
prefetchCache.delete(path);
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
prefetchCache.delete(path);
|
|
173
|
+
return entry.promise;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Abort any in-flight prefetch for path and remove it from the cache.
|
|
177
|
+
function cancelPrefetch(path) {
|
|
178
|
+
var entry = prefetchCache.get(path);
|
|
179
|
+
if (!entry) return;
|
|
180
|
+
entry.controller.abort();
|
|
181
|
+
prefetchCache.delete(path);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function getModuleMembers(name, extraOptions) {
|
|
185
|
+
var config = Object.assign({ params: { name: name }, timeout: 8000 }, extraOptions || {});
|
|
186
|
+
return axios.get(window.mbeditorBasePath() + '/module_members', config).then(function(res) { return res.data; });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function getFileIncludes(path, extraOptions) {
|
|
190
|
+
var config = Object.assign({ params: { path: path }, timeout: 15000 }, extraOptions || {});
|
|
191
|
+
return axios.get(window.mbeditorBasePath() + '/file_includes', config).then(function(res) { return res.data; });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function getUnusedMethods(path, extraOptions) {
|
|
195
|
+
var config = Object.assign({ params: { path: path }, timeout: 30000 }, extraOptions || {});
|
|
196
|
+
return axios.get(window.mbeditorBasePath() + '/unused_methods', config).then(function(res) { return res.data; });
|
|
197
|
+
}
|
|
198
|
+
|
|
122
199
|
return {
|
|
123
200
|
getWorkspace: getWorkspace,
|
|
124
201
|
getTree: getTree,
|
|
@@ -138,6 +215,12 @@ var FileService = (function () {
|
|
|
138
215
|
getBranchState: getBranchState,
|
|
139
216
|
saveBranchState: saveBranchState,
|
|
140
217
|
pruneBranchStates: pruneBranchStates,
|
|
141
|
-
getDefinition: getDefinition
|
|
218
|
+
getDefinition: getDefinition,
|
|
219
|
+
prefetch: prefetch,
|
|
220
|
+
getPrefetched: getPrefetched,
|
|
221
|
+
cancelPrefetch: cancelPrefetch,
|
|
222
|
+
getModuleMembers: getModuleMembers,
|
|
223
|
+
getFileIncludes: getFileIncludes,
|
|
224
|
+
getUnusedMethods: getUnusedMethods
|
|
142
225
|
};
|
|
143
226
|
})();
|
|
@@ -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, {
|
|
@@ -497,6 +549,7 @@ var TabManager = (function () {
|
|
|
497
549
|
clearGotoLine: clearGotoLine,
|
|
498
550
|
closeAllTabsInPane: closeAllTabsInPane,
|
|
499
551
|
closeAllTabs: closeAllTabs,
|
|
500
|
-
syncMarkdownPreview: _syncMarkdownPreviewContent
|
|
552
|
+
syncMarkdownPreview: _syncMarkdownPreviewContent,
|
|
553
|
+
evictLruModel: _evictLruModel
|
|
501
554
|
};
|
|
502
555
|
})();
|
|
@@ -837,6 +837,12 @@ html, body, #mbeditor-root {
|
|
|
837
837
|
padding: 0 6px;
|
|
838
838
|
}
|
|
839
839
|
|
|
840
|
+
.statusbar-zen-btn {
|
|
841
|
+
font-size: 10px;
|
|
842
|
+
font-weight: 600;
|
|
843
|
+
letter-spacing: 0.04em;
|
|
844
|
+
}
|
|
845
|
+
|
|
840
846
|
/* ── Search Panel ─────────────────────────────────────────── */
|
|
841
847
|
.search-panel {
|
|
842
848
|
padding: 8px;
|
|
@@ -1432,6 +1438,29 @@ html, body, #mbeditor-root {
|
|
|
1432
1438
|
gap: 8px;
|
|
1433
1439
|
}
|
|
1434
1440
|
|
|
1441
|
+
/* Open-editors panel: height is controlled by the --open-editors-height CSS variable
|
|
1442
|
+
set inline from React state (default 140px ≈ 5-6 items), drag-resizable via the
|
|
1443
|
+
.open-editors-resize-handle strip below. */
|
|
1444
|
+
.ide-sidebar-fixed .collapsible-content {
|
|
1445
|
+
max-height: var(--open-editors-height, 140px);
|
|
1446
|
+
overflow-y: auto;
|
|
1447
|
+
overflow-x: hidden;
|
|
1448
|
+
}
|
|
1449
|
+
.ide-sidebar-fixed .collapsible-content::-webkit-scrollbar { width: 6px; }
|
|
1450
|
+
.ide-sidebar-fixed .collapsible-content::-webkit-scrollbar-track { background: transparent; }
|
|
1451
|
+
.ide-sidebar-fixed .collapsible-content::-webkit-scrollbar-thumb { background: var(--ide-scrollbar); border-radius: 3px; }
|
|
1452
|
+
|
|
1453
|
+
.open-editors-resize-handle {
|
|
1454
|
+
height: 4px;
|
|
1455
|
+
flex-shrink: 0;
|
|
1456
|
+
cursor: row-resize;
|
|
1457
|
+
background: transparent;
|
|
1458
|
+
transition: background 0.15s;
|
|
1459
|
+
}
|
|
1460
|
+
.open-editors-resize-handle:hover {
|
|
1461
|
+
background: var(--ide-border);
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1435
1464
|
.open-editors-group-header {
|
|
1436
1465
|
font-size: 10px;
|
|
1437
1466
|
padding-left: 8px;
|