mbeditor 0.1.2 → 0.1.4

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: 45f561cf7e36141944025bc54ce679c4492a7c7315902532e350eab44b138361
4
+ data.tar.gz: 7612a0802d9c8e65d0539b621362b0adc3f09866bf131b14b707efafcd35e752
5
5
  SHA512:
6
- metadata.gz: b450317bed54ea23c246042a2a86a1a3468f20de9a3d71b81ca7a7a69a80d01279ad40ca569a755077317113099f4cb931702ad620448a812ffecc4d1b4b1508
7
- data.tar.gz: 3b23f5db3e43051546fd8997b8b5b975403aa7479c68463b7f1ebe1c6cc812f30322b70b2e838da51c012e7cd87604816d886715259af8169428f13f72a62d55
6
+ metadata.gz: c896888e924acc1d11e38a0efcdf6f7caec6013eb02426e4d4a0ee847accb3eb5872e5a40e1d4ca7551892352d9fce72ae4667a9585c8265ec7ffc7927e85341
7
+ data.tar.gz: d8cbae4ecfd6b3ec04fd5fde655a52cd176830ff38d02c89bd76e7b09372be63b0f227261ec3dcfda2cd79edbe9811a0a2bfddff58a51b2c5d1d36ac199fc3c5
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,8 @@
1
+ //= require mbeditor/editor_store
2
+ //= require mbeditor/file_service
3
+ //= require mbeditor/git_service
4
+ //= require mbeditor/search_service
5
+ //= require mbeditor/tab_manager
1
6
  //= require mbeditor/editor_plugins
2
7
  //= require mbeditor/components/CollapsibleSection
3
8
  //= require mbeditor/components/ShortcutHelp
@@ -12,8 +17,3 @@
12
17
  //= require mbeditor/components/QuickOpenDialog
13
18
  //= require mbeditor/components/TabBar
14
19
  //= 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
@@ -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;
@@ -143,6 +156,8 @@ var EditorPanel = function EditorPanel(_ref) {
143
156
  });
144
157
 
