mbeditor 0.9.0 → 0.10.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 +58 -0
- data/README.md +31 -0
- data/app/assets/javascripts/mbeditor/application.js +2 -0
- data/app/assets/javascripts/mbeditor/components/EditorPanel.js +148 -16
- data/app/assets/javascripts/mbeditor/components/LogPanel.js +50 -1
- data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +91 -16
- data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +217 -0
- data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +34 -3
- data/app/assets/javascripts/mbeditor/editor_plugins.js +106 -52
- data/app/assets/javascripts/mbeditor/git_service.js +8 -0
- data/app/assets/javascripts/mbeditor/js_outline.js +110 -0
- data/app/assets/stylesheets/mbeditor/editor.css +176 -4
- data/app/assets/stylesheets/mbeditor/themes.css +35 -0
- data/app/controllers/mbeditor/editors_controller.rb +33 -4
- data/app/controllers/mbeditor/git_controller.rb +11 -0
- data/app/services/mbeditor/git_line_diff_service.rb +99 -0
- data/lib/mbeditor/configuration.rb +2 -1
- data/lib/mbeditor/engine.rb +3 -0
- data/lib/mbeditor/file_watcher.rb +136 -0
- data/lib/mbeditor/route_map.rb +1 -0
- data/lib/mbeditor/version.rb +1 -1
- metadata +6 -2
|
@@ -63,12 +63,26 @@ html, body, #mbeditor-root {
|
|
|
63
63
|
font-weight: 500;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/* Fills the gap between the title and the button cluster. min-width: 0 lets it
|
|
67
|
+
collapse on a narrow window instead of shoving the buttons off the edge. */
|
|
68
|
+
.ide-titlebar-search-slot {
|
|
69
|
+
display: flex;
|
|
70
|
+
flex: 1 1 auto;
|
|
71
|
+
justify-content: center;
|
|
72
|
+
min-width: 0;
|
|
73
|
+
margin: 0 12px;
|
|
74
|
+
}
|
|
75
|
+
|
|
66
76
|
.ide-titlebar-search {
|
|
67
77
|
display: flex;
|
|
68
78
|
align-items: center;
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
79
|
+
justify-content: center;
|
|
80
|
+
/* 75% of the slot, i.e. of the space between the title and the buttons.
|
|
81
|
+
The floor keeps the placeholder readable once 75% of a shrinking gap
|
|
82
|
+
stops being enough, and caps at 100% so it can never outgrow its slot
|
|
83
|
+
and shove the buttons off the edge. */
|
|
84
|
+
width: 75%;
|
|
85
|
+
min-width: min(220px, 100%);
|
|
72
86
|
padding: 3px 12px;
|
|
73
87
|
background: rgba(255, 255, 255, 0.06);
|
|
74
88
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
@@ -78,7 +92,12 @@ html, body, #mbeditor-root {
|
|
|
78
92
|
cursor: pointer;
|
|
79
93
|
}
|
|
80
94
|
.ide-titlebar-search:hover { background: rgba(255, 255, 255, 0.10); }
|
|
81
|
-
.ide-titlebar-search-text {
|
|
95
|
+
.ide-titlebar-search-text {
|
|
96
|
+
opacity: 0.8;
|
|
97
|
+
overflow: hidden;
|
|
98
|
+
text-overflow: ellipsis;
|
|
99
|
+
white-space: nowrap;
|
|
100
|
+
}
|
|
82
101
|
|
|
83
102
|
.ide-body {
|
|
84
103
|
display: flex;
|
|
@@ -2061,6 +2080,19 @@ html, body, #mbeditor-root {
|
|
|
2061
2080
|
.ide-outline-entry-test .ide-outline-entry-icon { color: #8ab4f8; }
|
|
2062
2081
|
.ide-outline-entry-name { overflow: hidden; text-overflow: ellipsis; }
|
|
2063
2082
|
|
|
2083
|
+
/* Line numbers tinted by git status (see the line_diff effect in EditorPanel).
|
|
2084
|
+
Monaco themes style `.monaco-editor .line-numbers`, so these need the same
|
|
2085
|
+
two-class prefix to win on specificity rather than reaching for !important.
|
|
2086
|
+
The colours are theme variables so they track the active editor theme. */
|
|
2087
|
+
.monaco-editor .line-numbers.mbeditor-gitline-added { color: var(--ide-success); font-weight: 600; }
|
|
2088
|
+
.monaco-editor .line-numbers.mbeditor-gitline-modified { color: var(--ide-warning); font-weight: 600; }
|
|
2089
|
+
.monaco-editor .line-numbers.mbeditor-gitline-deleted { color: var(--ide-danger); font-weight: 600; }
|
|
2090
|
+
|
|
2091
|
+
/* A line that is both modified and sits above a deletion should read as
|
|
2092
|
+
deleted — the rarer, more surprising state wins. */
|
|
2093
|
+
.monaco-editor .line-numbers.mbeditor-gitline-deleted.mbeditor-gitline-added,
|
|
2094
|
+
.monaco-editor .line-numbers.mbeditor-gitline-deleted.mbeditor-gitline-modified { color: var(--ide-danger); }
|
|
2095
|
+
|
|
2064
2096
|
.ide-methods-dropdown-message {
|
|
2065
2097
|
padding: 8px 12px;
|
|
2066
2098
|
color: #858585;
|
|
@@ -2808,3 +2840,143 @@ button:not(.pico-btn) { margin-bottom: 0; }
|
|
|
2808
2840
|
word-break: break-word;
|
|
2809
2841
|
}
|
|
2810
2842
|
.ide-log-line { color: var(--ide-fg, #d4d4d4); }
|
|
2843
|
+
|
|
2844
|
+
/* Rails log colouring — see classifyLogLine in LogPanel.js for what matches
|
|
2845
|
+
what, and the --ide-log-* block in themes.css for the palette. Deliberately
|
|
2846
|
+
restrained: the point is to let the eye find request boundaries and failures
|
|
2847
|
+
while scrolling, not to paint every line. */
|
|
2848
|
+
.ide-log-line-request { color: var(--ide-log-request); font-weight: 600; }
|
|
2849
|
+
.ide-log-line-controller { color: var(--ide-log-controller); }
|
|
2850
|
+
.ide-log-line-success { color: var(--ide-log-success); }
|
|
2851
|
+
.ide-log-line-error { color: var(--ide-log-error); font-weight: 600; }
|
|
2852
|
+
.ide-log-line-warn { color: var(--ide-log-warn); }
|
|
2853
|
+
.ide-log-line-sql { color: var(--ide-log-sql); }
|
|
2854
|
+
.ide-log-line-render { color: var(--ide-log-render); }
|
|
2855
|
+
.ide-log-line-mbeditor { color: var(--ide-log-controller); font-style: italic; }
|
|
2856
|
+
.ide-log-line-muted,
|
|
2857
|
+
.ide-log-line-trace { color: var(--ide-log-muted); }
|
|
2858
|
+
|
|
2859
|
+
/* ── Problems drawer ──────────────────────────────────────────────────────
|
|
2860
|
+
Same geometry as the log drawer so the two read as one family; only the
|
|
2861
|
+
body differs, being a list of clickable diagnostics rather than raw text. */
|
|
2862
|
+
.ide-problems-drawer {
|
|
2863
|
+
position: absolute;
|
|
2864
|
+
left: 0;
|
|
2865
|
+
right: 0;
|
|
2866
|
+
bottom: 22px; /* sit above the status bar */
|
|
2867
|
+
height: 240px; /* default; overridden by the drag-resized inline height */
|
|
2868
|
+
display: flex;
|
|
2869
|
+
flex-direction: column;
|
|
2870
|
+
background: var(--ide-panel-bg, #1e1e1e);
|
|
2871
|
+
border-top: 1px solid var(--ide-border, #333);
|
|
2872
|
+
z-index: 41; /* above the log drawer when both are open */
|
|
2873
|
+
}
|
|
2874
|
+
.ide-problems-resize {
|
|
2875
|
+
flex: 0 0 auto;
|
|
2876
|
+
height: 6px;
|
|
2877
|
+
cursor: ns-resize;
|
|
2878
|
+
background: transparent;
|
|
2879
|
+
}
|
|
2880
|
+
.ide-problems-resize:hover { background: var(--ide-accent, #569cd6); }
|
|
2881
|
+
.ide-problems-header {
|
|
2882
|
+
display: flex;
|
|
2883
|
+
align-items: center;
|
|
2884
|
+
gap: 8px;
|
|
2885
|
+
padding: 4px 10px;
|
|
2886
|
+
border-bottom: 1px solid var(--ide-border, #333);
|
|
2887
|
+
font-size: 12px;
|
|
2888
|
+
color: var(--ide-fg-muted, #ccc);
|
|
2889
|
+
}
|
|
2890
|
+
.ide-problems-title { font-weight: 600; }
|
|
2891
|
+
.ide-problems-summary { color: var(--ide-text-muted, #858585); font-size: 11px; }
|
|
2892
|
+
.ide-problems-filter {
|
|
2893
|
+
margin-left: auto;
|
|
2894
|
+
background: var(--ide-input-bg, #2d2d2d);
|
|
2895
|
+
color: var(--ide-fg, #eee);
|
|
2896
|
+
border: 1px solid var(--ide-border, #333);
|
|
2897
|
+
border-radius: 3px;
|
|
2898
|
+
padding: 2px 6px;
|
|
2899
|
+
font-size: 12px;
|
|
2900
|
+
width: 180px;
|
|
2901
|
+
}
|
|
2902
|
+
.ide-problems-btn {
|
|
2903
|
+
background: transparent;
|
|
2904
|
+
border: none;
|
|
2905
|
+
color: var(--ide-fg-muted, #ccc);
|
|
2906
|
+
cursor: pointer;
|
|
2907
|
+
padding: 2px 6px;
|
|
2908
|
+
}
|
|
2909
|
+
.ide-problems-btn:hover { color: var(--ide-fg, #fff); }
|
|
2910
|
+
.ide-problems-body { flex: 1; overflow: auto; padding: 4px 0; font-size: 12px; }
|
|
2911
|
+
.ide-problems-empty {
|
|
2912
|
+
padding: 10px 14px;
|
|
2913
|
+
color: var(--ide-text-muted, #858585);
|
|
2914
|
+
font-style: italic;
|
|
2915
|
+
}
|
|
2916
|
+
.ide-problems-file-name {
|
|
2917
|
+
display: flex;
|
|
2918
|
+
align-items: center;
|
|
2919
|
+
gap: 6px;
|
|
2920
|
+
padding: 5px 12px 3px;
|
|
2921
|
+
color: var(--ide-fg-muted, #ccc);
|
|
2922
|
+
font-weight: 600;
|
|
2923
|
+
}
|
|
2924
|
+
.ide-problems-file-count {
|
|
2925
|
+
background: var(--ide-hover-bg, #2a2a2a);
|
|
2926
|
+
border-radius: 8px;
|
|
2927
|
+
padding: 0 6px;
|
|
2928
|
+
font-size: 10px;
|
|
2929
|
+
font-weight: 600;
|
|
2930
|
+
color: var(--ide-text-muted, #858585);
|
|
2931
|
+
}
|
|
2932
|
+
.ide-problems-item {
|
|
2933
|
+
display: flex;
|
|
2934
|
+
align-items: baseline;
|
|
2935
|
+
gap: 8px;
|
|
2936
|
+
width: 100%;
|
|
2937
|
+
padding: 3px 12px 3px 28px;
|
|
2938
|
+
background: transparent;
|
|
2939
|
+
border: none;
|
|
2940
|
+
color: var(--ide-fg, #d4d4d4);
|
|
2941
|
+
font-size: 12px;
|
|
2942
|
+
font-family: inherit;
|
|
2943
|
+
text-align: left;
|
|
2944
|
+
cursor: pointer;
|
|
2945
|
+
}
|
|
2946
|
+
.ide-problems-item:hover,
|
|
2947
|
+
.ide-problems-item:focus-visible { background: var(--ide-hover-bg, #2a2a2a); }
|
|
2948
|
+
.ide-problems-icon { flex-shrink: 0; }
|
|
2949
|
+
.ide-problems-item-error .ide-problems-icon { color: var(--ide-danger); }
|
|
2950
|
+
.ide-problems-item-warning .ide-problems-icon { color: var(--ide-warning); }
|
|
2951
|
+
.ide-problems-msg { flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
2952
|
+
/* The offending source line, dimmed so it reads as context rather than as part
|
|
2953
|
+
of the message. It is the one part of the row allowed to shrink, so a long
|
|
2954
|
+
line gives way to the message and location instead of pushing them out. */
|
|
2955
|
+
.ide-problems-code {
|
|
2956
|
+
min-width: 0;
|
|
2957
|
+
overflow: hidden;
|
|
2958
|
+
text-overflow: ellipsis;
|
|
2959
|
+
white-space: nowrap;
|
|
2960
|
+
color: var(--ide-text-muted, #858585);
|
|
2961
|
+
opacity: 0.8;
|
|
2962
|
+
font-family: var(--ide-mono, monospace);
|
|
2963
|
+
font-size: 11px;
|
|
2964
|
+
}
|
|
2965
|
+
.ide-problems-source {
|
|
2966
|
+
flex-shrink: 0;
|
|
2967
|
+
color: var(--ide-text-muted, #858585);
|
|
2968
|
+
font-size: 10px;
|
|
2969
|
+
text-transform: lowercase;
|
|
2970
|
+
}
|
|
2971
|
+
.ide-problems-loc {
|
|
2972
|
+
flex-shrink: 0;
|
|
2973
|
+
margin-left: auto;
|
|
2974
|
+
color: var(--ide-text-muted, #858585);
|
|
2975
|
+
font-size: 11px;
|
|
2976
|
+
}
|
|
2977
|
+
|
|
2978
|
+
/* Status-bar problems indicator: bug count then warning count, VS Code style. */
|
|
2979
|
+
.statusbar-problems { display: inline-flex; align-items: center; gap: 4px; }
|
|
2980
|
+
.statusbar-problems-error-icon { color: var(--ide-danger); }
|
|
2981
|
+
.statusbar-problems-warning-icon { color: var(--ide-warning); }
|
|
2982
|
+
.statusbar-problems-count { font-variant-numeric: tabular-nums; }
|
|
@@ -355,6 +355,41 @@
|
|
|
355
355
|
* or exceed that specificity to win the cascade. :root[data-theme] equals
|
|
356
356
|
* (0,2,0) and loads AFTER pico.classless.css, so it wins on cascade order.
|
|
357
357
|
* ─────────────────────────────────────────────────────────────────────────── */
|
|
358
|
+
/* Rails log drawer palette.
|
|
359
|
+
*
|
|
360
|
+
* Deliberately its own set rather than the --ide-* semantic vars: those only
|
|
361
|
+
* carry four hues, and several themes alias them (Dracula's --ide-accent-fg
|
|
362
|
+
* and --ide-success are the same green), which would render "Started GET" and
|
|
363
|
+
* "Completed 200" identically. A log needs its categories to separate from
|
|
364
|
+
* each other more than it needs to match the editor chrome — the same reason
|
|
365
|
+
* terminals keep a fixed palette. Light themes get darkened variants so the
|
|
366
|
+
* text stays legible on white. */
|
|
367
|
+
:root[data-theme],
|
|
368
|
+
:root,
|
|
369
|
+
[data-theme] {
|
|
370
|
+
--ide-log-request: #d7dae0;
|
|
371
|
+
--ide-log-controller: #c792ea;
|
|
372
|
+
--ide-log-sql: #56b6c2;
|
|
373
|
+
--ide-log-render: #82aaff;
|
|
374
|
+
--ide-log-success: #7ec699;
|
|
375
|
+
--ide-log-warn: #e5c07b;
|
|
376
|
+
--ide-log-error: #ef6b73;
|
|
377
|
+
--ide-log-muted: #7f8596;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
:root[data-theme="vs"],
|
|
381
|
+
:root[data-theme="hc-light"],
|
|
382
|
+
:root[data-theme="github-light"] {
|
|
383
|
+
--ide-log-request: #1f2328;
|
|
384
|
+
--ide-log-controller: #6f42c1;
|
|
385
|
+
--ide-log-sql: #0b7285;
|
|
386
|
+
--ide-log-render: #0550ae;
|
|
387
|
+
--ide-log-success: #116329;
|
|
388
|
+
--ide-log-warn: #8a6100;
|
|
389
|
+
--ide-log-error: #b42318;
|
|
390
|
+
--ide-log-muted: #656d76;
|
|
391
|
+
}
|
|
392
|
+
|
|
358
393
|
:root[data-theme],
|
|
359
394
|
:root,
|
|
360
395
|
[data-theme] {
|
|
@@ -1144,10 +1144,39 @@ module Mbeditor
|
|
|
1144
1144
|
contents = result.is_a?(Hash) ? result["contents"] : nil
|
|
1145
1145
|
return nil if contents.nil?
|
|
1146
1146
|
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1147
|
+
markdown =
|
|
1148
|
+
case contents
|
|
1149
|
+
when Hash then contents["value"].to_s
|
|
1150
|
+
when Array then contents.map { |c| c.is_a?(Hash) ? c["value"].to_s : c.to_s }.join("\n\n")
|
|
1151
|
+
else contents.to_s
|
|
1152
|
+
end
|
|
1153
|
+
|
|
1154
|
+
rewrite_lsp_hover_links(markdown)
|
|
1155
|
+
end
|
|
1156
|
+
|
|
1157
|
+
# ruby-lsp renders its "Definitions" line as VS Code file links, e.g.
|
|
1158
|
+
# `[user.rb](file:///abs/path/user.rb#L3,1-9,4)`. Monaco renders those as
|
|
1159
|
+
# links but clicking one does nothing, since nothing can open a file:// URI
|
|
1160
|
+
# here. Point in-workspace links at the `mbeditor.openDefinition` Monaco
|
|
1161
|
+
# command (registered in editor_plugins.js) and demote gem/stdlib links —
|
|
1162
|
+
# which the editor cannot open at all — to plain code spans.
|
|
1163
|
+
LSP_HOVER_FILE_LINK = %r{\[([^\]\n]+)\]\(file://([^)\s#]+)(?:\#L(\d+),\d+(?:-\d+,\d+)?)?\)}
|
|
1164
|
+
|
|
1165
|
+
def rewrite_lsp_hover_links(markdown)
|
|
1166
|
+
prefix = "#{workspace_root}/"
|
|
1167
|
+
markdown.gsub(LSP_HOVER_FILE_LINK) do
|
|
1168
|
+
label, raw_path, line = Regexp.last_match(1), Regexp.last_match(2), Regexp.last_match(3).to_i
|
|
1169
|
+
# Percent-decode by hand: URI's unescape helpers are deprecated on new
|
|
1170
|
+
# Rubies and their replacements are missing on the old ones we support.
|
|
1171
|
+
# (Must come after reading the other captures — gsub resets last_match.)
|
|
1172
|
+
path = raw_path.gsub(/%\h\h/) { |esc| esc[1..].hex.chr }.force_encoding(Encoding::UTF_8)
|
|
1173
|
+
|
|
1174
|
+
if path.start_with?(prefix)
|
|
1175
|
+
args = [path.delete_prefix(prefix), line.positive? ? line : 1]
|
|
1176
|
+
"[#{label}](command:mbeditor.openDefinition?#{ERB::Util.url_encode(args.to_json)})"
|
|
1177
|
+
else
|
|
1178
|
+
"`#{label}`"
|
|
1179
|
+
end
|
|
1151
1180
|
end
|
|
1152
1181
|
end
|
|
1153
1182
|
|
|
@@ -8,6 +8,7 @@ module Mbeditor
|
|
|
8
8
|
# ---------
|
|
9
9
|
# GET /mbeditor/git/diff ?file=<path>[&base=<sha>&head=<sha>]
|
|
10
10
|
# GET /mbeditor/git/blame ?file=<path>
|
|
11
|
+
# GET /mbeditor/git/line_diff ?file=<path>
|
|
11
12
|
# GET /mbeditor/git/file_history ?file=<path>
|
|
12
13
|
# GET /mbeditor/git/commit_graph
|
|
13
14
|
# GET /mbeditor/redmine/issue/:id
|
|
@@ -55,6 +56,16 @@ module Mbeditor
|
|
|
55
56
|
render json: { error: e.message }, status: :unprocessable_content
|
|
56
57
|
end
|
|
57
58
|
|
|
59
|
+
# GET /mbeditor/git/line_diff?file=<path>
|
|
60
|
+
def line_diff
|
|
61
|
+
file = require_file_param
|
|
62
|
+
return unless file
|
|
63
|
+
|
|
64
|
+
render json: GitLineDiffService.new(repo_path: workspace_root, file_path: file).call
|
|
65
|
+
rescue StandardError => e
|
|
66
|
+
render json: { error: e.message }, status: :unprocessable_content
|
|
67
|
+
end
|
|
68
|
+
|
|
58
69
|
# GET /mbeditor/git/file_history?file=<path>
|
|
59
70
|
def file_history
|
|
60
71
|
file = require_file_param
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mbeditor
|
|
4
|
+
# Per-line git status for one file, for colouring the editor's line numbers.
|
|
5
|
+
#
|
|
6
|
+
# Wraps `git diff -U0` (working tree vs HEAD) and turns its hunk headers into
|
|
7
|
+
# line ranges in the *current* file:
|
|
8
|
+
#
|
|
9
|
+
# {
|
|
10
|
+
# "added" => [{ "start" => 12, "end" => 14 }, ...],
|
|
11
|
+
# "modified" => [{ "start" => 30, "end" => 30 }, ...],
|
|
12
|
+
# "deleted" => [{ "start" => 47, "end" => 47 }, ...],
|
|
13
|
+
# "tracked" => true
|
|
14
|
+
# }
|
|
15
|
+
#
|
|
16
|
+
# A deletion has no lines left to mark, so it is reported as the single line
|
|
17
|
+
# the removed content sat after — line 0 when the file's opening lines were
|
|
18
|
+
# the ones deleted, which the frontend renders above the first line.
|
|
19
|
+
#
|
|
20
|
+
# An untracked file has no HEAD side at all, so every line counts as added.
|
|
21
|
+
class GitLineDiffService
|
|
22
|
+
include GitService
|
|
23
|
+
|
|
24
|
+
# @@ -<old_start>[,<old_count>] +<new_start>[,<new_count>] @@
|
|
25
|
+
HUNK_HEADER = /\A@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/
|
|
26
|
+
|
|
27
|
+
attr_reader :repo_path, :file_path
|
|
28
|
+
|
|
29
|
+
def initialize(repo_path:, file_path:)
|
|
30
|
+
@repo_path = repo_path.to_s
|
|
31
|
+
@file_path = file_path.to_s
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def call
|
|
35
|
+
return untracked_result if untracked?
|
|
36
|
+
|
|
37
|
+
output, status = GitService.run_git(
|
|
38
|
+
repo_path, "diff", "--no-color", "--no-ext-diff", "-U0", "HEAD", "--", file_path
|
|
39
|
+
)
|
|
40
|
+
# A non-zero status here means the diff could not be produced at all (no
|
|
41
|
+
# HEAD yet, path outside the repo). Report a clean file rather than
|
|
42
|
+
# raising: line colouring is decoration, never a reason to fail a load.
|
|
43
|
+
return empty_result unless status.success?
|
|
44
|
+
|
|
45
|
+
parse(output)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def empty_result
|
|
51
|
+
{ "added" => [], "modified" => [], "deleted" => [], "tracked" => true }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def untracked_result
|
|
55
|
+
line_count = count_lines
|
|
56
|
+
added = line_count.positive? ? [{ "start" => 1, "end" => line_count }] : []
|
|
57
|
+
{ "added" => added, "modified" => [], "deleted" => [], "tracked" => false }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def untracked?
|
|
61
|
+
output, status = GitService.run_git(repo_path, "ls-files", "--error-unmatch", "--", file_path)
|
|
62
|
+
!(status.success? && output.present?)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def count_lines
|
|
66
|
+
absolute = File.join(repo_path, file_path)
|
|
67
|
+
return 0 unless File.file?(absolute)
|
|
68
|
+
|
|
69
|
+
File.foreach(absolute).count
|
|
70
|
+
rescue SystemCallError
|
|
71
|
+
0
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def parse(output)
|
|
75
|
+
result = empty_result
|
|
76
|
+
|
|
77
|
+
output.each_line do |line|
|
|
78
|
+
match = HUNK_HEADER.match(line)
|
|
79
|
+
next unless match
|
|
80
|
+
|
|
81
|
+
old_count = match[2] ? match[2].to_i : 1
|
|
82
|
+
new_start = match[3].to_i
|
|
83
|
+
new_count = match[4] ? match[4].to_i : 1
|
|
84
|
+
|
|
85
|
+
if new_count.zero?
|
|
86
|
+
# Pure deletion. git reports the line the removed block followed, so
|
|
87
|
+
# new_start is already that line (and 0 when it was the file's head).
|
|
88
|
+
result["deleted"] << { "start" => new_start, "end" => new_start }
|
|
89
|
+
elsif old_count.zero?
|
|
90
|
+
result["added"] << { "start" => new_start, "end" => new_start + new_count - 1 }
|
|
91
|
+
else
|
|
92
|
+
result["modified"] << { "start" => new_start, "end" => new_start + new_count - 1 }
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
result
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -11,7 +11,7 @@ module Mbeditor
|
|
|
11
11
|
:mount_path, :resilient_routing, :js_global_identifiers,
|
|
12
12
|
:js_syntax_check, :babel_standalone_path,
|
|
13
13
|
:ruby_lsp, :ruby_lsp_command, :ruby_lsp_timeout,
|
|
14
|
-
:search_respect_gitignore
|
|
14
|
+
:search_respect_gitignore, :watch_files
|
|
15
15
|
|
|
16
16
|
def initialize
|
|
17
17
|
@allowed_environments = [:development]
|
|
@@ -42,6 +42,7 @@ module Mbeditor
|
|
|
42
42
|
@ruby_lsp_timeout = 3 # seconds per LSP request before falling back to the built-in services
|
|
43
43
|
@mount_path = nil # explicit URL prefix override; nil falls through to detection/"/mbeditor"
|
|
44
44
|
@resilient_routing = true # serve /mbeditor from middleware so the editor survives a broken host routes.rb; false is the escape hatch
|
|
45
|
+
@watch_files = :auto # watch the workspace for changes made outside the editor when the host has the `listen` gem; false disables
|
|
45
46
|
end
|
|
46
47
|
end
|
|
47
48
|
end
|
data/lib/mbeditor/engine.rb
CHANGED
|
@@ -4,6 +4,7 @@ require "mbeditor/rack/silence_ping_request"
|
|
|
4
4
|
require "mbeditor/rack/handle_pending_migrations"
|
|
5
5
|
require "mbeditor/rack/resilient_router"
|
|
6
6
|
require "mbeditor/cable_log_filter"
|
|
7
|
+
require "mbeditor/file_watcher"
|
|
7
8
|
|
|
8
9
|
module Mbeditor
|
|
9
10
|
class Engine < ::Rails::Engine
|
|
@@ -81,6 +82,8 @@ module Mbeditor
|
|
|
81
82
|
raise ArgumentError, "[mbeditor] config.workspace_root is set to '#{cfg.workspace_root}' but that path is not a directory"
|
|
82
83
|
end
|
|
83
84
|
|
|
85
|
+
Mbeditor::FileWatcher.start_if_enabled
|
|
86
|
+
|
|
84
87
|
if cfg.redmine_enabled
|
|
85
88
|
require "uri"
|
|
86
89
|
if cfg.redmine_url.blank?
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mbeditor
|
|
4
|
+
# Watches the workspace for changes made outside the editor — a terminal
|
|
5
|
+
# `git checkout`, a rebase, another editor, a generator — and broadcasts the
|
|
6
|
+
# same `files_changed` payload the mutation endpoints send. Clients refresh
|
|
7
|
+
# the file tree, git line-number tinting and cached globals from it.
|
|
8
|
+
#
|
|
9
|
+
# The `listen` gem is an optional host dependency. Without it the editor
|
|
10
|
+
# behaves exactly as before: only changes made *through* mbeditor announce
|
|
11
|
+
# themselves. Nothing warns loudly about the missing gem — it is opt-in.
|
|
12
|
+
#
|
|
13
|
+
# Only one watcher runs per process. It is deliberately not started in test
|
|
14
|
+
# or in non-server processes (rake, console, the Rails runner), where a
|
|
15
|
+
# background listener thread is pure overhead.
|
|
16
|
+
module FileWatcher
|
|
17
|
+
# Coalesce bursts: a branch switch touches hundreds of files, and each one
|
|
18
|
+
# would otherwise be its own broadcast.
|
|
19
|
+
DEBOUNCE_SECONDS = 0.3
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
def available?
|
|
23
|
+
return @available if defined?(@available)
|
|
24
|
+
|
|
25
|
+
@available = begin
|
|
26
|
+
require "listen"
|
|
27
|
+
true
|
|
28
|
+
rescue LoadError
|
|
29
|
+
false
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def running?
|
|
34
|
+
!@listener.nil?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Boot entry point. Confined to the environments the editor is allowed in
|
|
38
|
+
# and to processes that actually serve requests — a rake task or console
|
|
39
|
+
# has no client to broadcast to, and a listener thread there would only
|
|
40
|
+
# burn file handles. MBEDITOR_FORCE_WATCH overrides the process check for
|
|
41
|
+
# unusual servers and for tests.
|
|
42
|
+
def start_if_enabled
|
|
43
|
+
cfg = Mbeditor.configuration
|
|
44
|
+
return false if cfg.watch_files == false
|
|
45
|
+
return false unless cfg.allowed_environments.map(&:to_s).include?(Rails.env.to_s)
|
|
46
|
+
return false unless serving_requests?
|
|
47
|
+
|
|
48
|
+
start(cfg.workspace_root.presence || Rails.root.to_s)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Returns true when a watcher was started, false for every reason not to
|
|
52
|
+
# (gem absent, already running, no workspace, disabled by config).
|
|
53
|
+
def start(root)
|
|
54
|
+
return false unless available?
|
|
55
|
+
return false if running?
|
|
56
|
+
|
|
57
|
+
root = root.to_s
|
|
58
|
+
return false if root.empty? || !File.directory?(root)
|
|
59
|
+
|
|
60
|
+
ignores = ignore_patterns(root)
|
|
61
|
+
@listener = ::Listen.to(root, ignore: ignores, latency: DEBOUNCE_SECONDS) do |modified, added, removed|
|
|
62
|
+
broadcast(root, modified + added + removed)
|
|
63
|
+
end
|
|
64
|
+
@listener.start
|
|
65
|
+
Rails.logger.info("[mbeditor] watching #{root} for external changes")
|
|
66
|
+
true
|
|
67
|
+
rescue StandardError => e
|
|
68
|
+
# A watcher that cannot start must never take the host app down with it:
|
|
69
|
+
# inotify limits on Linux, permission issues, an unreadable root.
|
|
70
|
+
Rails.logger.warn("[mbeditor] file watcher failed to start: #{e.class}: #{e.message}")
|
|
71
|
+
@listener = nil
|
|
72
|
+
false
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def stop
|
|
76
|
+
@listener&.stop
|
|
77
|
+
rescue StandardError
|
|
78
|
+
nil
|
|
79
|
+
ensure
|
|
80
|
+
@listener = nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def serving_requests?
|
|
86
|
+
return true if ENV["MBEDITOR_FORCE_WATCH"]
|
|
87
|
+
|
|
88
|
+
defined?(Rails::Server) || defined?(Puma::Server) || defined?(Unicorn) || defined?(Passenger)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# `listen` matches ignores against paths relative to the watched root, so
|
|
92
|
+
# the configured exclusions become anchored regexps. Escaping matters:
|
|
93
|
+
# entries like "vendor/bundle" and "public/assets" contain separators, and
|
|
94
|
+
# a stray metacharacter in host config should not build a bogus pattern.
|
|
95
|
+
def ignore_patterns(root)
|
|
96
|
+
Array(Mbeditor.configuration.excluded_paths).map(&:to_s).reject(&:empty?).map do |path|
|
|
97
|
+
%r{\A#{Regexp.escape(path.delete_prefix("/").delete_suffix("/"))}(/|\z)}
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Paths arrive absolute. Anything that does not sit under the workspace
|
|
102
|
+
# is dropped rather than sent raw: the client keys everything by
|
|
103
|
+
# workspace-relative path, and an absolute one would leak host layout.
|
|
104
|
+
def relative_paths(root, paths)
|
|
105
|
+
paths.filter_map do |path|
|
|
106
|
+
rel = path.to_s.delete_prefix("#{root}/")
|
|
107
|
+
rel unless rel.empty? || rel == path.to_s
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def broadcast(root, paths)
|
|
112
|
+
relative = relative_paths(root, paths)
|
|
113
|
+
|
|
114
|
+
invalidate_caches(root)
|
|
115
|
+
return unless defined?(ActionCable.server)
|
|
116
|
+
|
|
117
|
+
payload = { type: "files_changed" }
|
|
118
|
+
payload[:paths] = relative.first(200) if relative.any?
|
|
119
|
+
ActionCable.server.broadcast("mbeditor_editor", payload)
|
|
120
|
+
rescue StandardError => e
|
|
121
|
+
Rails.logger.warn("[mbeditor] file watcher broadcast failed: #{e.class}: #{e.message}")
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Mirrors EditorsController#broadcast_files_changed: a change the editor
|
|
125
|
+
# did not make invalidates exactly the same caches as one it did.
|
|
126
|
+
def invalidate_caches(root)
|
|
127
|
+
FileTreeService.invalidate(root)
|
|
128
|
+
SearchReplaceService.invalidate_cache(root)
|
|
129
|
+
JsGlobalsService.invalidate(root)
|
|
130
|
+
GitInfoService.invalidate(root)
|
|
131
|
+
rescue StandardError => e
|
|
132
|
+
Rails.logger.warn("[mbeditor] file watcher cache invalidation failed: #{e.class}: #{e.message}")
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
data/lib/mbeditor/route_map.rb
CHANGED
|
@@ -54,6 +54,7 @@ module Mbeditor
|
|
|
54
54
|
# ── Git & Code Review ──────────────────────────────────────────────────────
|
|
55
55
|
get 'git/diff', to: 'git#diff'
|
|
56
56
|
get 'git/blame', to: 'git#blame'
|
|
57
|
+
get 'git/line_diff', to: 'git#line_diff'
|
|
57
58
|
get 'git/file_history', to: 'git#file_history'
|
|
58
59
|
get 'git/commit_graph', to: 'git#commit_graph'
|
|
59
60
|
get 'git/commit_detail', to: 'git#commit_detail'
|
data/lib/mbeditor/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mbeditor
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.10.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Oliver Noonan
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-07-
|
|
11
|
+
date: 2026-07-27 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -70,6 +70,7 @@ files:
|
|
|
70
70
|
- app/assets/javascripts/mbeditor/components/GitPanel.js
|
|
71
71
|
- app/assets/javascripts/mbeditor/components/LogPanel.js
|
|
72
72
|
- app/assets/javascripts/mbeditor/components/MbeditorApp.js
|
|
73
|
+
- app/assets/javascripts/mbeditor/components/ProblemsPanel.js
|
|
73
74
|
- app/assets/javascripts/mbeditor/components/QuickOpenDialog.js
|
|
74
75
|
- app/assets/javascripts/mbeditor/components/ShortcutHelp.js
|
|
75
76
|
- app/assets/javascripts/mbeditor/components/TabBar.js
|
|
@@ -81,6 +82,7 @@ files:
|
|
|
81
82
|
- app/assets/javascripts/mbeditor/file_service.js
|
|
82
83
|
- app/assets/javascripts/mbeditor/git_service.js
|
|
83
84
|
- app/assets/javascripts/mbeditor/history_service.js
|
|
85
|
+
- app/assets/javascripts/mbeditor/js_outline.js
|
|
84
86
|
- app/assets/javascripts/mbeditor/log_service.js
|
|
85
87
|
- app/assets/javascripts/mbeditor/ruby_outline.js
|
|
86
88
|
- app/assets/javascripts/mbeditor/search_service.js
|
|
@@ -107,6 +109,7 @@ files:
|
|
|
107
109
|
- app/services/mbeditor/git_diff_service.rb
|
|
108
110
|
- app/services/mbeditor/git_file_history_service.rb
|
|
109
111
|
- app/services/mbeditor/git_info_service.rb
|
|
112
|
+
- app/services/mbeditor/git_line_diff_service.rb
|
|
110
113
|
- app/services/mbeditor/git_service.rb
|
|
111
114
|
- app/services/mbeditor/js_definition_service.rb
|
|
112
115
|
- app/services/mbeditor/js_globals_service.rb
|
|
@@ -133,6 +136,7 @@ files:
|
|
|
133
136
|
- lib/mbeditor/configuration.rb
|
|
134
137
|
- lib/mbeditor/editor_bootstrap.rb
|
|
135
138
|
- lib/mbeditor/engine.rb
|
|
139
|
+
- lib/mbeditor/file_watcher.rb
|
|
136
140
|
- lib/mbeditor/mount_path.rb
|
|
137
141
|
- lib/mbeditor/private_routes.rb
|
|
138
142
|
- lib/mbeditor/rack/handle_pending_migrations.rb
|