mbeditor 0.1.2 → 0.1.5

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 815b7e6688f6e3d377f9c0e0f502f5b2c105da7058209d45e067c20096186c67
4
- data.tar.gz: c6f493c1a1a2a4c1f6d4ed14eb593afc28d8b173c88a4fdf1954899f44500f6c
3
+ metadata.gz: 414b42474453e80d58cfafbab5db7d5381dd8ebb22ba1e852a10e720eb246d8a
4
+ data.tar.gz: 0fbaffaedc1aed6196589975816fb9b009df7f4a810dcc41e3aa428d8e19ee37
5
5
  SHA512:
6
- metadata.gz: b450317bed54ea23c246042a2a86a1a3468f20de9a3d71b81ca7a7a69a80d01279ad40ca569a755077317113099f4cb931702ad620448a812ffecc4d1b4b1508
7
- data.tar.gz: 3b23f5db3e43051546fd8997b8b5b975403aa7479c68463b7f1ebe1c6cc812f30322b70b2e838da51c012e7cd87604816d886715259af8169428f13f72a62d55
6
+ metadata.gz: 4bab09aab7ae92d7835838d3c4b240915d0c3ff0c3fc32516a69bdad24430246c86c0da3f508946e9c738ca94a0966d7457903ba9095000a4f01643ca298dbe7
7
+ data.tar.gz: 522f1e56c9c280ccf9078f9f960ad1c3e3f1d1f99fafc2e9d80c18d539cd6ba409d18cb596b434037e551403623b74c54c7f2f4dfe8c03c90760eebe8b51862c
data/README.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # mbeditor
2
2
 
