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.
@@ -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
- margin: 0 auto;
70
- min-width: 220px;
71
- max-width: 340px;
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 { opacity: 0.8; }
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
- case contents
1148
- when Hash then contents["value"].to_s
1149
- when Array then contents.map { |c| c.is_a?(Hash) ? c["value"].to_s : c.to_s }.join("\n\n")
1150
- else contents.to_s
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
@@ -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
@@ -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'
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mbeditor
4
- VERSION = "0.9.0"
4
+ VERSION = "0.10.0"
5
5
  end
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.9.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-23 00:00:00.000000000 Z
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