mbeditor 0.13.0 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +66 -1
  3. data/app/assets/javascripts/mbeditor/application.js +0 -1
  4. data/app/assets/javascripts/mbeditor/collaboration_service.js +22 -2
  5. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +11 -87
  6. data/app/assets/javascripts/mbeditor/components/ImportDialog.js +9 -1
  7. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +171 -205
  8. data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +35 -7
  9. data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +97 -75
  10. data/app/assets/javascripts/mbeditor/editor_plugins.js +7 -1
  11. data/app/assets/javascripts/mbeditor/file_service.js +8 -18
  12. data/app/assets/javascripts/mbeditor/search_service.js +20 -2
  13. data/app/assets/javascripts/mbeditor/tab_manager.js +7 -4
  14. data/app/assets/stylesheets/mbeditor/editor.css +138 -67
  15. data/app/channels/mbeditor/collaboration_channel.rb +8 -2
  16. data/app/controllers/mbeditor/editors_controller.rb +8 -26
  17. data/app/services/mbeditor/collaboration_doc_store.rb +42 -3
  18. data/app/services/mbeditor/duplicate_content_scanner.rb +105 -0
  19. data/app/services/mbeditor/js_globals_service.rb +12 -1
  20. data/app/services/mbeditor/rubocop_run_service.rb +17 -5
  21. data/app/services/mbeditor/schema_service.rb +8 -2
  22. data/app/services/mbeditor/search_replace_service.rb +11 -2
  23. data/app/services/mbeditor/test_runner_service.rb +3 -56
  24. data/lib/mbeditor/configuration.rb +0 -8
  25. data/lib/mbeditor/route_map.rb +0 -1
  26. data/lib/mbeditor/version.rb +1 -1
  27. data/lib/tasks/mbeditor.rake +23 -0
  28. metadata +4 -3
  29. data/app/assets/javascripts/mbeditor/components/TestRunPanel.js +0 -312
@@ -205,8 +205,8 @@ html, body, #mbeditor-root {
205
205
 
206
206
  /* ── Activity Bar (VSCode-style, always visible) ───────── */
207
207
  .ide-activity-bar {
208
- width: 60px;
209
- min-width: 60px;
208
+ width: 40px;
209
+ min-width: 40px;
210
210
  flex-shrink: 0;
211
211
  background: var(--ide-panel-bg);
212
212
  border-right: 1px solid var(--ide-border);
@@ -240,7 +240,7 @@ html, body, #mbeditor-root {
240
240
  than a bare class and was silently winning. */
241
241
  .ide-activity-bar .ide-activity-btn {
242
242
  width: 100%;
243
- height: 60px;
243
+ height: 40px;
244
244
  background: transparent;
245
245
  border: none;
246
246
  /* Always reserve the indicator's width so the glyph does not shift sideways
@@ -252,7 +252,7 @@ html, body, #mbeditor-root {
252
252
  align-items: center;
253
253
  justify-content: center;
254
254
  cursor: pointer;
255
- font-size: 25px;
255
+ font-size: 17px;
256
256
  transition: color 0.12s ease;
257
257
  }
258
258
 
@@ -876,23 +876,33 @@ html, body, #mbeditor-root {
876
876
  }
877
877
 
878
878
  /* ── Status Bar ──────────────────────────────────────────── */
879
+ /* line-height: 1 is what keeps an icon and the number beside it on the same
880
+ * centre line. Pico's inherited 1.5 gave every text span a 16.5px line box
881
+ * inside a 22px bar while the icons' box was 11px, so the two were centred on
882
+ * different half-leadings; with 1 both boxes are the font's own content box
883
+ * and flex centres them together. */
879
884
  .ide-statusbar {
880
885
  display: flex;
881
886
  align-items: center;
882
887
  background: var(--ide-accent);
883
888
  height: 22px;
884
- padding: 0 8px;
885
- gap: 4px;
889
+ padding: 0;
890
+ gap: 0;
886
891
  flex-shrink: 0;
887
892
  font-size: 11px;
893
+ line-height: 1;
888
894
  color: var(--ide-accent-text);
889
895
  }
890
896
 
897
+ /* One cell rhythm for every item, button or not. The branch, the message and
898
+ * the chips each carried their own padding on top of the bar's, so the gaps
899
+ * between them were all different widths. */
900
+ .ide-statusbar > * { padding: 0 8px; }
901
+
891
902
  .statusbar-branch {
892
903
  display: flex;
893
904
  align-items: center;
894
- gap: 6px;
895
- font-size: 13px;
905
+ gap: 4px;
896
906
  font-weight: 600;
897
907
  color: var(--ide-accent-text);
898
908
  }
@@ -926,7 +936,6 @@ html, body, #mbeditor-root {
926
936
  font-size: 12px;
927
937
  font-weight: 600;
928
938
  color: var(--ide-danger);
929
- padding: 0 10px;
930
939
  animation: statusbar-pulse 2s ease-in-out infinite;
931
940
  }
932
941
 
@@ -1026,7 +1035,6 @@ html, body, #mbeditor-root {
1026
1035
  color: color-mix(in srgb, var(--ide-accent-text) 45%, transparent);
1027
1036
  font-size: 10px;
1028
1037
  white-space: nowrap;
1029
- padding-left: 8px;
1030
1038
  cursor: pointer;
1031
1039
  }