145
158
  return function () {
159
+ blameDecorationsRef.current = editor.deltaDecorations(blameDecorationsRef.current, []);
160
+ clearBlameZones(editor);
146
161
  TabManager.saveTabViewState(tab.id, editor.saveViewState());
147
162
  if (window.__mbeditorActiveEditor === editor) {
148
163
  window.__mbeditorActiveEditor = null;
@@ -224,14 +239,20 @@ var EditorPanel = function EditorPanel(_ref) {
224
239
  // Reset blame state when file path changes
225
240
  useEffect(function () {
226
241
  setBlameData(null);
227
- setIsBlameVisible(false);
228
242
  setIsBlameLoading(false);
243
+
244
+ // Clear stale blame render when switching files.
245
+ if (monacoRef.current && monacoRef.current.getModel()) {
246
+ clearBlameZones(monacoRef.current);
247
+ blameDecorationsRef.current = monacoRef.current.deltaDecorations(blameDecorationsRef.current, []);
248
+ }
229
249
  }, [tab.path]);
230
250
 
231
251
  // Handle Blame data fetching
232
252
  useEffect(function () {
233
253
  if (!isBlameVisible) {
234
254
  if (monacoRef.current && monacoRef.current.getModel()) {
255
+ clearBlameZones(monacoRef.current);
235
256
  blameDecorationsRef.current = monacoRef.current.deltaDecorations(blameDecorationsRef.current, []);
236
257
  }
237
258
  return;
@@ -240,7 +261,13 @@ var EditorPanel = function EditorPanel(_ref) {
240
261
  if (!blameData && !isBlameLoading) {
241
262
  setIsBlameLoading(true);
242
263
  GitService.fetchBlame(tab.path).then(function(data) {
243
- setBlameData(data.lines || []);
264
+ var lines = data && Array.isArray(data.lines) ? data.lines : [];
265
+ setBlameData(lines);
266
+ if (lines.length === 0) {
267
+ EditorStore.setStatus('No blame data available for this file', 'warning');
268
+ } else {
269
+ EditorStore.setStatus('Loaded blame for ' + lines.length + ' lines', 'info');
270
+ }
244
271
  setIsBlameLoading(false);
245
272
  }).catch(function(err) {
246
273
  var status = err.response && err.response.status;
@@ -248,38 +275,89 @@ var EditorPanel = function EditorPanel(_ref) {
248
275
  ? "File is not tracked by git"
249
276
  : "Failed to load blame: " + ((err.response && err.response.data && err.response.data.error) || err.message);
250
277
  EditorStore.setStatus(msg, "error");
278
+ setBlameData([]);
251
279
  setIsBlameLoading(false);
252
- setIsBlameVisible(false);
253
280
  });
254
281
  }
255
282
  }, [isBlameVisible, tab.path, blameData, isBlameLoading]);
256
283
 
257
- // Render Blame decorations
284
+ // Render Blame block headers (author + summary) above contiguous commit regions.
258
285
  useEffect(function () {
259
286
  if (!monacoRef.current || !window.monaco || !isBlameVisible || !blameData) return;
287
+
260
288
  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
- }
289
+ var model = editor.getModel();
290
+ var lineCount = model ? model.getLineCount() : 0;
291
+
292
+ try {
293
+ // Clear previous render before rebuilding.
294
+ clearBlameZones(editor);
295
+ blameDecorationsRef.current = editor.deltaDecorations(blameDecorationsRef.current, []);
296
+
297
+ var normalized = blameData.map(function(lineData) {
298
+ var ln = Number(lineData && lineData.line);
299
+ if (!model || !ln || ln < 1 || ln > lineCount) return null;
300
+
301
+ var sha = lineData && lineData.sha || '';
302
+ var author = lineData && lineData.author || 'Unknown';
303
+ var summary = lineData && lineData.summary || 'No commit message';
304
+ var isUncommitted = sha.substring(0, 8) === '00000000';
305
+
306
+ return {
307
+ line: ln,
308
+ sha: sha,
309
+ author: isUncommitted ? 'Not Committed' : author,
310
+ summary: summary,
311
+ isUncommitted: isUncommitted
312
+ };
313
+ }).filter(Boolean);
314
+
315
+ normalized.sort(function(a, b) { return a.line - b.line; });
316
+
317
+ var blocks = [];
318
+ normalized.forEach(function(item) {
319
+ var current = blocks.length > 0 ? blocks[blocks.length - 1] : null;
320
+ if (!current || current.sha !== item.sha || item.line !== current.endLine + 1) {
321
+ blocks.push({
322
+ sha: item.sha,
323
+ author: item.author,
324
+ summary: item.summary,
325
+ isUncommitted: item.isUncommitted,
326
+ startLine: item.line,
327
+ endLine: item.line
328
+ });
329
+ return;
278
330
  }
279
- };
280
- });
281
-
282
- blameDecorationsRef.current = editor.deltaDecorations(blameDecorationsRef.current, newDecorations);
331
+ current.endLine = item.line;
332
+ });
333
+
334
+ var zoneIds = [];
335
+ editor.changeViewZones(function(accessor) {
336
+ blocks.forEach(function(block, idx) {
337
+ var header = document.createElement('div');
338
+ header.className = block.isUncommitted
339
+ ? 'ide-blame-block-header ide-blame-block-header-uncommitted'
340
+ : 'ide-blame-block-header';
341
+ header.textContent = block.author + ' - ' + block.summary;
342
+
343
+ var zoneId = accessor.addZone({
344
+ afterLineNumber: block.startLine > 1 ? block.startLine - 1 : 0,
345
+ heightInLines: 1,
346
+ domNode: header,
347
+ suppressMouseDown: true
348
+ });
349
+ zoneIds.push(zoneId);
350
+ });
351
+ });
352
+ blameZoneIdsRef.current = zoneIds;
353
+ } catch (err) {
354
+ var message = err && err.message ? err.message : 'Unknown decoration error';
355
+ EditorStore.setStatus('Failed to render blame annotations: ' + message, 'error');
356
+ clearBlameZones(editor);
357
+ blameDecorationsRef.current = editor.deltaDecorations(blameDecorationsRef.current, []);
358
+ }
359
+
360
+ // Include tab.content so blame re-renders once async file contents finish loading.
283
361
  }, [blameData, isBlameVisible, tab.id, tab.content]);
284
362
 
285
363
  var sourceTab = tab.isPreview ? findTabByPath(tab.previewFor) : null;
@@ -337,20 +415,24 @@ var EditorPanel = function EditorPanel(_ref) {
337
415
  return React.createElement('div', { className: 'markdown-preview markdown-preview-full', dangerouslySetInnerHTML: { __html: markup } });
338
416
  }
339
417
 
418
+ // Always render the same wrapper structure so the editorRef div is never
419
+ // unmounted when gitAvailable changes (e.g. loaded async after workspace
420
+ // call returns). The toolbar is conditionally included inside the wrapper.
340
421
  return React.createElement(
341
422
  'div',
342
423
  { className: 'ide-editor-wrapper', style: { display: 'flex', flexDirection: 'column', height: '100%' } },
343
- React.createElement(
424
+ gitAvailable && React.createElement(
344
425
  'div',
345
426
  { className: 'ide-editor-toolbar', style: { display: 'flex', justifyContent: 'flex-end', padding: '4px 8px', background: '#252526', borderBottom: '1px solid #3c3c3c' } },
346
427
  React.createElement(
347
428
  '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' }
429
+ {
430
+ className: 'ide-icon-btn ' + (isBlameVisible ? 'active' : ''),
431
+ onClick: function() {
432
+ setIsBlameVisible(function(prev) { return !prev; });
433
+ },
434
+ title: 'Toggle Git Blame',
435
+ 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
436
  },
355
437
  React.createElement('i', { className: 'fas fa-shoe-prints', style: { marginRight: '6px' } }),
356
438
  isBlameLoading ? 'Loading...' : 'Blame'
@@ -300,5 +300,21 @@ var FileTree = function FileTree(_ref) {
300
300
  );
301
301
  };
302
302
 
303
+ // Wrap FileTree in React.memo with a custom comparator that only checks
304
+ // the data props that affect what's rendered. Function prop references
305
+ // (event handlers) are re-created on every parent render but do not
306
+ // change the visual output, so we intentionally ignore them here.
307
+ // This prevents O(n) tree traversal on every MbeditorApp re-render
308
+ // caused by unrelated state changes (status messages, git polls, etc.).
309
+ var FileTreeMemo = React.memo(FileTree, function(prev, next) {
310
+ return prev.items === next.items &&
311
+ prev.activePath === next.activePath &&
312
+ prev.selectedPath === next.selectedPath &&
313
+ prev.gitFiles === next.gitFiles &&
314
+ prev.expandedDirs === next.expandedDirs &&
315
+ prev.pendingCreate === next.pendingCreate &&
316
+ prev.pendingRename === next.pendingRename;
317
+ });
318
+
303
319
  // Expose globally for sprockets require
304
- window.FileTree = FileTree;
320
+ window.FileTree = FileTreeMemo;
@@ -197,11 +197,15 @@ var MbeditorApp = function MbeditorApp() {
197
197
  var _useState182 = _slicedToArray(_useState18, 2);
198
198
  var showGitPanel = _useState182[0];
199
199
  var setShowGitPanel = _useState182[1];
200
+ var showGitPanelRef = useRef(showGitPanel);
201
+ showGitPanelRef.current = showGitPanel;
200
202
 
201
203
  var _useState18g = useState(320);
202
204
  var _useState18g2 = _slicedToArray(_useState18g, 2);
203
205
  var gitPanelWidth = _useState18g2[0];
204
206
  var setGitPanelWidth = _useState18g2[1];
207
+ var gitPanelWidthRef = useRef(gitPanelWidth);
208
+ gitPanelWidthRef.current = gitPanelWidth;
205
209
 
206
210
  var _useState18h = useState(false);
207
211
 
@@ -567,7 +571,7 @@ var MbeditorApp = function MbeditorApp() {
567
571
  if (!body) return;
568
572
 
569
573
  var rect = body.getBoundingClientRect();
570
- var reservedRight = EDITOR_MIN_WIDTH + (showGitPanel ? gitPanelWidth : 0);
574
+ var reservedRight = EDITOR_MIN_WIDTH + (showGitPanelRef.current ? gitPanelWidthRef.current : 0);
571
575
  var maxSidebarWidth = Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, rect.width - reservedRight));
572
576
  var nextWidth = clientX - rect.left;
573
577
  setSidebarWidth(clamp(nextWidth, SIDEBAR_MIN_WIDTH, maxSidebarWidth));
@@ -614,23 +618,41 @@ var MbeditorApp = function MbeditorApp() {
614
618
  };
615
619
  }, []);
616
620
 
617
- // Heartbeat — poll /ping every 5s and reflect connectivity in the status bar
621
+ // Heartbeat — adaptive poll: 30s when connected, 5s when trying to reconnect.
622
+ // Skipped entirely while the tab is hidden (Page Visibility API).
618
623
  useEffect(function () {
619
624
  var wasOnline = true;
620
- var interval = setInterval(function () {
625
+ var timeoutId = null;
626
+
627
+ function schedule() {
628
+ var delay = wasOnline ? 30000 : 5000;
629
+ timeoutId = setTimeout(tick, delay);
630
+ }
631
+
632
+ function tick() {
633
+ if (document.hidden) {
634
+ // Tab is backgrounded — skip this cycle and reschedule at the normal
635
+ // online interval so we resume quickly once the tab becomes visible again.
636
+ schedule();
637
+ return;
638
+ }
621
639
  FileService.ping().then(function () {
622
640
  if (!wasOnline) {
623
641
  wasOnline = true;
624
642
  setServerOnline(true);
625
643
  }
644
+ schedule();
626
645
  }).catch(function () {
627
646
  if (wasOnline) {
628
647
  wasOnline = false;
629
648
  setServerOnline(false);
630
649
  }
650
+ schedule();
631
651
  });
632
- }, 5000);
633
- return function () { clearInterval(interval); };
652
+ }
653
+
654
+ schedule();
655
+ return function () { clearTimeout(timeoutId); };
634
656
  }, []);
635
657
 
636
658
  var handleSelectFile = function handleSelectFile(path, name, line) {
@@ -2103,6 +2125,11 @@ var MbeditorApp = function MbeditorApp() {
2103
2125
  "div",
2104
2126
  { className: "statusbar-msg " + state.statusMessage.kind },
2105
2127
  state.statusMessage.text
2128
+ ),
2129
+ React.createElement(
2130
+ "div",
2131
+ { className: "statusbar-version" },
2132
+ "v" + (document.body.dataset.mbeditorVersion || "")
2106
2133
  )
2107
2134
  ),
2108
2135
 
@@ -36,6 +36,21 @@ var EditorStore = (function () {
36
36
  emit();
37
37
  }
38
38
 
39
+ // Subscribe to changes in a specific subset of state keys.
40
+ // The listener is only called when at least one of the watched keys changes
41
+ // by reference (===), preventing unnecessary re-renders for unrelated updates.
42
+ function subscribeToSlice(keys, fn) {
43
+ var prev = {};
44
+ keys.forEach(function(k) { prev[k] = _state[k]; });
45
+ return subscribe(function(newState) {
46
+ var changed = keys.some(function(k) { return newState[k] !== prev[k]; });
47
+ if (changed) {
48
+ keys.forEach(function(k) { prev[k] = newState[k]; });
49
+ fn(newState);
50
+ }
51
+ });
52
+ }
53
+
39
54
  function setStatus(text, kind) {
40
55
  kind = kind || "info";
41
56
  setState({ statusMessage: { text: text, kind: kind } });
@@ -49,5 +64,5 @@ var EditorStore = (function () {
49
64
  }
50
65
  }
51
66
 
52
- return { getState: getState, subscribe: subscribe, setState: setState, setStatus: setStatus };
67
+ return { getState: getState, subscribe: subscribe, subscribeToSlice: subscribeToSlice, setState: setState, setStatus: setStatus };
53
68
  })();
@@ -9,22 +9,28 @@ var SearchService = (function () {
9
9
  });
10
10
 
11
11
  function buildIndex(treeData) {
12
- var docs = [];
13
- var idCounter = 1;
12
+ // Capture the tree data immediately so a subsequent refresh doesn't
13
+ // clobber us before the idle callback fires.
14
+ var snapshot = treeData;
15
+ var schedule = window.requestIdleCallback || function(cb) { setTimeout(cb, 50); };
16
+ schedule(function() {
17
+ var docs = [];
18
+ var idCounter = 1;
14
19
 
15
- function traverse(nodes) {
16
- nodes.forEach(function(n) {
17
- if (n.type === 'file') {
18
- docs.push({ id: idCounter++, path: n.path, name: n.name });
19
- } else if (n.children) {
20
- traverse(n.children);
21
- }
22
- });
23
- }
20
+ function traverse(nodes) {
21
+ nodes.forEach(function(n) {
22
+ if (n.type === 'file') {
23
+ docs.push({ id: idCounter++, path: n.path, name: n.name });
24
+ } else if (n.children) {
25
+ traverse(n.children);
26
+ }
27
+ });
28
+ }
24
29
 
25
- traverse(treeData);
26
- _miniSearch.removeAll();
27
- _miniSearch.addAll(docs);
30
+ traverse(snapshot);
31
+ _miniSearch.removeAll();
32
+ _miniSearch.addAll(docs);
33
+ });
28
34
  }
29
35
 
30
36
  function searchFiles(query) {
@@ -461,6 +461,27 @@
461
461
  user-select: none;
462
462
  opacity: 0.7;
463
463
  }
464
+ .ide-blame-widget {
465
+ pointer-events: none;
466
+ white-space: pre;
467
+ margin-left: 10px;
468
+ }
469
+ .ide-blame-block-header {
470
+ font-size: 11px;
471
+ font-style: italic;
472
+ font-family: var(--font-mono);
473
+ color: #9aa0a6;
474
+ opacity: 0.85;
475
+ white-space: nowrap;
476
+ overflow: hidden;
477
+ text-overflow: ellipsis;
478
+ border-top: 1px dashed rgba(255, 255, 255, 0.08);
479
+ padding: 2px 10px;
480
+ pointer-events: none;
481
+ }
482
+ .ide-blame-block-header-uncommitted {
483
+ color: #4CAF50;
484
+ }
464
485
 
465
486
  /* Code Review Panel & Redmine */
466
487
  .ide-code-review {
@@ -641,6 +641,13 @@ html, body, #mbeditor-root {
641
641
  .statusbar-msg.error { color: #f48771; }
642
642
  .statusbar-msg.success { color: #89d185; }
643
643
 
644
+ .statusbar-version {
645
+ color: rgba(255,255,255,0.45);
646
+ font-size: 10px;
647
+ white-space: nowrap;
648
+ padding-left: 8px;
649
+ }
650
+
644
651
  /* ── Search Panel ─────────────────────────────────────────── */
645
652
  .search-panel {
646
653
  padding: 8px;
@@ -1,3 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "pathname"
5
+
1
6
  module Mbeditor
2
7
  class ApplicationController < ActionController::Base
3
8
  private
@@ -6,5 +11,49 @@ module Mbeditor
6
11
  allowed = Array(Mbeditor.configuration.allowed_environments).map(&:to_sym)
7
12
  render plain: 'Not found', status: :not_found unless allowed.include?(Rails.env.to_sym)
8
13
  end
14
+
15
+ def workspace_root
16
+ configured_root = Mbeditor.configuration.workspace_root
17
+ if configured_root.present?
18
+ Pathname.new(configured_root.to_s)
19
+ else
20
+ self.class.instance_variable_get(:@workspace_root_cache) ||
21
+ self.class.instance_variable_set(:@workspace_root_cache, begin
22
+ rails_root = Rails.root.to_s
23
+ out, status = Open3.capture2("git", "-C", rails_root, "rev-parse", "--show-toplevel")
24
+ Pathname.new(status.success? && out.strip.present? ? out.strip : rails_root)
25
+ rescue StandardError
26
+ Rails.root
27
+ end)
28
+ end
29
+ end
30
+
31
+ # Expand path and confirm it's inside workspace_root.
32
+ # For existing paths we also resolve symlinks so that a symlink inside the
33
+ # workspace that points outside cannot be used to escape the sandbox.
34
+ def resolve_path(raw)
35
+ return nil if raw.blank?
36
+
37
+ root = workspace_root.to_s
38
+ full = File.expand_path(raw.to_s, root)
39
+ return nil unless full.start_with?("#{root}/") || full == root
40
+
41
+ if File.exist?(full)
42
+ real_root = File.realpath(root)
43
+ real = File.realpath(full)
44
+ return nil unless real.start_with?("#{real_root}/") || real == real_root
45
+ end
46
+
47
+ full
48
+ rescue Errno::EACCES
49
+ nil
50
+ end
51
+
52
+ def relative_path(full)
53
+ root = workspace_root.to_s
54
+ return "" if full == root
55
+
56
+ full.delete_prefix(root + "/")
57
+ end
9
58
  end
10
59
  end
@@ -12,12 +12,6 @@ module Mbeditor
12
12
  before_action :ensure_allowed_environment!
13
13
  before_action :verify_mbeditor_client, unless: -> { request.get? || request.head? }
14
14
 
15
- ALLOWED_EXTENSIONS = %w[
16
- rb js jsx ts tsx css scss sass html erb haml slim
17
- json yaml yml md txt gemspec gemfile rakefile
18
- gitignore env sh bash zsh conf config toml
19
- ].freeze
20
-
21
15
  IMAGE_EXTENSIONS = %w[png jpg jpeg gif svg ico webp bmp avif].freeze
22
16
  MAX_OPEN_FILE_SIZE_BYTES = 5 * 1024 * 1024
23
17
  RG_AVAILABLE = system("which rg > /dev/null 2>&1")
@@ -29,8 +23,9 @@ module Mbeditor
29
23
  end
30
24
 
31
25
  # GET /mbeditor/ping — heartbeat for the frontend connectivity check
26
+ # Silence the log line so development consoles are not spammed.
32
27
  def ping
33
- render json: { ok: true }
28
+ Rails.logger.silence { render json: { ok: true } }
34
29
  end
35
30
 
36
31
  # GET /mbeditor/workspace — metadata about current workspace root
@@ -171,11 +166,6 @@ module Mbeditor
171
166
  render json: { error: e.message }, status: :unprocessable_entity
172
167
  end
173
168
 
174
- # Backward compatibility for stale route/action caches.
175
- def rename_path
176
- rename
177
- end
178
-
179
169
  # DELETE /mbeditor/delete — remove file or directory
180
170
  def destroy_path
181
171
  path = resolve_path(params[:path])
@@ -194,11 +184,6 @@ module Mbeditor
194
184
  render json: { error: e.message }, status: :unprocessable_entity
195
185
  end
196
186
 
197
- # Backward compatibility for stale route/action caches.
198
- def delete_path
199
- destroy_path
200
- end
201
-
202
187
  # GET /mbeditor/search?q=...
203
188
  def search
204
189
  query = params[:q].to_s.strip
@@ -408,41 +393,6 @@ module Mbeditor
408
393
  render plain: 'Forbidden', status: :forbidden
409
394
  end
410
395
 
411
- def workspace_root
412
- configured_root = Mbeditor.configuration.workspace_root
413
- if configured_root.present?
414
- # Explicitly configured — no subprocess required, just wrap it.
415
- Pathname.new(configured_root.to_s)
416
- else
417
- # Auto-detect from git. Cache the result since the git root cannot change
418
- # within a running process.
419
- self.class.instance_variable_get(:@workspace_root_cache) ||
420
- self.class.instance_variable_set(:@workspace_root_cache, begin
421
- rails_root = Rails.root.to_s
422
- out, status = Open3.capture2("git", "-C", rails_root, "rev-parse", "--show-toplevel")
423
- Pathname.new(status.success? && out.strip.present? ? out.strip : rails_root)
424
- rescue StandardError
425
- Rails.root
426
- end)
427
- end
428
- end
429
-
430
- # Expand path and confirm it's inside workspace_root
431
- def resolve_path(raw)
432
- return nil if raw.blank?
433
-
434
- root = workspace_root.to_s
435
- full = File.expand_path(raw.to_s, root)
436
- full.start_with?(root + "/") || full == root ? full : nil
437
- end
438
-
439
- def relative_path(full)
440
- root = workspace_root.to_s
441
- return "" if full == root
442
-
443
- full.delete_prefix(root + "/")
444
- end
445
-
446
396
  def path_blocked_for_operations?(full_path)
447
397
  rel = relative_path(full_path)
448
398
  return true if rel.blank?
@@ -453,7 +403,7 @@ module Mbeditor
453
403
  def build_tree(dir, max_depth: 10, depth: 0)
454
404
  return [] if depth >= max_depth
455
405
 
456
- entries = Dir.entries(dir).sort.reject { |entry| entry.start_with?(".") || entry == "." || entry == ".." }
406
+ entries = Dir.entries(dir).sort.reject { |entry| entry == "." || entry == ".." }
457
407
  entries.filter_map do |name|
458
408
  full = File.join(dir, name)
459
409
  rel = relative_path(full)
@@ -137,7 +137,7 @@ module Mbeditor
137
137
  end
138
138
 
139
139
  render plain: out, content_type: "text/plain"
140
- rescue StandardError => e
140
+ rescue StandardError
141
141
  render plain: "", content_type: "text/plain"
142
142
  end
143
143
 
@@ -159,11 +159,6 @@ module Mbeditor
159
159
 
160
160
  private
161
161
 
162
- def workspace_root
163
- configured = Mbeditor.configuration.workspace_root
164
- configured.present? ? configured.to_s : Rails.root.to_s
165
- end
166
-
167
162
  # Require & validate a `file` query param, responding 400/403 on bad input.
168
163
  # Returns the relative path string on success, or nil if already responded.
169
164
  def require_file_param
@@ -174,15 +169,13 @@ module Mbeditor
174
169
  return nil
175
170
  end
176
171
 
177
- # Validate that the path stays within the workspace
178
- root = workspace_root
179
- full = File.expand_path(raw, root)
180
- unless full.start_with?(root + "/") || full == root
172
+ full = resolve_path(raw)
173
+ unless full
181
174
  render json: { error: "Forbidden" }, status: :forbidden
182
175
  return nil
183
176
  end
184
177
 
185
- raw
178
+ relative_path(full)
186
179
  end
187
180
  end
188
181
  end
@@ -29,7 +29,7 @@
29
29
  <script>var require = { paths: { vs: '<%= json_escape("#{base_path}/monaco-editor/vs") %>' } };</script>
30
30
  <script src="<%= "#{base_path}/monaco-editor/vs/loader.js" %>"></script>
31
31
  </head>
32
- <body data-rails-root="<%= Rails.root.to_s %>">
32
+ <body data-rails-root="<%= Rails.root.to_s %>" data-mbeditor-version="<%= Mbeditor::VERSION %>">
33
33
  <script>
34
34
  window.MBEDITOR_BASE_PATH = "<%= json_escape(base_path) %>";
35
35
  window.prettierPlugins = {
@@ -1,7 +1,13 @@
1
+ require "mbeditor/rack/silence_ping_request"
2
+
1
3
  module Mbeditor
2
4
  class Engine < ::Rails::Engine
3
5
  isolate_namespace Mbeditor
4
6
 
7
+ initializer "mbeditor.silence_ping_request" do |app|
8
+ app.middleware.insert_before Rails::Rack::Logger, Mbeditor::Rack::SilencePingRequest
9
+ end
10
+
5
11
  initializer "mbeditor.assets.precompile" do |app|
6
12
  app.config.assets.precompile += %w[
7
13
  mbeditor/application.css
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/logger_silence"
4
+
5
+ module Mbeditor
6
+ module Rack
7
+ # Silence periodic editor heartbeats so development logs stay readable.
8
+ class SilencePingRequest
9
+ def initialize(app)
10
+ @app = app
11
+ end
12
+
13
+ def call(env)
14
+ if ping_request?(env)
15
+ Rails.logger.silence { @app.call(env) }
16
+ else
17
+ @app.call(env)
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def ping_request?(env)
24
+ env["REQUEST_METHOD"] == "GET" &&
25
+ env["HTTP_X_MBEDITOR_CLIENT"] == "1" &&
26
+ env["PATH_INFO"].to_s.end_with?("/ping")
27
+ end
28
+ end
29
+ end
30
+ end
@@ -1,3 +1,3 @@
1
1
  module Mbeditor
2
- VERSION = "0.1.2"
2
+ VERSION = "0.1.4"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mbeditor
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Oliver Noonan
@@ -92,6 +92,7 @@ files:
92
92
  - lib/mbeditor.rb
93
93
  - lib/mbeditor/configuration.rb
94
94
  - lib/mbeditor/engine.rb
95
+ - lib/mbeditor/rack/silence_ping_request.rb
95
96
  - lib/mbeditor/version.rb
96
97
  - mbeditor.gemspec
97
98
  - public/min-maps/vs/base/worker/workerMain.js.map