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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +66 -1
- data/app/assets/javascripts/mbeditor/application.js +0 -1
- data/app/assets/javascripts/mbeditor/collaboration_service.js +22 -2
- data/app/assets/javascripts/mbeditor/components/EditorPanel.js +11 -87
- data/app/assets/javascripts/mbeditor/components/ImportDialog.js +9 -1
- data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +171 -205
- data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +35 -7
- data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +97 -75
- data/app/assets/javascripts/mbeditor/editor_plugins.js +7 -1
- data/app/assets/javascripts/mbeditor/file_service.js +8 -18
- data/app/assets/javascripts/mbeditor/search_service.js +20 -2
- data/app/assets/javascripts/mbeditor/tab_manager.js +7 -4
- data/app/assets/stylesheets/mbeditor/editor.css +138 -67
- data/app/channels/mbeditor/collaboration_channel.rb +8 -2
- data/app/controllers/mbeditor/editors_controller.rb +8 -26
- data/app/services/mbeditor/collaboration_doc_store.rb +42 -3
- data/app/services/mbeditor/duplicate_content_scanner.rb +105 -0
- data/app/services/mbeditor/js_globals_service.rb +12 -1
- data/app/services/mbeditor/rubocop_run_service.rb +17 -5
- data/app/services/mbeditor/schema_service.rb +8 -2
- data/app/services/mbeditor/search_replace_service.rb +11 -2
- data/app/services/mbeditor/test_runner_service.rb +3 -56
- data/lib/mbeditor/configuration.rb +0 -8
- data/lib/mbeditor/route_map.rb +0 -1
- data/lib/mbeditor/version.rb +1 -1
- data/lib/tasks/mbeditor.rake +23 -0
- metadata +4 -3
- 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:
|
|
209
|
-
min-width:
|
|
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:
|
|
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:
|
|
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
|
|
885
|
-
gap:
|
|
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:
|
|
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:
|
|
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:
|
|
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-
|
|
1254
|
-
|
|
1255
|
-
|
|
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:
|
|
1260
|
-
color: var(--ide-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
2546
|
-
|
|
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:
|
|
2563
|
-
font-size:
|
|
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:
|
|
2657
|
+
right: 8px;
|
|
2573
2658
|
top: 50%;
|
|
2574
2659
|
transform: translateY(-50%);
|
|
2575
|
-
width:
|
|
2576
|
-
height:
|
|
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:
|
|
2683
|
+
padding: 8px 18px;
|
|
2599
2684
|
cursor: pointer;
|
|
2600
2685
|
display: flex;
|
|
2601
2686
|
align-items: center;
|
|
2602
|
-
gap:
|
|
2603
|
-
font-size:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
2734
|
+
padding: 24px 18px;
|
|
2650
2735
|
color: var(--ide-text-muted);
|
|
2651
|
-
font-size:
|
|
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
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
.statusbar-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1226
|
-
#
|
|
1227
|
-
#
|
|
1228
|
-
|
|
1229
|
-
|
|
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
|
-
|
|
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
|