1032
1040
  .statusbar-version:hover {
@@ -1039,7 +1047,6 @@ html, body, #mbeditor-root {
1039
1047
  font-weight: 600;
1040
1048
  letter-spacing: 0.04em;
1041
1049
  opacity: 0.85;
1042
- padding: 0 6px;
1043
1050
  }
1044
1051
 
1045
1052
  .statusbar-zen-btn {
@@ -1225,52 +1232,88 @@ html, body, #mbeditor-root {
1225
1232
  to { transform: rotate(360deg); }
1226
1233
  }
1227
1234
 
1235
+ /* Tree rows. Both kinds are pinned to the same 22px because MbeditorApp
1236
+ windows them by index * SEARCH_ROW_HEIGHT — change one and change both. */
1237
+ .search-result-file-row,
1228
1238
  .search-result-item {
1229
- height: 40px;
1230
- padding: 4px 6px;
1239
+ height: 22px;
1231
1240
  cursor: pointer;
1232
- border-radius: 3px;
1233
1241
  transition: background 0.1s;
1234
- border-bottom: 1px solid var(--ide-input-bg);
1235
1242
  display: flex;
1236
1243
  align-items: center;
1237
- gap: 8px;
1244
+ gap: 6px;
1238
1245
  overflow: hidden;
1239
1246
  box-sizing: border-box;
1240
1247
  flex-shrink: 0;
1241
1248
  }
1242
1249
 
1250
+ .search-result-file-row { padding: 0 6px; }
1251
+ /* Indented under its file header, the way the explorer nests children. */
1252
+ .search-result-item { padding: 0 6px 0 24px; }
1253
+
1254
+ .search-result-file-row:hover,
1243
1255
  .search-result-item:hover { background: var(--ide-hover-bg); }
1244
1256
 
1257
+ .search-result-chevron {
1258
+ flex-shrink: 0;
1259
+ width: 12px;
1260
+ font-size: 12px;
1261
+ color: var(--ide-text-muted);
1262
+ }
1263
+
1245
1264
  .search-result-icon {
1246
1265
  flex-shrink: 0;
1247
1266
  width: 16px;
1248
- margin-top: 2px;
1249
1267
  text-align: center;
1250
1268
  color: #78b4e0;
1251
1269
  }
1252
1270
 
