mbeditor 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +131 -0
- data/README.md +153 -3
- data/app/assets/javascripts/mbeditor/application.js +5 -0
- data/app/assets/javascripts/mbeditor/application_iife_tail.js +6 -0
- data/app/assets/javascripts/mbeditor/collaboration_identity.js +234 -0
- data/app/assets/javascripts/mbeditor/collaboration_service.js +690 -0
- data/app/assets/javascripts/mbeditor/components/EditorPanel.js +120 -19
- data/app/assets/javascripts/mbeditor/components/FileTree.js +127 -8
- data/app/assets/javascripts/mbeditor/components/GitPanel.js +12 -3
- data/app/assets/javascripts/mbeditor/components/ImportConflictModal.js +127 -0
- data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +911 -72
- data/app/assets/javascripts/mbeditor/components/ModelGraph.js +565 -0
- data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +130 -10
- data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +1 -0
- data/app/assets/javascripts/mbeditor/components/TabBar.js +4 -2
- data/app/assets/javascripts/mbeditor/editor_plugins.js +517 -111
- data/app/assets/javascripts/mbeditor/file_import.js +146 -0
- data/app/assets/javascripts/mbeditor/file_service.js +52 -3
- data/app/assets/javascripts/mbeditor/tab_manager.js +50 -1
- data/app/assets/javascripts/mbeditor/websocket_service.js +89 -0
- data/app/assets/stylesheets/mbeditor/editor.css +273 -10
- data/app/channels/mbeditor/channel_authentication.rb +94 -0
- data/app/channels/mbeditor/collaboration_channel.rb +84 -0
- data/app/channels/mbeditor/editor_channel.rb +40 -1
- data/app/controllers/mbeditor/application_controller.rb +5 -1
- data/app/controllers/mbeditor/editors_controller.rb +465 -19
- data/app/controllers/mbeditor/git_controller.rb +9 -2
- data/app/services/mbeditor/availability_probe.rb +76 -17
- data/app/services/mbeditor/code_search_service.rb +23 -3
- data/app/services/mbeditor/collaboration_doc_store.rb +116 -0
- data/app/services/mbeditor/file_import_service.rb +103 -0
- data/app/services/mbeditor/git_combined_diff_service.rb +36 -5
- data/app/services/mbeditor/git_info_service.rb +6 -0
- data/app/services/mbeditor/git_service.rb +22 -6
- data/app/services/mbeditor/lsp_diagnostics_translator.rb +99 -5
- data/app/services/mbeditor/model_graph_service.rb +232 -0
- data/app/services/mbeditor/presence_registry.rb +83 -0
- data/app/services/mbeditor/ri_definition_service.rb +39 -5
- data/app/services/mbeditor/search_replace_service.rb +24 -4
- data/app/views/layouts/mbeditor/application.html.erb +2 -0
- data/lib/mbeditor/configuration.rb +33 -3
- data/lib/mbeditor/engine.rb +34 -0
- data/lib/mbeditor/exception_log.rb +84 -0
- data/lib/mbeditor/route_map.rb +5 -0
- data/lib/mbeditor/ruby_lsp_client.rb +28 -1
- data/lib/mbeditor/version.rb +1 -1
- data/lib/mbeditor.rb +1 -0
- data/vendor/assets/javascripts/yjs-collab.js +12 -0
- metadata +15 -2
|
@@ -266,6 +266,12 @@ html, body, #mbeditor-root {
|
|
|
266
266
|
.tree-item.selected { box-shadow: inset 0 0 0 1px rgba(120, 180, 224, 0.5); background: rgba(120, 180, 224, 0.08); }
|
|
267
267
|
.tree-item.active.selected { box-shadow: inset 0 0 0 1px rgba(140, 210, 255, 0.8); }
|
|
268
268
|
.tree-item.drag-over { background: rgba(100, 160, 220, 0.25); box-shadow: inset 0 0 0 1px rgba(100, 160, 220, 0.7); }
|
|
269
|
+
/* External file drop (import) — green, to read as "add" rather than "move" */
|
|
270
|
+
.tree-item.drag-over-external { background: rgba(120, 200, 140, 0.22); box-shadow: inset 0 0 0 1px rgba(120, 200, 140, 0.75); }
|
|
271
|
+
/* Goes on the whole scrollable explorer panel, not just .file-tree-root: that
|
|
272
|
+
element stops at its last row, so outlining it would mark only part of the
|
|
273
|
+
area that actually accepts the drop. */
|
|
274
|
+
.drag-over-external-root { box-shadow: inset 0 0 0 2px rgba(120, 200, 140, 0.6); }
|
|
269
275
|
.tree-item.modified { color: var(--ide-warning); }
|
|
270
276
|
|
|
271
277
|
/* Inline create row (VS Code-style new file/folder input) */
|
|
@@ -315,6 +321,16 @@ html, body, #mbeditor-root {
|
|
|
315
321
|
.yml-icon { color: #cb171e !important; }
|
|
316
322
|
|
|
317
323
|
/* ── Main Editor Area ────────────────────────────────────── */
|
|
324
|
+
/* Editor column: the split panes on top, any open bottom drawers beneath. */
|
|
325
|
+
.ide-center-column {
|
|
326
|
+
flex: 1;
|
|
327
|
+
display: flex;
|
|
328
|
+
flex-direction: column;
|
|
329
|
+
min-width: 0;
|
|
330
|
+
min-height: 0;
|
|
331
|
+
overflow: hidden;
|
|
332
|
+
}
|
|
333
|
+
|
|
318
334
|
.ide-main {
|
|
319
335
|
flex: 1;
|
|
320
336
|
display: flex;
|
|
@@ -852,6 +868,51 @@ html, body, #mbeditor-root {
|
|
|
852
868
|
|
|
853
869
|
.statusbar-sep { width: 1px; height: 14px; background: color-mix(in srgb, var(--ide-accent-text) 20%, transparent); margin: 0 2px; }
|
|
854
870
|
|
|
871
|
+
/* ── Collaboration presence ────────────────────────────────────────────────
|
|
872
|
+
A participant is in the roster only while their heartbeat keeps arriving, so
|
|
873
|
+
roster membership is liveness — the pulse needs no separate timer, and a peer
|
|
874
|
+
whose connection drops stops pulsing by disappearing. Slow and shallow on
|
|
875
|
+
purpose: this sits in the titlebar for the whole session, so anything faster
|
|
876
|
+
or higher-contrast reads as an alert. */
|
|
877
|
+
@keyframes mbeditor-collab-pulse {
|
|
878
|
+
0%, 100% { opacity: 1; }
|
|
879
|
+
50% { opacity: 0.4; }
|
|
880
|
+
}
|
|
881
|
+
.collab-pulse { animation: mbeditor-collab-pulse 2.6s ease-in-out infinite; }
|
|
882
|
+
@media (prefers-reduced-motion: reduce) {
|
|
883
|
+
.collab-pulse { animation: none; }
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/* Own popover rather than a title attribute: native tooltips wait ~1s before
|
|
887
|
+
appearing, cannot show the colour swatch, and collapse the newlines this
|
|
888
|
+
content needs. Fixed positioning is anchored from the chip's rect at hover
|
|
889
|
+
time — the titlebar never scrolls, so there is nothing to keep in sync. */
|
|
890
|
+
.collab-hovercard {
|
|
891
|
+
position: fixed;
|
|
892
|
+
z-index: 4000;
|
|
893
|
+
min-width: 180px;
|
|
894
|
+
max-width: 320px;
|
|
895
|
+
padding: 8px 10px;
|
|
896
|
+
background: var(--ide-panel-bg);
|
|
897
|
+
border: 1px solid var(--ide-border);
|
|
898
|
+
border-radius: 6px;
|
|
899
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
|
900
|
+
font-size: 11px;
|
|
901
|
+
line-height: 1.5;
|
|
902
|
+
color: var(--ide-text);
|
|
903
|
+
pointer-events: none;
|
|
904
|
+
}
|
|
905
|
+
.collab-hovercard-name {
|
|
906
|
+
display: flex;
|
|
907
|
+
align-items: center;
|
|
908
|
+
gap: 6px;
|
|
909
|
+
font-weight: 500;
|
|
910
|
+
margin-bottom: 4px;
|
|
911
|
+
}
|
|
912
|
+
.collab-hovercard-swatch { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
|
913
|
+
.collab-hovercard-row { color: var(--ide-text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
914
|
+
.collab-hovercard-hint { margin-top: 5px; padding-top: 5px; border-top: 1px solid var(--ide-border); color: var(--ide-text-muted); }
|
|
915
|
+
|
|
855
916
|
.statusbar-msg {
|
|
856
917
|
margin-left: auto;
|
|
857
918
|
color: var(--ide-accent-text);
|
|
@@ -1298,6 +1359,49 @@ html, body, #mbeditor-root {
|
|
|
1298
1359
|
padding: 0 0 12px;
|
|
1299
1360
|
flex: 1;
|
|
1300
1361
|
}
|
|
1362
|
+
/* Import conflict modal — reuses the schema-modal shell */
|
|
1363
|
+
.import-conflict-modal { min-width: 380px; }
|
|
1364
|
+
.import-conflict-list {
|
|
1365
|
+
list-style: none;
|
|
1366
|
+
margin: 0;
|
|
1367
|
+
padding: 0;
|
|
1368
|
+
max-height: 240px;
|
|
1369
|
+
overflow-y: auto;
|
|
1370
|
+
font-family: var(--ide-mono-font, monospace);
|
|
1371
|
+
font-size: 12px;
|
|
1372
|
+
}
|
|
1373
|
+
.import-conflict-list li {
|
|
1374
|
+
padding: 3px 0;
|
|
1375
|
+
white-space: nowrap;
|
|
1376
|
+
overflow: hidden;
|
|
1377
|
+
text-overflow: ellipsis;
|
|
1378
|
+
}
|
|
1379
|
+
.import-conflict-errors {
|
|
1380
|
+
margin-top: 12px;
|
|
1381
|
+
padding-top: 12px;
|
|
1382
|
+
border-top: 1px solid var(--ide-border, #3c3c3c);
|
|
1383
|
+
color: var(--ide-error, #f48771);
|
|
1384
|
+
}
|
|
1385
|
+
.import-conflict-errors-title { font-size: 12px; margin-bottom: 4px; }
|
|
1386
|
+
.import-conflict-actions {
|
|
1387
|
+
display: flex;
|
|
1388
|
+
justify-content: flex-end;
|
|
1389
|
+
gap: 8px;
|
|
1390
|
+
padding: 12px 16px;
|
|
1391
|
+
border-top: 1px solid var(--ide-border, #3c3c3c);
|
|
1392
|
+
flex-shrink: 0;
|
|
1393
|
+
}
|
|
1394
|
+
.import-conflict-btn {
|
|
1395
|
+
background: var(--ide-input-bg, #3c3c3c);
|
|
1396
|
+
color: var(--ide-text, #d4d4d4);
|
|
1397
|
+
border: 1px solid var(--ide-border, #3c3c3c);
|
|
1398
|
+
border-radius: 3px;
|
|
1399
|
+
padding: 5px 12px;
|
|
1400
|
+
font-size: 12px;
|
|
1401
|
+
cursor: pointer;
|
|
1402
|
+
}
|
|
1403
|
+
.import-conflict-btn:hover { background: var(--ide-hover-bg, #4a4a4a); }
|
|
1404
|
+
.import-conflict-btn-danger { border-color: var(--ide-error, #f48771); color: var(--ide-error, #f48771); }
|
|
1301
1405
|
.schema-table {
|
|
1302
1406
|
width: 100%;
|
|
1303
1407
|
border-collapse: collapse;
|
|
@@ -2779,17 +2883,16 @@ button:not(.pico-btn) { margin-bottom: 0; }
|
|
|
2779
2883
|
}
|
|
2780
2884
|
|
|
2781
2885
|
/* ── Rails log drawer ─────────────────────────────────────────────────────── */
|
|
2886
|
+
/* Flow siblings of the editor inside .ide-center-column, not overlays: an
|
|
2887
|
+
open drawer shrinks the editor rather than covering the code you are
|
|
2888
|
+
reading. Both can be open at once and simply stack. */
|
|
2782
2889
|
.ide-log-drawer {
|
|
2783
|
-
|
|
2784
|
-
left: 0;
|
|
2785
|
-
right: 0;
|
|
2786
|
-
bottom: 22px; /* sit above the status bar */
|
|
2890
|
+
flex: none;
|
|
2787
2891
|
height: 240px; /* default; overridden by the drag-resized inline height */
|
|
2788
2892
|
display: flex;
|
|
2789
2893
|
flex-direction: column;
|
|
2790
2894
|
background: var(--ide-panel-bg, #1e1e1e);
|
|
2791
2895
|
border-top: 1px solid var(--ide-border, #333);
|
|
2792
|
-
z-index: 40;
|
|
2793
2896
|
}
|
|
2794
2897
|
.ide-log-resize {
|
|
2795
2898
|
flex: 0 0 auto;
|
|
@@ -2860,16 +2963,12 @@ button:not(.pico-btn) { margin-bottom: 0; }
|
|
|
2860
2963
|
Same geometry as the log drawer so the two read as one family; only the
|
|
2861
2964
|
body differs, being a list of clickable diagnostics rather than raw text. */
|
|
2862
2965
|
.ide-problems-drawer {
|
|
2863
|
-
|
|
2864
|
-
left: 0;
|
|
2865
|
-
right: 0;
|
|
2866
|
-
bottom: 22px; /* sit above the status bar */
|
|
2966
|
+
flex: none;
|
|
2867
2967
|
height: 240px; /* default; overridden by the drag-resized inline height */
|
|
2868
2968
|
display: flex;
|
|
2869
2969
|
flex-direction: column;
|
|
2870
2970
|
background: var(--ide-panel-bg, #1e1e1e);
|
|
2871
2971
|
border-top: 1px solid var(--ide-border, #333);
|
|
2872
|
-
z-index: 41; /* above the log drawer when both are open */
|
|
2873
2972
|
}
|
|
2874
2973
|
.ide-problems-resize {
|
|
2875
2974
|
flex: 0 0 auto;
|
|
@@ -2948,6 +3047,7 @@ button:not(.pico-btn) { margin-bottom: 0; }
|
|
|
2948
3047
|
.ide-problems-icon { flex-shrink: 0; }
|
|
2949
3048
|
.ide-problems-item-error .ide-problems-icon { color: var(--ide-danger); }
|
|
2950
3049
|
.ide-problems-item-warning .ide-problems-icon { color: var(--ide-warning); }
|
|
3050
|
+
.ide-problems-item-info .ide-problems-icon { color: var(--ide-accent, #3794ff); }
|
|
2951
3051
|
.ide-problems-msg { flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
2952
3052
|
/* The offending source line, dimmed so it reads as context rather than as part
|
|
2953
3053
|
of the message. It is the one part of the row allowed to shrink, so a long
|
|
@@ -2968,6 +3068,20 @@ button:not(.pico-btn) { margin-bottom: 0; }
|
|
|
2968
3068
|
font-size: 10px;
|
|
2969
3069
|
text-transform: lowercase;
|
|
2970
3070
|
}
|
|
3071
|
+
/* The cop name, linking out to its RuboCop docs page. Underlined only on hover
|
|
3072
|
+
so a list of rows doesn't read as a wall of links. */
|
|
3073
|
+
.ide-problems-docs {
|
|
3074
|
+
flex-shrink: 0;
|
|
3075
|
+
color: var(--ide-text-muted, #858585);
|
|
3076
|
+
font-size: 11px;
|
|
3077
|
+
font-family: var(--ide-mono, monospace);
|
|
3078
|
+
text-decoration: none;
|
|
3079
|
+
}
|
|
3080
|
+
.ide-problems-docs:hover,
|
|
3081
|
+
.ide-problems-docs:focus-visible {
|
|
3082
|
+
color: var(--ide-accent, #3794ff);
|
|
3083
|
+
text-decoration: underline;
|
|
3084
|
+
}
|
|
2971
3085
|
.ide-problems-loc {
|
|
2972
3086
|
flex-shrink: 0;
|
|
2973
3087
|
margin-left: auto;
|
|
@@ -2975,6 +3089,155 @@ button:not(.pico-btn) { margin-bottom: 0; }
|
|
|
2975
3089
|
font-size: 11px;
|
|
2976
3090
|
}
|
|
2977
3091
|
|
|
3092
|
+
/* ── Model graph ──────────────────────────────────────────────────────────
|
|
3093
|
+
An SVG ER diagram of the app's ActiveRecord models. The panel is narrow, so
|
|
3094
|
+
the SVG scrolls in both directions rather than being scaled down to
|
|
3095
|
+
illegibility. */
|
|
3096
|
+
.ide-model-graph { display: flex; flex-direction: column; height: 100%; min-height: 0; position: relative; }
|
|
3097
|
+
.ide-model-graph-empty {
|
|
3098
|
+
padding: 16px;
|
|
3099
|
+
color: var(--ide-text-muted, #858585);
|
|
3100
|
+
font-size: 12px;
|
|
3101
|
+
line-height: 1.5;
|
|
3102
|
+
}
|
|
3103
|
+
.ide-model-graph-toolbar {
|
|
3104
|
+
display: flex;
|
|
3105
|
+
align-items: center;
|
|
3106
|
+
gap: 6px;
|
|
3107
|
+
padding: 6px 10px;
|
|
3108
|
+
border-bottom: 1px solid var(--ide-border, #333);
|
|
3109
|
+
color: var(--ide-text-muted, #858585);
|
|
3110
|
+
font-size: 11px;
|
|
3111
|
+
}
|
|
3112
|
+
.ide-model-graph-warn { color: var(--ide-warning); }
|
|
3113
|
+
/* One `margin-left: auto` on the group, not on each button — putting it on
|
|
3114
|
+
every button pushes each one to the far right independently and strands them
|
|
3115
|
+
at opposite ends of the toolbar. */
|
|
3116
|
+
.ide-model-graph-actions {
|
|
3117
|
+
display: flex;
|
|
3118
|
+
align-items: center;
|
|
3119
|
+
gap: 4px;
|
|
3120
|
+
margin-left: auto;
|
|
3121
|
+
}
|
|
3122
|
+
.ide-model-graph-search {
|
|
3123
|
+
width: 150px;
|
|
3124
|
+
padding: 3px 6px;
|
|
3125
|
+
background: var(--ide-input-bg, #3c3c3c);
|
|
3126
|
+
border: 1px solid var(--ide-border, #3c3c3c);
|
|
3127
|
+
border-radius: 3px;
|
|
3128
|
+
color: var(--ide-fg, #d4d4d4);
|
|
3129
|
+
font-size: 11px;
|
|
3130
|
+
}
|
|
3131
|
+
.ide-model-graph-search:focus { outline: none; border-color: var(--ide-accent, #3794ff); }
|
|
3132
|
+
.ide-model-graph-btn {
|
|
3133
|
+
background: none;
|
|
3134
|
+
border: none;
|
|
3135
|
+
color: inherit;
|
|
3136
|
+
cursor: pointer;
|
|
3137
|
+
padding: 2px 6px;
|
|
3138
|
+
}
|
|
3139
|
+
.ide-model-graph-btn:hover { color: var(--ide-fg, #d4d4d4); }
|
|
3140
|
+
/* The SVG fills the pane; panning and zooming happen on an inner <g>, so
|
|
3141
|
+
there is no scroll container to fight with the drag. */
|
|
3142
|
+
.ide-model-graph-svg {
|
|
3143
|
+
display: block;
|
|
3144
|
+
flex: 1;
|
|
3145
|
+
min-height: 0;
|
|
3146
|
+
width: 100%;
|
|
3147
|
+
cursor: grab;
|
|
3148
|
+
user-select: none;
|
|
3149
|
+
}
|
|
3150
|
+
.ide-model-graph-svg.mg-dragging { cursor: grabbing; }
|
|
3151
|
+
.ide-model-graph-hint {
|
|
3152
|
+
margin-left: 12px;
|
|
3153
|
+
opacity: 0.7;
|
|
3154
|
+
font-size: 10px;
|
|
3155
|
+
}
|
|
3156
|
+
|
|
3157
|
+
.mg-node { cursor: pointer; }
|
|
3158
|
+
.mg-node .mg-box {
|
|
3159
|
+
fill: var(--ide-panel-bg, #252526);
|
|
3160
|
+
stroke: var(--ide-border, #3c3c3c);
|
|
3161
|
+
stroke-width: 1;
|
|
3162
|
+
}
|
|
3163
|
+
.mg-box-header { fill: var(--ide-hover-bg, #2d2d2d); stroke: none; }
|
|
3164
|
+
.mg-node:hover .mg-box { stroke: var(--ide-accent, #3794ff); }
|
|
3165
|
+
/* The model the search just centred on — findable once it gets there. */
|
|
3166
|
+
.mg-focused .mg-box { stroke: var(--ide-accent, #3794ff); stroke-width: 2; }
|
|
3167
|
+
.mg-name {
|
|
3168
|
+
fill: var(--ide-fg, #d4d4d4);
|
|
3169
|
+
font-size: 12px;
|
|
3170
|
+
font-weight: 600;
|
|
3171
|
+
font-family: var(--ide-mono, monospace);
|
|
3172
|
+
}
|
|
3173
|
+
.mg-table { fill: var(--ide-text-muted, #858585); font-size: 9px; }
|
|
3174
|
+
.mg-field {
|
|
3175
|
+
fill: var(--ide-fg, #d4d4d4);
|
|
3176
|
+
font-size: 10px;
|
|
3177
|
+
font-family: var(--ide-mono, monospace);
|
|
3178
|
+
}
|
|
3179
|
+
.mg-field-type { fill: var(--ide-text-muted, #858585); font-size: 9px; font-family: var(--ide-mono, monospace); }
|
|
3180
|
+
.mg-field-more { fill: var(--ide-text-muted, #858585); font-size: 9px; font-style: italic; }
|
|
3181
|
+
|
|
3182
|
+
.mg-edge { fill: none; stroke-width: 1.2; pointer-events: none; }
|
|
3183
|
+
/* The hover target. A 1.2px stroke is its own hit area, so without this wider
|
|
3184
|
+
transparent twin the lines are effectively un-hoverable. */
|
|
3185
|
+
.mg-edge-hit {
|
|
3186
|
+
fill: none;
|
|
3187
|
+
stroke: transparent;
|
|
3188
|
+
stroke-width: 14;
|
|
3189
|
+
pointer-events: stroke;
|
|
3190
|
+
cursor: help;
|
|
3191
|
+
}
|
|
3192
|
+
.mg-edge-hovered { stroke-width: 2.5; stroke: var(--ide-accent, #3794ff); }
|
|
3193
|
+
|
|
3194
|
+
/* Relation tooltip. Rendered rather than using SVG <title> so it appears at
|
|
3195
|
+
once and can show cardinality and the through: hop on separate lines. */
|
|
3196
|
+
.mg-tooltip {
|
|
3197
|
+
position: absolute;
|
|
3198
|
+
z-index: 20;
|
|
3199
|
+
pointer-events: none;
|
|
3200
|
+
max-width: 240px;
|
|
3201
|
+
padding: 6px 9px;
|
|
3202
|
+
background: var(--ide-panel-bg, #252526);
|
|
3203
|
+
border: 1px solid var(--ide-accent, #3794ff);
|
|
3204
|
+
border-radius: 4px;
|
|
3205
|
+
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.45);
|
|
3206
|
+
font-size: 11px;
|
|
3207
|
+
line-height: 1.45;
|
|
3208
|
+
}
|
|
3209
|
+
.mg-tooltip-title { color: var(--ide-fg, #d4d4d4); font-weight: 600; }
|
|
3210
|
+
.mg-tooltip-macro { color: var(--ide-accent, #3794ff); font-family: var(--ide-mono, monospace); }
|
|
3211
|
+
.mg-tooltip-meta { color: var(--ide-text-muted, #858585); }
|
|
3212
|
+
.mg-arrow-head { fill: var(--ide-text-muted, #858585); }
|
|
3213
|
+
/* belongs_to is the edge that gives the graph its direction, so it reads
|
|
3214
|
+
strongest; the rest are the same relationship seen from the other side. */
|
|
3215
|
+
.mg-edge-belongs { stroke: var(--ide-accent, #3794ff); }
|
|
3216
|
+
.mg-edge-has-many { stroke: var(--ide-text-muted, #858585); stroke-dasharray: 4 3; }
|
|
3217
|
+
.mg-edge-has-one { stroke: var(--ide-text-muted, #858585); }
|
|
3218
|
+
.mg-edge-habtm { stroke: var(--ide-warning); stroke-dasharray: 2 3; }
|
|
3219
|
+
|
|
3220
|
+
/* A git hint that reports a problem rather than context. */
|
|
3221
|
+
.git-hint-warn { color: var(--ide-warning); }
|
|
3222
|
+
|
|
3223
|
+
/* Host-app exceptions, above the marker list. Each backtrace frame is a button
|
|
3224
|
+
that opens the file — only in-workspace frames ever reach the client. */
|
|
3225
|
+
.ide-problems-exceptions .ide-problems-file-name { color: var(--ide-danger); }
|
|
3226
|
+
.ide-problems-clear { margin-left: auto; }
|
|
3227
|
+
.ide-problems-exception { margin-bottom: 6px; }
|
|
3228
|
+
.ide-problems-exception-head { cursor: default; }
|
|
3229
|
+
.ide-problems-exception-head .ide-problems-msg { white-space: normal; }
|
|
3230
|
+
.ide-problems-frame { padding-left: 28px; }
|
|
3231
|
+
.ide-problems-frame .ide-problems-code { opacity: 1; }
|
|
3232
|
+
|
|
3233
|
+
/* Status-bar ruby-lsp indicator. Healthy is a dim icon you can ignore;
|
|
3234
|
+
degraded/off is amber with a label, because that one you should read. */
|
|
3235
|
+
.statusbar-lsp { display: inline-flex; align-items: center; gap: 4px; }
|
|
3236
|
+
.statusbar-lsp-ok { opacity: 0.45; }
|
|
3237
|
+
.statusbar-lsp-ok:hover { opacity: 1; }
|
|
3238
|
+
.statusbar-lsp-degraded,
|
|
3239
|
+
.statusbar-lsp-off { color: var(--ide-warning); }
|
|
3240
|
+
|
|
2978
3241
|
/* Status-bar problems indicator: bug count then warning count, VS Code style. */
|
|
2979
3242
|
.statusbar-problems { display: inline-flex; align-items: center; gap: 4px; }
|
|
2980
3243
|
.statusbar-problems-error-icon { color: var(--ide-danger); }
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "action_dispatch"
|
|
4
|
+
|
|
5
|
+
module Mbeditor
|
|
6
|
+
# Runs the configured `authenticate_with` hook on the WebSocket handshake (at
|
|
7
|
+
# channel subscribe) and rejects the subscription when it denies. This is the
|
|
8
|
+
# same proc the engine runs as a controller `before_action`; on the cable side
|
|
9
|
+
# there is no controller, so it is evaluated against a probe that mirrors the
|
|
10
|
+
# surface the hook relies on (`session`, `cookies`, `redirect_to`/`render`/
|
|
11
|
+
# `head`). A hook that halts — or raises — denies the socket (fail-closed).
|
|
12
|
+
#
|
|
13
|
+
# The probe reads cookies/session from the upgrade request env when available;
|
|
14
|
+
# because the cable mount can bypass host middleware, hooks that depend on
|
|
15
|
+
# request-scoped state may see less than they do over HTTP. Restricting network
|
|
16
|
+
# exposure (trusted tunnel / LAN) and securing the host's ActionCable connection
|
|
17
|
+
# remain the primary controls — see the README pairing section.
|
|
18
|
+
module ChannelAuthentication
|
|
19
|
+
# True when the connection is allowed (or no hook is configured); otherwise
|
|
20
|
+
# rejects the subscription and returns false.
|
|
21
|
+
def mbeditor_authenticated?
|
|
22
|
+
hook = Mbeditor.configuration.authenticate_with
|
|
23
|
+
return true unless hook
|
|
24
|
+
|
|
25
|
+
probe = AuthProbe.new(mbeditor_connection_env)
|
|
26
|
+
probe.instance_exec(&hook)
|
|
27
|
+
return true unless probe.denied?
|
|
28
|
+
|
|
29
|
+
mbeditor_reject_subscription
|
|
30
|
+
false
|
|
31
|
+
rescue StandardError
|
|
32
|
+
mbeditor_reject_subscription
|
|
33
|
+
false
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def mbeditor_connection_env
|
|
39
|
+
connection.env
|
|
40
|
+
rescue StandardError
|
|
41
|
+
{}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def mbeditor_reject_subscription
|
|
45
|
+
reject if respond_to?(:reject, true)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Minimal controller-like context for evaluating `authenticate_with` off the
|
|
49
|
+
# cable connection. Any render/redirect/head marks the request as denied,
|
|
50
|
+
# mirroring a controller's `performed?` halt semantics.
|
|
51
|
+
class AuthProbe
|
|
52
|
+
def initialize(env)
|
|
53
|
+
@request = ActionDispatch::Request.new(env || {})
|
|
54
|
+
@denied = false
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def session
|
|
58
|
+
@request.session
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def cookies
|
|
62
|
+
@request.cookie_jar
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def request
|
|
66
|
+
@request
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def params
|
|
70
|
+
@request.params
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def redirect_to(*, **)
|
|
74
|
+
@denied = true
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def render(*, **)
|
|
78
|
+
@denied = true
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def head(*, **)
|
|
82
|
+
@denied = true
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def performed?
|
|
86
|
+
@denied
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def denied?
|
|
90
|
+
@denied
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Mbeditor
|
|
6
|
+
# Per-file relay edge for realtime collaborative editing. The channel never
|
|
7
|
+
# interprets the opaque Yjs bytes it carries — it persists them through
|
|
8
|
+
# CollaborationDocStore and relays them to the other subscribers on a stream
|
|
9
|
+
# keyed by a digest of the workspace-relative path. It degrades gracefully
|
|
10
|
+
# when ActionCable is absent, matching the EditorChannel conditional-base
|
|
11
|
+
# pattern.
|
|
12
|
+
class CollaborationChannel < (defined?(ActionCable::Channel::Base) ? ActionCable::Channel::Base : Object)
|
|
13
|
+
include ChannelAuthentication
|
|
14
|
+
|
|
15
|
+
STREAM_PREFIX = "mbeditor_collab"
|
|
16
|
+
|
|
17
|
+
def subscribed
|
|
18
|
+
return unless mbeditor_authenticated?
|
|
19
|
+
|
|
20
|
+
@path = params[:path].to_s
|
|
21
|
+
return reject if @path.empty? && respond_to?(:reject, true)
|
|
22
|
+
|
|
23
|
+
stream_from stream_name if respond_to?(:stream_from)
|
|
24
|
+
transmit_initial_state
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def unsubscribed
|
|
28
|
+
# no-op
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def doc_update(data)
|
|
32
|
+
bytes = data["update"]
|
|
33
|
+
return if bytes.nil?
|
|
34
|
+
|
|
35
|
+
CollaborationDocStore.record_update(room_key, bytes)
|
|
36
|
+
relay("type" => "doc_update", "update" => bytes)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def snapshot(data)
|
|
40
|
+
bytes = data["snapshot"]
|
|
41
|
+
return if bytes.nil?
|
|
42
|
+
|
|
43
|
+
CollaborationDocStore.replace_snapshot(room_key, bytes)
|
|
44
|
+
relay("type" => "snapshot", "snapshot" => bytes)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def awareness(data)
|
|
48
|
+
bytes = data["awareness"]
|
|
49
|
+
return if bytes.nil?
|
|
50
|
+
|
|
51
|
+
relay("type" => "awareness", "awareness" => bytes)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def relay(payload)
|
|
57
|
+
return unless defined?(ActionCable) && ActionCable.respond_to?(:server)
|
|
58
|
+
|
|
59
|
+
ActionCable.server.broadcast(stream_name, payload)
|
|
60
|
+
rescue StandardError
|
|
61
|
+
# A relay failure (cable down / not mounted) must never crash the socket
|
|
62
|
+
# or undo the persistence that already happened.
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def transmit_initial_state
|
|
66
|
+
return unless respond_to?(:transmit, true)
|
|
67
|
+
|
|
68
|
+
state = CollaborationDocStore.state_for(room_key)
|
|
69
|
+
transmit({ "type" => "sync", "snapshot" => state[:snapshot], "deltas" => state[:deltas] })
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# A room is identified by workspace *and* relative path. Keying on the path
|
|
73
|
+
# alone means two workspaces that both contain "README.md" share one buffer,
|
|
74
|
+
# so pointing `workspace_root` at a different project seeds its file with the
|
|
75
|
+
# previous project's content. The NUL separator cannot occur in either part.
|
|
76
|
+
def room_key
|
|
77
|
+
@room_key ||= "#{WorkspaceRootResolver.call}\0#{@path}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def stream_name
|
|
81
|
+
"#{STREAM_PREFIX}:#{Digest::SHA256.hexdigest(room_key)}"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -7,8 +7,16 @@ module Mbeditor
|
|
|
7
7
|
CableBaseClass = defined?(ActionCable::Channel::Base) ? ActionCable::Channel::Base : Object
|
|
8
8
|
|
|
9
9
|
class EditorChannel < CableBaseClass
|
|
10
|
+
include ChannelAuthentication
|
|
11
|
+
|
|
10
12
|
def subscribed
|
|
13
|
+
return unless mbeditor_authenticated?
|
|
14
|
+
|
|
11
15
|
stream_from "mbeditor_editor" if respond_to?(:stream_from)
|
|
16
|
+
# Hand the joiner the current roster straight away. Without it they would
|
|
17
|
+
# only learn about a peer who happens to heartbeat after they connected,
|
|
18
|
+
# and would show an empty participant list until then.
|
|
19
|
+
transmit(presence_payload) if respond_to?(:transmit, true)
|
|
12
20
|
end
|
|
13
21
|
|
|
14
22
|
# Push newly appended log lines to this subscriber ~once a second while the
|
|
@@ -17,7 +25,26 @@ module Mbeditor
|
|
|
17
25
|
periodically :push_log_lines, every: 1 if respond_to?(:periodically)
|
|
18
26
|
|
|
19
27
|
def unsubscribed
|
|
20
|
-
#
|
|
28
|
+
# Action Cable calls this on a clean close *and* on its own connection
|
|
29
|
+
# timeout, which is what makes the registry authoritative: a participant
|
|
30
|
+
# cannot linger past their socket.
|
|
31
|
+
return if @presence_client_id.nil?
|
|
32
|
+
|
|
33
|
+
PresenceRegistry.remove(@presence_client_id)
|
|
34
|
+
relay(presence_payload)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Presence heartbeat: record this participant and broadcast the whole roster.
|
|
38
|
+
#
|
|
39
|
+
# Whole roster, not just the sender: clients replace theirs outright, so they
|
|
40
|
+
# cannot accumulate a participant the server has already dropped. That is the
|
|
41
|
+
# one guarantee per-participant events could not give, and it also removes the
|
|
42
|
+
# client-side merge, the leave handling and the greet-a-new-peer re-announce
|
|
43
|
+
# that used to stand in for it.
|
|
44
|
+
def presence(data)
|
|
45
|
+
@presence_client_id = data["client_id"]
|
|
46
|
+
PresenceRegistry.record(@presence_client_id, data)
|
|
47
|
+
relay(presence_payload)
|
|
21
48
|
end
|
|
22
49
|
|
|
23
50
|
def save_state(data)
|
|
@@ -51,6 +78,18 @@ module Mbeditor
|
|
|
51
78
|
|
|
52
79
|
private
|
|
53
80
|
|
|
81
|
+
def presence_payload
|
|
82
|
+
{ "type" => "presence", "roster" => PresenceRegistry.roster }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def relay(payload)
|
|
86
|
+
return unless defined?(ActionCable) && ActionCable.respond_to?(:server)
|
|
87
|
+
|
|
88
|
+
ActionCable.server.broadcast("mbeditor_editor", payload)
|
|
89
|
+
rescue StandardError
|
|
90
|
+
# Never let a relay failure crash the WebSocket connection.
|
|
91
|
+
end
|
|
92
|
+
|
|
54
93
|
def push_log_lines
|
|
55
94
|
return unless @log_watching
|
|
56
95
|
|
|
@@ -45,7 +45,11 @@ module Mbeditor
|
|
|
45
45
|
return nil unless SafePath.within?(root, full)
|
|
46
46
|
|
|
47
47
|
full
|
|
48
|
-
rescue Errno::EACCES
|
|
48
|
+
rescue Errno::EACCES, ArgumentError
|
|
49
|
+
# ArgumentError: File.expand_path rejects a null byte in the path. Every
|
|
50
|
+
# caller already treats nil as "refuse this path", so a rejected path
|
|
51
|
+
# becomes a clean 4xx instead of escaping as a 500 — and in a batch
|
|
52
|
+
# operation like #import it stays scoped to the one bad entry.
|
|
49
53
|
nil
|
|
50
54
|
end
|
|
51
55
|
|