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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +131 -0
  3. data/README.md +153 -3
  4. data/app/assets/javascripts/mbeditor/application.js +5 -0
  5. data/app/assets/javascripts/mbeditor/application_iife_tail.js +6 -0
  6. data/app/assets/javascripts/mbeditor/collaboration_identity.js +234 -0
  7. data/app/assets/javascripts/mbeditor/collaboration_service.js +690 -0
  8. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +120 -19
  9. data/app/assets/javascripts/mbeditor/components/FileTree.js +127 -8
  10. data/app/assets/javascripts/mbeditor/components/GitPanel.js +12 -3
  11. data/app/assets/javascripts/mbeditor/components/ImportConflictModal.js +127 -0
  12. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +911 -72
  13. data/app/assets/javascripts/mbeditor/components/ModelGraph.js +565 -0
  14. data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +130 -10
  15. data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +1 -0
  16. data/app/assets/javascripts/mbeditor/components/TabBar.js +4 -2
  17. data/app/assets/javascripts/mbeditor/editor_plugins.js +517 -111
  18. data/app/assets/javascripts/mbeditor/file_import.js +146 -0
  19. data/app/assets/javascripts/mbeditor/file_service.js +52 -3
  20. data/app/assets/javascripts/mbeditor/tab_manager.js +50 -1
  21. data/app/assets/javascripts/mbeditor/websocket_service.js +89 -0
  22. data/app/assets/stylesheets/mbeditor/editor.css +273 -10
  23. data/app/channels/mbeditor/channel_authentication.rb +94 -0
  24. data/app/channels/mbeditor/collaboration_channel.rb +84 -0
  25. data/app/channels/mbeditor/editor_channel.rb +40 -1
  26. data/app/controllers/mbeditor/application_controller.rb +5 -1
  27. data/app/controllers/mbeditor/editors_controller.rb +465 -19
  28. data/app/controllers/mbeditor/git_controller.rb +9 -2
  29. data/app/services/mbeditor/availability_probe.rb +76 -17
  30. data/app/services/mbeditor/code_search_service.rb +23 -3
  31. data/app/services/mbeditor/collaboration_doc_store.rb +116 -0
  32. data/app/services/mbeditor/file_import_service.rb +103 -0
  33. data/app/services/mbeditor/git_combined_diff_service.rb +36 -5
  34. data/app/services/mbeditor/git_info_service.rb +6 -0
  35. data/app/services/mbeditor/git_service.rb +22 -6
  36. data/app/services/mbeditor/lsp_diagnostics_translator.rb +99 -5
  37. data/app/services/mbeditor/model_graph_service.rb +232 -0
  38. data/app/services/mbeditor/presence_registry.rb +83 -0
  39. data/app/services/mbeditor/ri_definition_service.rb +39 -5
  40. data/app/services/mbeditor/search_replace_service.rb +24 -4
  41. data/app/views/layouts/mbeditor/application.html.erb +2 -0
  42. data/lib/mbeditor/configuration.rb +33 -3
  43. data/lib/mbeditor/engine.rb +34 -0
  44. data/lib/mbeditor/exception_log.rb +84 -0
  45. data/lib/mbeditor/route_map.rb +5 -0
  46. data/lib/mbeditor/ruby_lsp_client.rb +28 -1
  47. data/lib/mbeditor/version.rb +1 -1
  48. data/lib/mbeditor.rb +1 -0
  49. data/vendor/assets/javascripts/yjs-collab.js +12 -0
  50. 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
- position: absolute;
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
- position: absolute;
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
- # no-op
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