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.
@@ -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.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, {
@@ -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;