1253
- .search-result-body {
1254
- min-width: 0;
1255
- flex: 1;
1271
+ .search-result-file-name {
1272
+ font-size: 12px;
1273
+ color: var(--ide-text);
1274
+ white-space: nowrap;
1275
+ flex-shrink: 0;
1276
+ max-width: 60%;
1277
+ overflow: hidden;
1278
+ text-overflow: ellipsis;
1256
1279
  }
1257
1280
 
1258
- .search-result-file {
1259
- font-size: 11px;
1260
- color: var(--ide-accent-fg);
1261
- font-weight: 500;
1281
+ .search-result-file-dir {
1282
+ font-size: 10px;
1283
+ color: var(--ide-text-muted);
1262
1284
  white-space: nowrap;
1263
1285
  overflow: hidden;
1264
1286
  text-overflow: ellipsis;
1287
+ flex: 1;
1288
+ min-width: 0;
1289
+ }
1290
+
1291
+ /* Match count, VS Code's little pill on the right of the file row. */
1292
+ .search-result-count {
1293
+ flex-shrink: 0;
1294
+ margin-left: auto;
1295
+ min-width: 16px;
1296
+ padding: 0 5px;
1297
+ border-radius: 9px;
1298
+ background: var(--ide-input-bg);
1299
+ color: var(--ide-text-muted);
1300
+ font-size: 10px;
1301
+ line-height: 15px;
1302
+ text-align: center;
1265
1303
  }
1266
1304
 
1267
1305
  .search-result-line-num {
1306
+ flex-shrink: 0;
1268
1307
  font-size: 10px;
1269
1308
  color: var(--ide-text-muted);
1270
- margin-left: 4px;
1309
+ font-family: monospace;
1310
+ min-width: 28px;
1311
+ text-align: right;
1271
1312
  }
1272
1313
 
1273
1314
  .search-result-text {
1315
+ flex: 1;
1316
+ min-width: 0;
1274
1317
  font-size: 11px;
1275
1318
  color: var(--ide-text-muted);
1276
1319
  font-family: monospace;
@@ -1279,6 +1322,47 @@ html, body, #mbeditor-root {
1279
1322
  text-overflow: ellipsis;
1280
1323
  }
1281
1324
 
1325
+ /* The match has to stay on screen at any panel width, so a hit line is three
1326
+ boxes rather than one ellipsised string. Shrink order is what does the work:
1327
+ the text BEFORE the match gives way first, then the text after it, and the
1328
+ match itself only as a last resort — so it can never be the part that scrolls
1329
+ out of view. `direction: rtl` is what moves the leading ellipsis to the start
1330
+ of .search-result-pre; searchMatchParts() prefixes a U+200E so an all-symbol
1331
+ segment can't be reordered by it. */
1332
+ .search-result-text-split {
1333
+ display: flex;
1334
+ align-items: center;
1335
+ text-overflow: clip;
1336
+ }
1337
+
1338
+ .search-result-pre,
1339
+ .search-result-post,
1340
+ .search-result-match {
1341
+ overflow: hidden;
1342
+ text-overflow: ellipsis;
1343
+ white-space: pre;
1344
+ }
1345
+
1346
+ .search-result-pre {
1347
+ flex: 0 100 auto;
1348
+ direction: rtl;
1349
+ text-align: left;
1350
+ }
1351
+
1352
+ .search-result-post { flex: 0 10 auto; }
1353
+
1354
+ /* Never shrinks: flex distributes overflow across every shrinkable box in
1355
+ proportion to its size, so even shrink:1 against a long tail cost the match a
1356
+ character or two of ellipsis. max-width is what still bounds a match wider
1357
+ than the panel — it clips instead of overflowing. */
1358
+ .search-result-match {
1359
+ flex: 0 0 auto;
1360
+ max-width: 100%;
1361
+ background: color-mix(in srgb, var(--ide-accent) 40%, transparent);
1362
+ color: var(--ide-text);
1363
+ border-radius: 2px;
1364
+ }
1365
+
1282
1366
  .search-results-loading-more {
1283
1367
  padding: 8px 10px;
1284
1368
  font-size: 11px;
@@ -2534,7 +2618,7 @@ html, body, #mbeditor-root {
2534
2618
  display: flex;
2535
2619
  align-items: flex-start;
2536
2620
  justify-content: center;
2537
- padding-top: 15vh;
2621
+ padding-top: 12vh;
2538
2622
  z-index: 9999;
2539
2623
  }
2540
2624
 
@@ -2542,8 +2626,9 @@ html, body, #mbeditor-root {
2542
2626
  background: var(--ide-panel-bg);
2543
2627
  border: 1px solid var(--ide-border-input);
2544
2628
  border-radius: 8px;
2545
- width: 500px;
2546
- max-height: 400px;
2629
+ /* vw/vh caps keep the larger dialog inside a phone-sized viewport */
2630
+ width: min(760px, 94vw);
2631
+ max-height: min(620px, 72vh);
2547
2632
  display: flex;
2548
2633
  flex-direction: column;
2549
2634
  box-shadow: 0 16px 48px rgba(0,0,0,0.7);
@@ -2559,8 +2644,8 @@ html, body, #mbeditor-root {
2559
2644
  border: none;
2560
2645
  border-bottom: 1px solid var(--ide-border);
2561
2646
  color: var(--ide-text);
2562
- padding: 12px 34px 12px 16px;
2563
- font-size: 14px;
2647
+ padding: 14px 40px 14px 18px;
2648
+ font-size: 16px;
2564
2649
  outline: none;
2565
2650
  width: 100%;
2566
2651
  box-sizing: border-box;
@@ -2569,11 +2654,11 @@ html, body, #mbeditor-root {
2569
2654
 
2570
2655
  .quick-open-clear-btn {
2571
2656
  position: absolute;
2572
- right: 6px;
2657
+ right: 8px;
2573
2658
  top: 50%;
2574
2659
  transform: translateY(-50%);
2575
- width: 22px;
2576
- height: 22px;
2660
+ width: 26px;
2661
+ height: 26px;
2577
2662
  border: none;
2578
2663
  border-radius: 4px;
2579
2664
  background: transparent;
@@ -2595,18 +2680,18 @@ html, body, #mbeditor-root {
2595
2680
  }
2596
2681
 
2597
2682
  .quick-open-result {
2598
- padding: 6px 16px;
2683
+ padding: 8px 18px;
2599
2684
  cursor: pointer;
2600
2685
  display: flex;
2601
2686
  align-items: center;
2602
- gap: 8px;
2603
- font-size: 12px;
2687
+ gap: 10px;
2688
+ font-size: 14px;
2604
2689
  transition: background 0.1s;
2605
2690
  }
2606
2691
 
2607
2692
  .quick-open-result-icon {
2608
2693
  flex-shrink: 0;
2609
- width: 16px;
2694
+ width: 18px;
2610
2695
  text-align: center;
2611
2696
  color: #78b4e0;
2612
2697
  }
@@ -2620,10 +2705,10 @@ html, body, #mbeditor-root {
2620
2705
  .quick-open-result.selected { background: var(--ide-active-bg); }
2621
2706
 
2622
2707
  .quick-open-result-name { color: var(--ide-text); }
2623
- .quick-open-result-path { color: var(--ide-text-muted); font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
2708
+ .quick-open-result-path { color: var(--ide-text-muted); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
2624
2709
 
2625
2710
  .quick-open-section { margin-bottom: 4px; }
2626
- .quick-open-section-header { padding: 6px 14px 4px; font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ide-text-muted); font-weight: 600; }
2711
+ .quick-open-section-header { padding: 8px 18px 4px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ide-text-muted); font-weight: 600; }
2627
2712
 
2628
2713
  .quick-open-star-btn {
2629
2714
  flex-shrink: 0;
@@ -2632,7 +2717,7 @@ html, body, #mbeditor-root {
2632
2717
  cursor: pointer;
2633
2718
  padding: 2px 6px;
2634
2719
  color: var(--ide-text-muted);
2635
- font-size: 12px;
2720
+ font-size: 14px;
2636
2721
  line-height: 1;
2637
2722
  opacity: 0;
2638
2723
  transition: opacity 0.1s, color 0.1s;
@@ -2646,9 +2731,9 @@ html, body, #mbeditor-root {
2646
2731
  .quick-open-result-dir .quick-open-result-name { color: #dcb67a; }
2647
2732
 
2648
2733
  .quick-open-empty-hint {
2649
- padding: 20px 16px;
2734
+ padding: 24px 18px;
2650
2735
  color: var(--ide-text-muted);
2651
- font-size: 12px;
2736
+ font-size: 13px;
2652
2737
  text-align: center;
2653
2738
  line-height: 1.6;
2654
2739
  }
@@ -2881,7 +2966,11 @@ textarea {
2881
2966
  margin-bottom: 0;
2882
2967
  }
2883
2968
 
2884
- /* Status bar buttons override (22px tall, full-bleed) */
2969
+ /* Status bar buttons override (22px tall, full-bleed).
2970
+ * line-height must be set here too, not just on .ide-statusbar: Pico gives
2971
+ * <button> its own line-height, so it does not inherit the bar's, and the
2972
+ * count next to an icon ended up in a 16.5px line box beside the icon's 11px
2973
+ * one. */
2885
2974
  .ide-statusbar button,
2886
2975
  .statusbar-btn {
2887
2976
  --pico-form-element-spacing-vertical: 0;
@@ -2889,6 +2978,7 @@ textarea {
2889
2978
  height: 22px;
2890
2979
  padding: 0 8px;
2891
2980
  font-size: 11px;
2981
+ line-height: 1;
2892
2982
  border-radius: 2px;
2893
2983
  border: none;
2894
2984
  background: transparent;
@@ -3251,19 +3341,6 @@ button:not(.pico-btn) { margin-bottom: 0; }
3251
3341
  .ide-problems-actions { display: flex; gap: 2px; }
3252
3342
  .ide-problems-actions .ide-problems-btn { white-space: nowrap; }
3253
3343
  .ide-problems-btn.is-on { color: var(--ide-accent, #0a84ff); }
3254
- .ide-testrun-stat { font-size: 11px; color: var(--ide-text-muted, #858585); }
3255
- .ide-testrun-pass { color: #4ec9b0; }
3256
- .ide-testrun-fail { color: #f14c4c; }
3257
- .ide-testrun-raw {
3258
- margin: 0;
3259
- padding: 8px 14px;
3260
- font-family: var(--ide-mono, monospace);
3261
- font-size: 11px;
3262
- line-height: 1.5;
3263
- white-space: pre;
3264
- overflow-x: auto;
3265
- color: var(--ide-fg, #eee);
3266
- }
3267
3344
  .ide-problems-body { flex: 1; overflow: auto; padding: 4px 0; font-size: 12px; }
3268
3345
  .ide-problems-empty {
3269
3346
  padding: 10px 14px;
@@ -3496,18 +3573,12 @@ button:not(.pico-btn) { margin-bottom: 0; }
3496
3573
  .ide-problems-frame { padding-left: 28px; }
3497
3574
  .ide-problems-frame .ide-problems-code { opacity: 1; }
3498
3575
 
3499
- /* Status-bar ruby-lsp indicator. Healthy is a dim icon you can ignore;
3500
- degraded/off is amber with a label, because that one you should read. */
3501
- .statusbar-lsp { display: inline-flex; align-items: center; gap: 4px; }
3502
- .statusbar-lsp-ok { opacity: 0.45; }
3503
- .statusbar-lsp-ok:hover { opacity: 1; }
3504
- .statusbar-lsp-degraded,
3505
- .statusbar-lsp-off { color: var(--ide-warning); }
3506
-
3507
- /* Status-bar problems indicator: bug count then warning count, VS Code style. */
3508
- .statusbar-problems { display: inline-flex; align-items: center; gap: 4px; }
3576
+ /* Status-bar problems indicator: bug count then warning count, VS Code style.
3577
+ The gap is the icon-to-its-own-number spacing; the warning icon opens the
3578
+ second pair, so it gets the wider separator. */
3579
+ .statusbar-problems { gap: 4px; }
3509
3580
  .statusbar-problems-error-icon { color: var(--ide-danger); }
3510
- .statusbar-problems-warning-icon { color: var(--ide-warning); }
3581
+ .statusbar-problems-warning-icon { color: var(--ide-warning); margin-left: 8px; }
3511
3582
  .statusbar-problems-count { font-variant-numeric: tabular-nums; }
3512
3583
 
3513
3584
  /* Search decorations off across engines — Chrome/Safari draw their own
@@ -21,11 +21,14 @@ module Mbeditor
21
21
  return reject if @path.empty? && respond_to?(:reject, true)
22
22
 
23
23
  stream_from stream_name if respond_to?(:stream_from)
24
+ CollaborationDocStore.join(room_key)
25
+ @joined = true
24
26
  transmit_initial_state
25
27
  end
26
28
 
27
29
  def unsubscribed
28
- # no-op
30
+ CollaborationDocStore.leave(room_key) if @joined
31
+ @joined = false
29
32
  end
30
33
 
31
34
  def doc_update(data)
@@ -66,7 +69,10 @@ module Mbeditor
66
69
  return unless respond_to?(:transmit, true)
67
70
 
68
71
  state = CollaborationDocStore.state_for(room_key)
69
- transmit({ "type" => "sync", "snapshot" => state[:snapshot], "deltas" => state[:deltas] })
72
+ # Exactly one client per empty room is told to seed it from disk; see
73
+ # CollaborationDocStore.claim_seed. Everyone else waits for that content.
74
+ transmit({ "type" => "sync", "snapshot" => state[:snapshot], "deltas" => state[:deltas],
75
+ "seed" => CollaborationDocStore.claim_seed(room_key) })
70
76
  end
71
77
 
72
78
  # A room is identified by workspace *and* relative path. Keying on the path
@@ -96,12 +96,11 @@ module Mbeditor
96
96
  blameAvailable: AvailabilityProbe.git(workspace_root),
97
97
  redmineEnabled: Mbeditor.configuration.redmine_enabled == true,
98
98
  testAvailable: test_available?,
99
- # The client derives its request timeouts from these. Two independently
99
+ # The client derives its request timeout from this. Two independently
100
100
  # hard-coded numbers is how you get the browser aborting a run the
101
101
  # server is still happily executing, reported as a generic network
102
102
  # error instead of the server's own message.
103
103
  testTimeout: (Mbeditor.configuration.test_timeout || 180).to_i,
104
- testAllTimeout: (Mbeditor.configuration.test_all_timeout || 1800).to_i,
105
104
  actionCableEnabled: action_cable_enabled?,
106
105
  jsSyntaxCheckAvailable: JsSyntaxCheckService.available?,
107
106
  rubyLspAvailable: AvailabilityProbe.ruby_lsp(workspace_root),
@@ -1122,25 +1121,6 @@ module Mbeditor
1122
1121
  render json: { ok: false, error: e.message, files: [] }, status: :unprocessable_content
1123
1122
  end
1124
1123
 
1125
- # POST /mbeditor/test_all — run the whole suite
1126
- #
1127
- # No path: the framework's own default target applies. Deliberately a plain
1128
- # blocking request like /test, just with the suite ceiling — streaming would
1129
- # need a channel, a buffer and a cancel protocol for a button most people
1130
- # press a handful of times a day.
1131
- def run_all_tests
1132
- config = Mbeditor.configuration
1133
- result = TestRunnerService.run_all(
1134
- workspace_root.to_s,
1135
- framework: config.test_framework&.to_sym,
1136
- command: config.test_all_command,
1137
- timeout: (config.test_all_timeout || 1800).to_i
1138
- )
1139
- render json: result
1140
- rescue StandardError => e
1141
- render json: { ok: false, error: e.message }, status: :unprocessable_content
1142
- end
1143
-
1144
1124
  # POST /mbeditor/test — run tests for the given file
1145
1125
  def run_test
1146
1126
  path = resolve_path(params[:path])
@@ -1222,11 +1202,13 @@ module Mbeditor
1222
1202
  def model_schema
1223
1203
  model_name = params[:model].to_s.strip
1224
1204
  return render json: { error: "model required" }, status: :bad_request if model_name.blank?
1225
- # SchemaService derives a file path from this name, so it is validated the
1226
- # same way #module_members validates its own: a constant name, nothing that
1227
- # could carry a "..".
1228
- return render json: { error: "Invalid model" }, status: :bad_request \
1229
- unless model_name.match?(/\A[A-Z][A-Za-z0-9_:]*\z/)
1205
+ # SchemaService interpolates the underscored name into app/models/<x>.rb,
1206
+ # and Inflector.underscore leaves "../" intact. Same guard as
1207
+ # module_members, widened to the "::" a namespaced model needs. Spaces
1208
+ # are allowed because SchemaService strips them ("Order Item" OrderItem).
1209
+ unless model_name.delete(" ").match?(RUBY_CONSTANT_PATH)
1210
+ return render json: { error: "Invalid model name" }, status: :bad_request
1211
+ end
1230
1212
 
1231
1213
  schema = SchemaService.new(model_name, workspace_root.to_s).call
1232
1214
  if schema
@@ -33,6 +33,44 @@ module Mbeditor
33
33
  # snapshot instead of syncing from the buffer.
34
34
  MAX_DELTAS = 500
35
35
 
36
+ # Grant the right to seed a room's shared document from disk, to exactly one
37
+ # client. Without this every client that finds the room empty inserts the whole
38
+ # file at offset 0, and Yjs merges those inserts into two concatenated copies —
39
+ # the file appended to itself. That happens whenever two editor tabs attach to
40
+ # a room the server has no state for: a fresh room, or one that was evicted
41
+ # while both were idle. The claim is per room, so the loser waits for the
42
+ # winner's content instead of inventing its own.
43
+ def claim_seed(path, now: monotonic)
44
+ MUTEX.synchronize do
45
+ room = touch(path, now)
46
+ next false if room[:seed_claimed] || room[:snapshot] || !room[:deltas].empty?
47
+
48
+ room[:seed_claimed] = true
49
+ end
50
+ end
51
+
52
+ # Subscriber accounting. A room with a live subscriber must never be evicted:
53
+ # eviction empties the server's copy while clients still hold content, and the
54
+ # next client to attach would be granted a seed it must not perform.
55
+ def join(path, now: monotonic)
56
+ MUTEX.synchronize { touch(path, now)[:subscribers] += 1 }
57
+ nil
58
+ end
59
+
60
+ def leave(path, now: monotonic)
61
+ MUTEX.synchronize do
62
+ room = rooms[path]
63
+ next unless room
64
+
65
+ room[:subscribers] -= 1 if room[:subscribers] > 0
66
+ # The claimer can leave before it ever seeds (a tab closed during the
67
+ # handshake). Releasing the claim on an empty, empty-handed room keeps the
68
+ # next opener from deferring to content that will never arrive.
69
+ room[:seed_claimed] = false if room[:subscribers].zero? && room[:snapshot].nil? && room[:deltas].empty?
70
+ end
71
+ nil
72
+ end
73
+
36
74
  def record_update(path, bytes, now: monotonic)
37
75
  MUTEX.synchronize do
38
76
  room = touch(path, now)
@@ -85,7 +123,7 @@ module Mbeditor
85
123
 
86
124
  def touch(path, now)
87
125
  new_room = !rooms.key?(path)
88
- room = (rooms[path] ||= { snapshot: nil, deltas: [] })
126
+ room = (rooms[path] ||= { snapshot: nil, deltas: [], subscribers: 0, seed_claimed: false })
89
127
  room[:last_activity] = now
90
128
  maybe_sweep(now)
91
129
  evict_lru if new_room && rooms.size > ROOM_CAP
@@ -105,12 +143,13 @@ module Mbeditor
105
143
  private_class_method :maybe_sweep
106
144
 
107
145
  def evict_idle(now, grace)
108
- rooms.delete_if { |_path, room| (now - room[:last_activity]) > grace }
146
+ rooms.delete_if { |_path, room| room[:subscribers].zero? && (now - room[:last_activity]) > grace }
109
147
  end
110
148
  private_class_method :evict_idle
111
149
 
112
150
  def evict_lru
113
- oldest = rooms.min_by { |_path, room| room[:last_activity] }
151
+ idle = rooms.reject { |_path, room| room[:subscribers].positive? }
152
+ oldest = (idle.empty? ? rooms : idle).min_by { |_path, room| room[:last_activity] }
114
153
  rooms.delete(oldest.first) if oldest
115
154
  end
116
155
  private_class_method :evict_lru
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "find"
4
+
5
+ module Mbeditor
6
+ # Finds files that look like they were written back to disk with their own
7
+ # content appended to themselves.
8
+ #
9
+ # The corruption this detects came from the collaborative buffer: two clients
10
+ # that each found an empty shared document seeded it from disk, and Yjs merged
11
+ # both inserts at offset 0 into two concatenated copies. That bug is fixed
12
+ # (CollaborationDocStore.claim_seed), but files saved while it was live are
13
+ # still on disk, so there has to be a way to find them.
14
+ #
15
+ # Two signals, cheapest first:
16
+ #
17
+ # * :exact — the file is byte-for-byte X + X. What a duplicated save
18
+ # produces before anyone edits it again.
19
+ # * :repeated_block — the first ANCHOR_LINES lines of the file occur again
20
+ # later, verbatim. Survives edits made after the
21
+ # corruption, which is the common case for a file noticed
22
+ # days later.
23
+ #
24
+ # Report-only. Nothing here writes.
25
+ class DuplicateContentScanner
26
+ ANCHOR_LINES = 10
27
+ MIN_LINES = 20 # below this, a repeat is unremarkable
28
+ MAX_FILE_SIZE = 5 * 1024 * 1024
29
+
30
+ Finding = Struct.new(:path, :reason, :line, :lines, keyword_init: true)
31
+
32
+ def self.check(content)
33
+ return nil if content.nil? || content.empty?
34
+
35
+ lines = content.lines
36
+ return nil if lines.size < MIN_LINES
37
+
38
+ half = content.bytesize / 2
39
+ if content.bytesize.even? && content.byteslice(0, half) == content.byteslice(half, half)
40
+ return { reason: :exact, line: (lines.size / 2) + 1 }
41
+ end
42
+
43
+ at = repeated_anchor_line(lines)
44
+ at ? { reason: :repeated_block, line: at } : nil
45
+ end
46
+
47
+ # Line number (1-based) of a later verbatim recurrence of the file's opening
48
+ # block, or nil. The anchor starts at the first non-blank line so a leading
49
+ # blank does not shift it out of alignment with its copy.
50
+ def self.repeated_anchor_line(lines)
51
+ start = lines.index { |l| !l.strip.empty? }
52
+ return nil if start.nil? || (start + ANCHOR_LINES) > lines.size
53
+
54
+ anchor = lines[start, ANCHOR_LINES]
55
+ # A block of identical lines (padding, a long table) repeats for boring
56
+ # reasons; require the anchor itself to carry some variety.
57
+ return nil if anchor.uniq.size < 3
58
+
59
+ idx = ((start + 1)..(lines.size - ANCHOR_LINES)).find { |i| lines[i, ANCHOR_LINES] == anchor }
60
+ idx && (idx + 1)
61
+ end
62
+ private_class_method :repeated_anchor_line
63
+
64
+ def initialize(root, excluded: Mbeditor.configuration.excluded_paths)
65
+ @root = File.expand_path(root.to_s)
66
+ @matcher = ExclusionMatcher.new(excluded, root: @root)
67
+ end
68
+
69
+ def call
70
+ findings = []
71
+ Find.find(@root) do |path|
72
+ rel = path == @root ? "" : path.delete_prefix("#{@root}/")
73
+ if File.directory?(path)
74
+ Find.prune if !rel.empty? && @matcher.excluded?(rel)
75
+ next
76
+ end
77
+ next if @matcher.excluded?(rel)
78
+ next unless File.file?(path) && File.size(path).between?(1, MAX_FILE_SIZE)
79
+
80
+ content = read_text(path)
81
+ next unless content
82
+
83
+ hit = self.class.check(content)
84
+ next unless hit
85
+
86
+ findings << Finding.new(path: rel, reason: hit[:reason], line: hit[:line], lines: content.lines.size)
87
+ end
88
+ findings.sort_by { |f| [f.reason == :exact ? 0 : 1, f.path] }
89
+ end
90
+
91
+ private
92
+
93
+ # nil for anything that is not readable UTF-8 text — a NUL byte is the same
94
+ # binary test the rest of the editor uses.
95
+ def read_text(path)
96
+ content = File.read(path, encoding: "UTF-8")
97
+ return nil unless content.valid_encoding?
98
+ return nil if content.include?("\0")
99
+
100
+ content
101
+ rescue SystemCallError
102
+ nil
103
+ end
104
+ end
105
+ end