3
+ [![Gem Version](https://badge.fury.io/rb/mbeditor.svg)](https://rubygems.org/gems/mbeditor)
4
+ [![Test](https://github.com/ojnoonan/mbeditor/actions/workflows/test.yml/badge.svg)](https://github.com/ojnoonan/mbeditor/actions/workflows/test.yml)
5
+
3
6
  Mbeditor (Mini Browser Editor) is a mountable Rails engine that adds a browser-based editor UI to a Rails app.
4
7
 
5
8
  ## Features
@@ -1,3 +1,9 @@
1
+ //= require mbeditor/editor_store
2
+ //= require mbeditor/file_icon
3
+ //= require mbeditor/file_service
4
+ //= require mbeditor/git_service
5
+ //= require mbeditor/search_service
6
+ //= require mbeditor/tab_manager
1
7
  //= require mbeditor/editor_plugins
2
8
  //= require mbeditor/components/CollapsibleSection
3
9
  //= require mbeditor/components/ShortcutHelp
@@ -12,8 +18,3 @@
12
18
  //= require mbeditor/components/QuickOpenDialog
13
19
  //= require mbeditor/components/TabBar
14
20
  //= require mbeditor/components/MbeditorApp
15
- //= require mbeditor/editor_store
16
- //= require mbeditor/file_service
17
- //= require mbeditor/git_service
18
- //= require mbeditor/search_service
19
- //= require mbeditor/tab_manager
@@ -83,10 +83,12 @@ var DiffViewer = function DiffViewer(_ref) {
83
83
 
84
84
  function getLanguageForPath(filePath) {
85
85
  if (!filePath) return 'plaintext';
86
+ var fileName = filePath.split('/').pop().toLowerCase();
87
+ if (fileName === 'gemfile' || fileName === 'gemfile.lock' || fileName === 'rakefile') return 'ruby';
86
88
  var ext = filePath.split('.').pop().toLowerCase();
87
89
  var map = {
88
90
  'rb': 'ruby', 'js': 'javascript', 'jsx': 'javascript',
89
- 'ts': 'javascript', 'tsx': 'javascript',
91
+ 'ts': 'typescript', 'tsx': 'typescript',
90
92
  'json': 'json', 'yml': 'yaml', 'yaml': 'yaml',
91
93
  'css': 'css', 'scss': 'scss', 'html': 'html',
92
94
  'xml': 'xml', 'md': 'markdown', 'sh': 'shell'
@@ -40,6 +40,19 @@ var EditorPanel = function EditorPanel(_ref) {
40
40
  var setIsBlameLoading = _useState8[1];
41
41
 
42
42
  var blameDecorationsRef = useRef([]);
43
+ var blameZoneIdsRef = useRef([]);
44
+
45
+ var clearBlameZones = function clearBlameZones(editor) {
46
+ if (!editor) return;
47
+ if (blameZoneIdsRef.current.length === 0) return;
48
+
49
+ editor.changeViewZones(function(accessor) {
50
+ blameZoneIdsRef.current.forEach(function(zoneId) {
51
+ accessor.removeZone(zoneId);
52
+ });
53
+ });
54
+ blameZoneIdsRef.current = [];
55
+ };
43
56
 
44
57
  var findTabByPath = function findTabByPath(path) {
45
58
  if (!path) return null;
@@ -62,16 +75,23 @@ var EditorPanel = function EditorPanel(_ref) {
62
75
  window.MbeditorEditorPlugins.registerGlobalExtensions(window.monaco);
63
76
  }
64
77
 
65
- var parts = tab.path.split('.');
78
+ var fileName = tab.path.split('/').pop() || '';
79
+ var parts = fileName.split('.');
66
80
  var extension = parts.length > 1 ? parts.pop().toLowerCase() : '';
67
81
  var language = 'plaintext';
68
- switch (extension) {
69
- case 'rb':case 'ruby':case 'gemspec':case 'rakefile':
82
+ switch (fileName.toLowerCase()) {
83
+ case 'gemfile':
84
+ case 'gemfile.lock':
85
+ case 'rakefile':
70
86
  language = 'ruby';break;
87
+ default:
88
+ switch (extension) {
89
+ case 'rb':case 'ruby':case 'gemspec':
90
+ language = 'ruby';break;
71
91
  case 'js':case 'jsx':
72
92
  language = 'javascript';break;
73
93
  case 'ts':case 'tsx':
74
- language = 'javascript';break;
94
+ language = 'typescript';break;
75
95
  case 'css':case 'scss':case 'sass':
76
96
  language = 'css';break;
77
97
  case 'html':case 'erb':
@@ -88,6 +108,7 @@ var EditorPanel = function EditorPanel(_ref) {
88
108
  language = 'shell';break;
89
109
  case 'png':case 'jpg':case 'jpeg':case 'gif':case 'svg':case 'ico':case 'webp':case 'bmp':case 'avif':
90
110
  language = 'image';break;
111
+ }
91
112
  }
92
113
 
93
114
  if (language === 'image') return;
@@ -143,6 +164,8 @@ var EditorPanel = function EditorPanel(_ref) {
143
164
  });
144
165
 
145
166
  return function () {
167
+ blameDecorationsRef.current = editor.deltaDecorations(blameDecorationsRef.current, []);
168
+ clearBlameZones(editor);
146
169
  TabManager.saveTabViewState(tab.id, editor.saveViewState());
147
170
  if (window.__mbeditorActiveEditor === editor) {
148
171
  window.__mbeditorActiveEditor = null;
@@ -224,14 +247,20 @@ var EditorPanel = function EditorPanel(_ref) {
224
247
  // Reset blame state when file path changes
225
248
  useEffect(function () {
226
249
  setBlameData(null);
227
- setIsBlameVisible(false);
228
250
  setIsBlameLoading(false);
251
+
252
+ // Clear stale blame render when switching files.
253
+ if (monacoRef.current && monacoRef.current.getModel()) {
254
+ clearBlameZones(monacoRef.current);
255
+ blameDecorationsRef.current = monacoRef.current.deltaDecorations(blameDecorationsRef.current, []);
256
+ }
229
257
  }, [tab.path]);
230
258
 
231
259
  // Handle Blame data fetching
232
260
  useEffect(function () {
233
261
  if (!isBlameVisible) {
234
262
  if (monacoRef.current && monacoRef.current.getModel()) {
263
+ clearBlameZones(monacoRef.current);
235
264
  blameDecorationsRef.current = monacoRef.current.deltaDecorations(blameDecorationsRef.current, []);
236
265
  }
237
266
  return;
@@ -240,7 +269,13 @@ var EditorPanel = function EditorPanel(_ref) {
240
269
  if (!blameData && !isBlameLoading) {
241
270
  setIsBlameLoading(true);
242
271
  GitService.fetchBlame(tab.path).then(function(data) {
243
- setBlameData(data.lines || []);
272
+ var lines = data && Array.isArray(data.lines) ? data.lines : [];
273
+ setBlameData(lines);
274
+ if (lines.length === 0) {
275
+ EditorStore.setStatus('No blame data available for this file', 'warning');
276
+ } else {
277
+ EditorStore.setStatus('Loaded blame for ' + lines.length + ' lines', 'info');
278
+ }
244
279
  setIsBlameLoading(false);
245
280
  }).catch(function(err) {
246
281
  var status = err.response && err.response.status;
@@ -248,38 +283,89 @@ var EditorPanel = function EditorPanel(_ref) {
248
283
  ? "File is not tracked by git"
249
284
  : "Failed to load blame: " + ((err.response && err.response.data && err.response.data.error) || err.message);
250
285
  EditorStore.setStatus(msg, "error");
286
+ setBlameData([]);
251
287
  setIsBlameLoading(false);
252
- setIsBlameVisible(false);
253
288
  });
254
289
  }
255
290
  }, [isBlameVisible, tab.path, blameData, isBlameLoading]);
256
291
 
257
- // Render Blame decorations
292
+ // Render Blame block headers (author + summary) above contiguous commit regions.
258
293
  useEffect(function () {
259
294
  if (!monacoRef.current || !window.monaco || !isBlameVisible || !blameData) return;
295
+
260
296
  var editor = monacoRef.current;
261
-
262
- var newDecorations = blameData.map(function(lineData) {
263
- var ln = lineData.line;
264
- var hash = lineData.sha && lineData.sha.substring(0, 8) || '';
265
- var author = lineData.author || '';
266
- // Exclude uncommitted changes from noisy blame
267
- var isUncommitted = hash === '00000000';
268
- var text = isUncommitted ? 'Not Committed' : author + ' \xB7 ' + hash;
269
-
270
- return {
271
- range: new window.monaco.Range(ln, 1, ln, 1),
272
- options: {
273
- isWholeLine: false,
274
- after: {
275
- content: '\xA0\xA0\xA0\xA0' + text,
276
- inlineClassName: isUncommitted ? 'ide-blame-annotation-uncommitted' : 'ide-blame-annotation'
277
- }
297
+ var model = editor.getModel();
298
+ var lineCount = model ? model.getLineCount() : 0;
299
+
300
+ try {
301
+ // Clear previous render before rebuilding.
302
+ clearBlameZones(editor);
303
+ blameDecorationsRef.current = editor.deltaDecorations(blameDecorationsRef.current, []);
304
+
305
+ var normalized = blameData.map(function(lineData) {
306
+ var ln = Number(lineData && lineData.line);
307
+ if (!model || !ln || ln < 1 || ln > lineCount) return null;
308
+
309
+ var sha = lineData && lineData.sha || '';
310
+ var author = lineData && lineData.author || 'Unknown';
311
+ var summary = lineData && lineData.summary || 'No commit message';
312
+ var isUncommitted = sha.substring(0, 8) === '00000000';
313
+
314
+ return {
315
+ line: ln,
316
+ sha: sha,
317
+ author: isUncommitted ? 'Not Committed' : author,
318
+ summary: summary,
319
+ isUncommitted: isUncommitted
320
+ };
321
+ }).filter(Boolean);
322
+
323
+ normalized.sort(function(a, b) { return a.line - b.line; });
324
+
325
+ var blocks = [];
326
+ normalized.forEach(function(item) {
327
+ var current = blocks.length > 0 ? blocks[blocks.length - 1] : null;
328
+ if (!current || current.sha !== item.sha || item.line !== current.endLine + 1) {
329
+ blocks.push({
330
+ sha: item.sha,
331
+ author: item.author,
332
+ summary: item.summary,
333
+ isUncommitted: item.isUncommitted,
334
+ startLine: item.line,
335
+ endLine: item.line
336
+ });
337
+ return;
278
338
  }
279
- };
280
- });
281
-
282
- blameDecorationsRef.current = editor.deltaDecorations(blameDecorationsRef.current, newDecorations);
339
+ current.endLine = item.line;
340
+ });
341
+
342
+ var zoneIds = [];
343
+ editor.changeViewZones(function(accessor) {
344
+ blocks.forEach(function(block, idx) {
345
+ var header = document.createElement('div');
346
+ header.className = block.isUncommitted
347
+ ? 'ide-blame-block-header ide-blame-block-header-uncommitted'
348
+ : 'ide-blame-block-header';
349
+ header.textContent = block.author + ' - ' + block.summary;
350
+
351
+ var zoneId = accessor.addZone({
352
+ afterLineNumber: block.startLine > 1 ? block.startLine - 1 : 0,
353
+ heightInLines: 1,
354
+ domNode: header,
355
+ suppressMouseDown: true
356
+ });
357
+ zoneIds.push(zoneId);
358
+ });
359
+ });
360
+ blameZoneIdsRef.current = zoneIds;
361
+ } catch (err) {
362
+ var message = err && err.message ? err.message : 'Unknown decoration error';
363
+ EditorStore.setStatus('Failed to render blame annotations: ' + message, 'error');
364
+ clearBlameZones(editor);
365
+ blameDecorationsRef.current = editor.deltaDecorations(blameDecorationsRef.current, []);
366
+ }
367
+
368
+ // Include tab.content so blame re-renders once async file contents finish loading.
283
369
  }, [blameData, isBlameVisible, tab.id, tab.content]);
284
370
 
285
371
  var sourceTab = tab.isPreview ? findTabByPath(tab.previewFor) : null;
@@ -337,20 +423,24 @@ var EditorPanel = function EditorPanel(_ref) {
337
423
  return React.createElement('div', { className: 'markdown-preview markdown-preview-full', dangerouslySetInnerHTML: { __html: markup } });
338
424
  }
339
425
 
426
+ // Always render the same wrapper structure so the editorRef div is never
427
+ // unmounted when gitAvailable changes (e.g. loaded async after workspace
428
+ // call returns). The toolbar is conditionally included inside the wrapper.
340
429
  return React.createElement(
341
430
  'div',
342
431
  { className: 'ide-editor-wrapper', style: { display: 'flex', flexDirection: 'column', height: '100%' } },
343
- React.createElement(
432
+ gitAvailable && React.createElement(
344
433
  'div',
345
434
  { className: 'ide-editor-toolbar', style: { display: 'flex', justifyContent: 'flex-end', padding: '4px 8px', background: '#252526', borderBottom: '1px solid #3c3c3c' } },
346
435
  React.createElement(
347
436
  'button',
348
- {
349
- className: 'ide-icon-btn ' + (isBlameVisible ? 'active' : ''),
350
- disabled: !gitAvailable,
351
- onClick: gitAvailable ? function() { setIsBlameVisible(!isBlameVisible); } : undefined,
352
- title: gitAvailable ? 'Toggle Git Blame' : 'Git not available in this workspace',
353
- style: { fontSize: '12px', padding: '2px 6px', opacity: (gitAvailable && isBlameVisible) ? 1 : 0.6, background: isBlameVisible ? 'rgba(255,255,255,0.1)' : 'transparent', border: 'none', color: gitAvailable ? '#ccc' : '#666', cursor: gitAvailable ? 'pointer' : 'not-allowed', borderRadius: '3px' }
437
+ {
438
+ className: 'ide-icon-btn ' + (isBlameVisible ? 'active' : ''),
439
+ onClick: function() {
440
+ setIsBlameVisible(function(prev) { return !prev; });
441
+ },
442
+ title: 'Toggle Git Blame',
443
+ style: { fontSize: '12px', padding: '2px 6px', opacity: isBlameVisible ? 1 : 0.6, background: isBlameVisible ? 'rgba(255,255,255,0.1)' : 'transparent', border: 'none', color: '#ccc', cursor: 'pointer', borderRadius: '3px' }
354
444
  },
355
445
  React.createElement('i', { className: 'fas fa-shoe-prints', style: { marginRight: '6px' } }),
356
446
  isBlameLoading ? 'Loading...' : 'Blame'
@@ -109,23 +109,6 @@ var FileTree = function FileTree(_ref) {
109
109
  return { badge: key || '?', cssKey: key || 'Q', title: titleMap[key] || 'Status' };
110
110
  };
111
111
 
112
- window.getFileIcon = function (name) {
113
- var ext = name.split('.').pop().toLowerCase();
114
- var lName = name.toLowerCase();
115
- if (lName === 'gemfile' || ext === 'gemspec' || ext === 'lock') return 'fas fa-gem ruby-icon';
116
- if (ext === 'rb' || ext === 'rake' || lName === 'rakefile') return 'far fa-gem ruby-icon';
117
- if (ext === 'jsx' || name.endsWith('.js.jsx')) return 'fas fa-atom react-icon';
118
- if (ext === 'js' || ext === 'mjs' || ext === 'cjs') return 'fa-brands fa-js js-icon';
119
- if (ext === 'html') return 'fa-brands fa-html5 html-icon';
120
- if (ext === 'erb') return 'fa-brands fa-html5 erb-icon';
121
- if (ext === 'css' || ext === 'scss' || ext === 'sass') return 'fa-brands fa-css3-alt css-icon';
122
- if (['png', 'jpg', 'jpeg', 'gif', 'svg', 'ico', 'webp', 'bmp', 'avif'].includes(ext)) return 'far fa-file-image image-icon';
123
- if (ext === 'json') return 'fas fa-code json-icon';
124
- if (ext === 'md' || ext === 'txt') return 'fas fa-file-alt md-icon';
125
- if (ext === 'yml' || ext === 'yaml') return 'fas fa-cogs yml-icon';
126
- return 'far fa-file-code';
127
- };
128
-
129
112
  var handleInlineKeyDown = function handleInlineKeyDown(e) {
130
113
  var isRename = !!pendingRename;
131
114
  if (e.key === 'Enter') {
@@ -300,5 +283,21 @@ var FileTree = function FileTree(_ref) {
300
283
  );
301
284
  };
302
285
 
286
+ // Wrap FileTree in React.memo with a custom comparator that only checks
287
+ // the data props that affect what's rendered. Function prop references
288
+ // (event handlers) are re-created on every parent render but do not
289
+ // change the visual output, so we intentionally ignore them here.
290
+ // This prevents O(n) tree traversal on every MbeditorApp re-render
291
+ // caused by unrelated state changes (status messages, git polls, etc.).
292
+ var FileTreeMemo = React.memo(FileTree, function(prev, next) {
293
+ return prev.items === next.items &&
294
+ prev.activePath === next.activePath &&
295
+ prev.selectedPath === next.selectedPath &&
296
+ prev.gitFiles === next.gitFiles &&
297
+ prev.expandedDirs === next.expandedDirs &&
298
+ prev.pendingCreate === next.pendingCreate &&
299
+ prev.pendingRename === next.pendingRename;
300
+ });
301
+
303
302
  // Expose globally for sprockets require
304
- window.FileTree = FileTree;
303
+ window.FileTree = FileTreeMemo;
@@ -16,6 +16,7 @@ var GitPanel = function GitPanel(_ref) {
16
16
  var _s4 = useState({}); var expandedCommits = _s4[0]; var setExpandedCommits = _s4[1];
17
17
  // { [hash]: { loading, files: [{status,path}], error } }
18
18
  var _s5 = useState({}); var commitFiles = _s5[0]; var setCommitFiles = _s5[1];
19
+ var _s6 = useState(false); var refreshing = _s6[0]; var setRefreshing = _s6[1];
19
20
 
20
21
  var workingTree = gitInfo && gitInfo.workingTree || [];
21
22
  var unpushedFiles = gitInfo && gitInfo.unpushedFiles || [];
@@ -46,36 +47,7 @@ var GitPanel = function GitPanel(_ref) {
46
47
  };
47
48
 
48
49
  var fileIcon = function fileIcon(filename) {
49
- var ext = (filename || '').split('.').pop().toLowerCase();
50
- var iconMap = {
51
- 'rb': { cls: 'fas fa-gem', color: '#cc342d' },
52
- 'gemspec': { cls: 'fas fa-gem', color: '#cc342d' },
53
- 'js': { cls: 'fab fa-js', color: '#f0db4f' },
54
- 'ts': { cls: 'fas fa-code', color: '#3178c6' },
55
- 'jsx': { cls: 'fab fa-js', color: '#61dafb' },
56
- 'tsx': { cls: 'fas fa-code', color: '#61dafb' },
57
- 'html': { cls: 'fab fa-html5', color: '#e34f26' },
58
- 'htm': { cls: 'fab fa-html5', color: '#e34f26' },
59
- 'erb': { cls: 'fab fa-html5', color: '#cc342d' },
60
- 'css': { cls: 'fab fa-css3-alt', color: '#1572b6' },
61
- 'scss': { cls: 'fab fa-sass', color: '#cc6699' },
62
- 'sass': { cls: 'fab fa-sass', color: '#cc6699' },
63
- 'md': { cls: 'fab fa-markdown', color: '#7f8b97' },
64
- 'json': { cls: 'fas fa-code', color: '#ffe082' },
65
- 'yml': { cls: 'fas fa-cog', color: '#888' },
66
- 'yaml': { cls: 'fas fa-cog', color: '#888' },
67
- 'png': { cls: 'fas fa-image', color: '#aaa' },
68
- 'jpg': { cls: 'fas fa-image', color: '#aaa' },
69
- 'jpeg': { cls: 'fas fa-image', color: '#aaa' },
70
- 'gif': { cls: 'fas fa-image', color: '#aaa' },
71
- 'svg': { cls: 'fas fa-image', color: '#aaa' },
72
- 'txt': { cls: 'fas fa-file-alt', color: '#888' },
73
- 'sh': { cls: 'fas fa-terminal', color: '#89d185' },
74
- 'lock': { cls: 'fas fa-lock', color: '#888' },
75
- 'pdf': { cls: 'fas fa-file-pdf', color: '#f48771' },
76
- };
77
- var icon = iconMap[ext] || { cls: 'fas fa-file-code', color: '#7f8b97' };
78
- return React.createElement('i', { className: icon.cls + ' git-file-type-icon', style: { color: icon.color } });
50
+ return React.createElement('i', { className: (window.getFileIcon ? window.getFileIcon(filename) : 'far fa-file-code') + ' git-file-type-icon' });
79
51
  };
80
52
 
81
53
  // Renders a file row used in Local Changes and Changes in Branch sections
@@ -148,6 +120,23 @@ var GitPanel = function GitPanel(_ref) {
148
120
  }
149
121
  };
150
122
 
123
+ var handleRefresh = function handleRefresh() {
124
+ if (!onRefresh || refreshing) return;
125
+
126
+ var result;
127
+ try {
128
+ setRefreshing(true);
129
+ result = onRefresh();
130
+ } catch (err) {
131
+ setRefreshing(false);
132
+ throw err;
133
+ }
134
+
135
+ return Promise.resolve(result).finally(function () {
136
+ setRefreshing(false);
137
+ });
138
+ };
139
+
151
140
  var renderCommit = function renderCommit(commit, idx) {
152
141
  var isFirst = idx === 0;
153
142
  var isLast = idx === branchCommits.length - 1;
@@ -305,8 +294,8 @@ var GitPanel = function GitPanel(_ref) {
305
294
  { className: 'ide-git-panel-actions' },
306
295
  onRefresh && React.createElement(
307
296
  'button',
308
- { className: 'git-header-btn', onClick: onRefresh, title: 'Refresh' },
309
- React.createElement('i', { className: 'fas fa-sync-alt' })
297
+ { className: 'git-header-btn', onClick: handleRefresh, title: 'Refresh', disabled: refreshing, 'aria-busy': refreshing },
298
+ React.createElement('i', { className: 'fas fa-sync-alt' + (refreshing ? ' fa-spin' : '') })
310
299
  ),
311
300
  onClose && React.createElement(
312
301
  'button',
@@ -121,6 +121,13 @@ var MbeditorApp = function MbeditorApp() {
121
121
  var searchQuery = _useState72[0];
122
122
  var setSearchQuery = _useState72[1];
123
123
 
124
+ var _useState33 = useState(false);
125
+ var _useState332 = _slicedToArray(_useState33, 2);
126
+ var searchLoading = _useState332[0];
127
+ var setSearchLoading = _useState332[1];
128
+
129
+ var searchRequestIdRef = useRef(0);
130
+
124
131
  var _useState8 = useState("explorer");
125
132
 
126
133
  var _useState82 = _slicedToArray(_useState8, 2);
@@ -197,11 +204,15 @@ var MbeditorApp = function MbeditorApp() {
197
204
  var _useState182 = _slicedToArray(_useState18, 2);
198
205
  var showGitPanel = _useState182[0];
199
206
  var setShowGitPanel = _useState182[1];
207
+ var showGitPanelRef = useRef(showGitPanel);
208
+ showGitPanelRef.current = showGitPanel;
200
209
 
201
210
  var _useState18g = useState(320);
202
211
  var _useState18g2 = _slicedToArray(_useState18g, 2);
203
212
  var gitPanelWidth = _useState18g2[0];
204
213
  var setGitPanelWidth = _useState18g2[1];
214
+ var gitPanelWidthRef = useRef(gitPanelWidth);
215
+ gitPanelWidthRef.current = gitPanelWidth;
205
216
 
206
217
  var _useState18h = useState(false);
207
218
 
@@ -567,7 +578,7 @@ var MbeditorApp = function MbeditorApp() {
567
578
  if (!body) return;
568
579
 
569
580
  var rect = body.getBoundingClientRect();
570
- var reservedRight = EDITOR_MIN_WIDTH + (showGitPanel ? gitPanelWidth : 0);
581
+ var reservedRight = EDITOR_MIN_WIDTH + (showGitPanelRef.current ? gitPanelWidthRef.current : 0);
571
582
  var maxSidebarWidth = Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, rect.width - reservedRight));
572
583
  var nextWidth = clientX - rect.left;
573
584
  setSidebarWidth(clamp(nextWidth, SIDEBAR_MIN_WIDTH, maxSidebarWidth));
@@ -614,23 +625,41 @@ var MbeditorApp = function MbeditorApp() {
614
625
  };
615
626
  }, []);
616
627
 
617
- // Heartbeat — poll /ping every 5s and reflect connectivity in the status bar
628
+ // Heartbeat — adaptive poll: 30s when connected, 5s when trying to reconnect.
629
+ // Skipped entirely while the tab is hidden (Page Visibility API).
618
630
  useEffect(function () {
619
631
  var wasOnline = true;
620
- var interval = setInterval(function () {
632
+ var timeoutId = null;
633
+
634
+ function schedule() {
635
+ var delay = wasOnline ? 30000 : 5000;
636
+ timeoutId = setTimeout(tick, delay);
637
+ }
638
+
639
+ function tick() {
640
+ if (document.hidden) {
641
+ // Tab is backgrounded — skip this cycle and reschedule at the normal
642
+ // online interval so we resume quickly once the tab becomes visible again.
643
+ schedule();
644
+ return;
645
+ }
621
646
  FileService.ping().then(function () {
622
647
  if (!wasOnline) {
623
648
  wasOnline = true;
624
649
  setServerOnline(true);
625
650
  }
651
+ schedule();
626
652
  }).catch(function () {
627
653
  if (wasOnline) {
628
654
  wasOnline = false;
629
655
  setServerOnline(false);
630
656
  }
657
+ schedule();
631
658
  });
632
- }, 5000);
633
- return function () { clearInterval(interval); };
659
+ }
660
+
661
+ schedule();
662
+ return function () { clearTimeout(timeoutId); };
634
663
  }, []);
635
664
 
636
665
  var handleSelectFile = function handleSelectFile(path, name, line) {
@@ -1045,12 +1074,22 @@ var MbeditorApp = function MbeditorApp() {
1045
1074
 
1046
1075
  var _debouncedSearch = useRef(window._.debounce(function (q) {
1047
1076
  if (!q.trim()) {
1077
+ searchRequestIdRef.current += 1;
1078
+ setSearchLoading(false);
1048
1079
  EditorStore.setState({ searchResults: [] });
1049
1080
  return;
1050
1081
  }
1082
+ var requestId = ++searchRequestIdRef.current;
1083
+ setSearchLoading(true);
1051
1084
  EditorStore.setStatus("Searching project...", "info");
1052
1085
  SearchService.projectSearch(q).then(function (res) {
1053
- EditorStore.setStatus("Found " + res.length + " results", "success");
1086
+ if (searchRequestIdRef.current === requestId) {
1087
+ EditorStore.setStatus("Found " + res.length + " results", "success");
1088
+ }
1089
+ }).finally(function () {
1090
+ if (searchRequestIdRef.current === requestId) {
1091
+ setSearchLoading(false);
1092
+ }
1054
1093
  });
1055
1094
  }, 400)).current;
1056
1095
 
@@ -1060,6 +1099,14 @@ var MbeditorApp = function MbeditorApp() {
1060
1099
  _debouncedSearch(val);
1061
1100
  };
1062
1101
 
1102
+ var clearSearch = function clearSearch() {
1103
+ searchRequestIdRef.current += 1;
1104
+ if (_debouncedSearch.cancel) _debouncedSearch.cancel();
1105
+ setSearchQuery("");
1106
+ setSearchLoading(false);
1107
+ EditorStore.setState({ searchResults: [] });
1108
+ };
1109
+
1063
1110
  var execSearch = function execSearch(e) {
1064
1111
  e.preventDefault();
1065
1112
  _debouncedSearch(searchQuery);
@@ -1743,16 +1790,31 @@ var MbeditorApp = function MbeditorApp() {
1743
1790
  React.createElement(
1744
1791
  "div",
1745
1792
  { className: "search-input-wrap" },
1746
- React.createElement("input", {
1747
- className: "search-input",
1748
- placeholder: "Find in files...",
1749
- value: searchQuery,
1750
- onChange: handleSearchChange
1751
- }),
1793
+ React.createElement(
1794
+ "div",
1795
+ { className: "search-input-shell" },
1796
+ React.createElement("input", {
1797
+ className: "search-input",
1798
+ placeholder: "Find in files...",
1799
+ value: searchQuery,
1800
+ onChange: handleSearchChange
1801
+ }),
1802
+ searchQuery && React.createElement(
1803
+ "button",
1804
+ {
1805
+ type: "button",
1806
+ className: "search-clear-btn",
1807
+ onClick: clearSearch,
1808
+ title: "Clear search",
1809
+ "aria-label": "Clear search"
1810
+ },
1811
+ React.createElement("i", { className: "fas fa-times" })
1812
+ )
1813
+ ),
1752
1814
  React.createElement(
1753
1815
  "button",
1754
- { type: "submit", className: "search-btn" },
1755
- React.createElement("i", { className: "fas fa-search" })
1816
+ { type: "submit", className: "search-btn", disabled: searchLoading, title: searchLoading ? "Searching..." : "Search" },
1817
+ React.createElement("i", { className: searchLoading ? "fas fa-spinner fa-spin" : "fas fa-search" })
1756
1818
  )
1757
1819
  ),
1758
1820
  React.createElement(
@@ -1775,28 +1837,35 @@ var MbeditorApp = function MbeditorApp() {
1775
1837
  "No results"
1776
1838
  ),
1777
1839
  state.searchResults.map(function (res, i) {
1840
+ var fileName = res.file.split('/').pop();
1778
1841
  return React.createElement(
1779
1842
  "div",
1780
1843
  { key: i, className: "search-result-item", onClick: function () {
1781
1844
  return handleSelectFile(res.file, res.file.split('/').pop(), res.line);
1782
1845
  } },
1846
+ React.createElement("i", { className: (window.getFileIcon ? window.getFileIcon(fileName) : 'far fa-file-code') + " search-result-icon" }),
1783
1847
  React.createElement(
1784
1848
  "div",
1785
- { className: "search-result-file" },
1786
- res.file,
1787
- " ",
1849
+ { className: "search-result-body" },
1850
+ React.createElement(
1851
+ "div",
1852
+ { className: "search-result-file" },
1853
+ fileName,
1854
+ React.createElement(
1855
+ "span",
1856
+ { className: "search-result-line-num" },
1857
+ " ",
1858
+ res.file,
1859
+ ":",
1860
+ res.line
1861
+ )
1862
+ ),
1788
1863
  React.createElement(
1789
- "span",
1790
- { className: "search-result-line-num" },
1791
- ":",
1792
- res.line
1864
+ "div",
1865
+ { className: "search-result-text" },
1866
+ res.text
1793
1867
  )
1794
1868
  ),
1795
- React.createElement(
1796
- "div",
1797
- { className: "search-result-text" },
1798
- res.text
1799
- )
1800
1869
  );
1801
1870
  })
1802
1871
  )
@@ -2103,6 +2172,11 @@ var MbeditorApp = function MbeditorApp() {
2103
2172
  "div",
2104
2173
  { className: "statusbar-msg " + state.statusMessage.kind },
2105
2174
  state.statusMessage.text
2175
+ ),
2176
+ React.createElement(
2177
+ "div",
2178
+ { className: "statusbar-version" },
2179
+ "v" + (document.body.dataset.mbeditorVersion || "")
2106
2180
  )
2107
2181
  ),
2108
2182