coverband 6.2.0 → 6.2.2

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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/main.yml +10 -2
  3. data/.gitignore +2 -1
  4. data/README.md +119 -2
  5. data/Rakefile +9 -1
  6. data/changes.md +16 -0
  7. data/lib/coverband/collectors/abstract_tracker.rb +17 -1
  8. data/lib/coverband/collectors/delta.rb +21 -7
  9. data/lib/coverband/collectors/query_burst_tracker.rb +268 -0
  10. data/lib/coverband/collectors/view_tracker.rb +11 -1
  11. data/lib/coverband/configuration.rb +14 -0
  12. data/lib/coverband/mcp/http_handler.rb +27 -26
  13. data/lib/coverband/reporters/web.rb +28 -2
  14. data/lib/coverband/utils/configuration_template.rb +4 -0
  15. data/lib/coverband/utils/file_list.rb +1 -0
  16. data/lib/coverband/utils/html_formatter.rb +7 -2
  17. data/lib/coverband/utils/tasks.rb +18 -0
  18. data/lib/coverband/version.rb +1 -1
  19. data/lib/coverband.rb +3 -2
  20. data/public/application.css +27 -0
  21. data/public/application.js +246 -0
  22. data/public/dead_code_session.js +173 -0
  23. data/test/benchmarks/benchmark.rake +18 -10
  24. data/test/benchmarks/benchmark_file_list_covered_lines.rb +54 -0
  25. data/test/coverband/benchmarks/benchmark_memory_leak_check_test.rb +43 -0
  26. data/test/coverband/collectors/delta_test.rb +17 -0
  27. data/test/coverband/collectors/query_burst_tracker_test.rb +77 -0
  28. data/test/coverband/collectors/view_tracker_test.rb +27 -0
  29. data/test/coverband/configuration_test.rb +12 -0
  30. data/test/coverband/mcp/http_handler_test.rb +58 -46
  31. data/test/coverband/reporters/web_test.rb +67 -0
  32. data/test/coverband/track_key_test.rb +9 -0
  33. data/test/forked/rails_route_tracker_stack_test.rb +1 -1
  34. data/test/forked/rails_view_tracker_stack_test.rb +1 -1
  35. data/test/integration/mcp_integration_test.rb +3 -3
  36. data/test/javascript/dead_code_session.test.mjs +123 -0
  37. data/views/abstract_tracker.erb +49 -2
  38. data/views/layout.erb +1 -0
  39. data/views/nav.erb +5 -0
  40. metadata +7 -1
@@ -14,6 +14,8 @@ module Coverband
14
14
  :view_tracker, :defer_eager_loading_data,
15
15
  :track_routes, :track_redirect_routes, :route_tracker,
16
16
  :track_translations, :translations_tracker,
17
+ :track_query_bursts, :query_burst_tracker,
18
+ :query_burst_query_count_threshold, :query_burst_sql_time_threshold_ms,
17
19
  :trackers, :csp_policy, :hide_settings,
18
20
  :mcp_enabled
19
21
 
@@ -81,6 +83,10 @@ module Coverband
81
83
  @route_tracker = nil
82
84
  @track_translations = false
83
85
  @translations_tracker = nil
86
+ @track_query_bursts = false
87
+ @query_burst_tracker = nil
88
+ @query_burst_query_count_threshold = 30
89
+ @query_burst_sql_time_threshold_ms = 100.0
84
90
  @web_debug = false
85
91
  @report_on_exit = true
86
92
  @use_oneshot_lines_coverage = ENV["ONESHOT"] || false
@@ -140,6 +146,11 @@ module Coverband
140
146
  end
141
147
  trackers << Coverband.configuration.view_tracker
142
148
  end
149
+
150
+ if Coverband.configuration.track_query_bursts
151
+ Coverband.configuration.query_burst_tracker = Coverband::Collectors::QueryBurstTracker.new
152
+ trackers << Coverband.configuration.query_burst_tracker
153
+ end
143
154
  trackers.each { |tracker| tracker.railtie! }
144
155
  rescue Redis::CannotConnectError => e
145
156
  Coverband.configuration.logger.info "Redis is not available (#{e}), Coverband not configured"
@@ -213,6 +224,9 @@ module Coverband
213
224
  Coverband::Adapters::WebServiceStore.new(service_url)
214
225
  else
215
226
  begin
227
+ # Redis gem automatically enables TLS when the scheme is 'rediss://'
228
+ # For serverless ElastiCache or other TLS-required Redis instances, use:
229
+ # REDIS_URL=rediss://your-endpoint:6379
216
230
  Coverband::Adapters::RedisStore.new(Redis.new(url: redis_url), redis_store_options)
217
231
  rescue Redis::CannotConnectError => error
218
232
  logger.info "Redis is not available (#{error}), defaulting to NullStore"
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Coverband
4
4
  module MCP
5
- # Rack middleware that adds MCP HTTP endpoint support.
5
+ # Rack middleware that adds MCP HTTP endpoint support using StreamableHTTPTransport.
6
6
  # Can be used to wrap the existing Coverband::Reporters::Web app
7
7
  # or mounted standalone.
8
8
  #
@@ -10,7 +10,7 @@ module Coverband
10
10
  # map "/coverage" do
11
11
  # run Coverband::MCP::HttpHandler.new(Coverband::Reporters::Web.new)
12
12
  # end
13
- # # MCP endpoint available at POST /coverage/mcp
13
+ # # MCP endpoint available at /coverage/mcp with full Streamable HTTP transport support
14
14
  #
15
15
  # Usage standalone:
16
16
  # map "/mcp" do
@@ -23,6 +23,7 @@ module Coverband
23
23
  def initialize(app = nil)
24
24
  @app = app
25
25
  @server = nil
26
+ @transport = nil
26
27
  end
27
28
 
28
29
  def call(env)
@@ -40,17 +41,22 @@ module Coverband
40
41
  private
41
42
 
42
43
  def mcp_request?(request)
43
- request.post? && request.path_info.end_with?(MCP_PATH)
44
+ # Accept GET, POST, DELETE, OPTIONS for StreamableHTTPTransport protocol
45
+ %w[GET POST DELETE OPTIONS].include?(request.request_method) &&
46
+ request.path_info.end_with?(MCP_PATH)
44
47
  end
45
48
 
46
49
  def handle_mcp_request(request)
50
+ # Handle CORS preflight
51
+ return cors_preflight_response if request.request_method == "OPTIONS"
52
+
47
53
  # Check authentication if MCP password is configured
48
54
  unless authenticate_mcp_request(request)
49
55
  return [401, {
50
56
  "Content-Type" => "application/json",
51
57
  "Access-Control-Allow-Origin" => "*",
52
- "Access-Control-Allow-Methods" => "POST, OPTIONS",
53
- "Access-Control-Allow-Headers" => "Content-Type, Authorization",
58
+ "Access-Control-Allow-Methods" => "GET, POST, DELETE, OPTIONS",
59
+ "Access-Control-Allow-Headers" => "Content-Type, Authorization, Accept, Mcp-Session-Id",
54
60
  "WWW-Authenticate" => 'Bearer realm="Coverband MCP"'
55
61
  }, [JSON.generate({
56
62
  "error" => "Authentication required",
@@ -58,29 +64,21 @@ module Coverband
58
64
  })]]
59
65
  end
60
66
 
61
- body = request.body.read
62
- json_request = JSON.parse(body)
63
- response = mcp_server.handle_json(json_request)
64
-
65
- # response might already be a JSON string, so check before converting
66
- response_body = response.is_a?(String) ? response : response.to_json
67
-
68
- [
69
- 200,
70
- {
71
- "content-type" => "application/json",
72
- "access-control-allow-origin" => "*",
73
- "access-control-allow-methods" => "POST, OPTIONS",
74
- "access-control-allow-headers" => "Content-Type"
75
- },
76
- [response_body]
77
- ]
78
- rescue JSON::ParserError => e
79
- error_response(400, "Invalid JSON: #{e.message}")
67
+ # Delegate to StreamableHTTPTransport which handles the full MCP HTTP protocol
68
+ # (GET for SSE streams, POST for requests/responses, DELETE for cleanup, etc.)
69
+ transport.handle_request(request)
80
70
  rescue => e
81
71
  error_response(500, "Server error: #{e.message}")
82
72
  end
83
73
 
74
+ def cors_preflight_response
75
+ [204, {
76
+ "Access-Control-Allow-Origin" => "*",
77
+ "Access-Control-Allow-Methods" => "GET, POST, DELETE, OPTIONS",
78
+ "Access-Control-Allow-Headers" => "Content-Type, Authorization, Accept, Mcp-Session-Id"
79
+ }, []]
80
+ end
81
+
84
82
  def authenticate_mcp_request(request)
85
83
  # If no MCP password is configured, allow access
86
84
  mcp_password = Coverband.configuration.mcp_password
@@ -98,8 +96,11 @@ module Coverband
98
96
  token == mcp_password
99
97
  end
100
98
 
101
- def mcp_server
102
- @server ||= Server.new
99
+ def transport
100
+ @transport ||= begin
101
+ server = ::Coverband::MCP::Server.new
102
+ ::MCP::Server::Transports::StreamableHTTPTransport.new(server.mcp_server)
103
+ end
103
104
  end
104
105
 
105
106
  def error_response(status, message)
@@ -32,7 +32,9 @@ module Coverband
32
32
 
33
33
  def init_web
34
34
  full_path = Gem::Specification.find_by_name("coverband").full_gem_path
35
- @file_server = Rack::Files.new(
35
+ # Rack::Files was introduced in Rack 2.0; Rack 1.x uses Rack::File
36
+ rack_file_server = defined?(Rack::Files) ? Rack::Files : Rack::File
37
+ @file_server = rack_file_server.new(
36
38
  File.expand_path("public", full_path)
37
39
  )
38
40
  end
@@ -151,10 +153,13 @@ module Coverband
151
153
  def display_abstract_tracker(tracker)
152
154
  notice = "<strong>Notice:</strong> #{Rack::Utils.escape_html(request.params["notice"])}<br/>"
153
155
  notice = request.params["notice"] ? notice : ""
156
+ used_keys_sort = tracker_used_keys_sort_mode(tracker)
154
157
  options = {
155
158
  tracker: tracker,
156
159
  notice: notice,
157
- base_path: base_path
160
+ base_path: base_path,
161
+ used_keys_sort: used_keys_sort,
162
+ sorted_used_keys: sorted_tracker_used_keys(tracker, used_keys_sort)
158
163
  }
159
164
  Coverband::Utils::HTMLFormatter.new(nil, options).format_abstract_tracker!
160
165
  end
@@ -227,6 +232,27 @@ module Coverband
227
232
 
228
233
  private
229
234
 
235
+ def tracker_used_keys_sort_mode(tracker)
236
+ requested_sort = request.params["used_sort"]
237
+ return requested_sort if %w[alpha last_active].include?(requested_sort)
238
+
239
+ views_tracker?(tracker) ? "last_active" : "alpha"
240
+ end
241
+
242
+ def sorted_tracker_used_keys(tracker, used_keys_sort)
243
+ used_keys = tracker.used_keys
244
+ sorted_pairs = if used_keys_sort == "last_active"
245
+ used_keys.sort_by { |_key, time_at| -time_at.to_i }
246
+ else
247
+ used_keys.sort_by { |key, _time_at| key.to_s }
248
+ end
249
+ sorted_pairs.to_h
250
+ end
251
+
252
+ def views_tracker?(tracker)
253
+ tracker.route == Coverband::Collectors::ViewTracker::REPORT_ROUTE
254
+ end
255
+
230
256
  def coverband_headers(content_type: "text/html")
231
257
  web_headers = {
232
258
  "content-type" => content_type
@@ -7,6 +7,7 @@
7
7
  # Coverband.configure do |config|
8
8
  ####
9
9
  # set a redis URL and set it with with some reasonable timeouts
10
+ # For TLS connections (e.g. AWS serverless ElastiCache), use rediss:// scheme
10
11
  ####
11
12
  # redis_url = ENV["COVERBAND_REDIS"] || ENV["REDIS_URL"] || "redis://localhost:6379"
12
13
  # config.store = Coverband::Adapters::RedisStore.new(
@@ -18,6 +19,9 @@
18
19
  # reconnect_delay_max: ENV.fetch("REDIS_RECONNECT_DELAY_MAX", 2.5)
19
20
  # )
20
21
  # )
22
+ #
23
+ # Example with TLS (rediss:// scheme automatically enables TLS):
24
+ # redis_url = "rediss://my-elasticache.abcdef.cache.amazonaws.com:6379"
21
25
 
22
26
  # Allow folks to reset the coverband data via the web UI
23
27
  # config.web_enable_clear = true
@@ -11,6 +11,7 @@ module Coverband
11
11
  module Utils
12
12
  class FileList < Array
13
13
  # Returns the count of lines that have coverage
14
+ # Using sum avoids intermediate array allocation compared to map.inject
14
15
  def covered_lines
15
16
  return 0.0 if empty?
16
17
 
@@ -13,13 +13,15 @@ require "time"
13
13
  module Coverband
14
14
  module Utils
15
15
  class HTMLFormatter
16
- attr_reader :notice, :base_path, :tracker, :page
16
+ attr_reader :notice, :base_path, :tracker, :page, :used_keys_sort, :sorted_used_keys
17
17
 
18
18
  def initialize(report, options = {})
19
19
  @notice = options.fetch(:notice, nil)
20
20
  @base_path = options.fetch(:base_path, "./")
21
21
  @tracker = options.fetch(:tracker, nil)
22
22
  @page = options.fetch(:page, nil)
23
+ @used_keys_sort = options.fetch(:used_keys_sort, nil)
24
+ @sorted_used_keys = options.fetch(:sorted_used_keys, nil)
23
25
  @coverage_result = Coverband::Utils::Results.new(report) if report
24
26
  end
25
27
 
@@ -180,7 +182,10 @@ module Coverband
180
182
 
181
183
  def link_to_source_file(source_file)
182
184
  data_loader_url = "#{base_path}load_file_details?filename=#{source_file.filename}"
183
- %(<a href="##{id source_file}" class="src_link" title="#{shortened_filename source_file}" data-loader-url="#{data_loader_url}" onclick="src_link_click(this)">#{truncate(shortened_filename(source_file))}</a>)
185
+ full_filename = shortened_filename(source_file)
186
+ truncated_filename = truncate(full_filename)
187
+ # Include full filename in a hidden span so DataTables search can find it even when truncated
188
+ %(<a href="##{id source_file}" class="src_link" title="#{full_filename}" data-loader-url="#{data_loader_url}" onclick="src_link_click(this)">#{truncated_filename}<span style="display:none;">#{full_filename}</span></a>)
184
189
  end
185
190
 
186
191
  def truncate(text, length: 50)
@@ -128,10 +128,28 @@ namespace :coverband do
128
128
 
129
129
  desc "Start MCP server for AI assistant integration (set COVERBAND_MCP_HTTP=true for HTTP mode)"
130
130
  task :mcp do
131
+ # In stdio mode, we must suppress all non-JSON-RPC output to stdout to comply with
132
+ # the MCP stdio transport spec. Otherwise, Rails logger and gem output will pollute
133
+ # the JSON-RPC stream and break clients like Claude Desktop.
134
+ # See https://github.com/danmayer/coverband/issues/625
135
+ use_stdio_mode = !ENV["COVERBAND_MCP_HTTP"]
136
+
137
+ original_stdout = nil
138
+ if use_stdio_mode
139
+ # Save original stdout and redirect stdout to stderr temporarily
140
+ original_stdout = $stdout
141
+ $stdout = $stderr
142
+ end
143
+
131
144
  if Rake::Task.task_defined?("environment")
132
145
  Rake.application["environment"].invoke
133
146
  end
134
147
 
148
+ if use_stdio_mode
149
+ # Restore stdout for JSON-RPC communication over stdio
150
+ $stdout = original_stdout
151
+ end
152
+
135
153
  begin
136
154
  require "coverband/mcp"
137
155
  rescue LoadError
@@ -5,5 +5,5 @@
5
5
  # use format "4.2.1.rc.1" ~> 4.2.1.rc to prerelease versions like v4.2.1.rc.2 and v4.2.1.rc.3
6
6
  ###
7
7
  module Coverband
8
- VERSION = "6.2.0"
8
+ VERSION = "6.2.2"
9
9
  end
data/lib/coverband.rb CHANGED
@@ -21,6 +21,7 @@ require "coverband/collectors/view_tracker"
21
21
  require "coverband/collectors/view_tracker_service"
22
22
  require "coverband/collectors/route_tracker"
23
23
  require "coverband/collectors/translation_tracker"
24
+ require "coverband/collectors/query_burst_tracker"
24
25
  require "coverband/reporters/base"
25
26
  require "coverband/reporters/console_report"
26
27
  require "coverband/integrations/background"
@@ -101,14 +102,14 @@ module Coverband
101
102
  end
102
103
 
103
104
  # Track a key with the specified tracker.
104
- # @param tracker_type [Symbol] The type of tracker to use (e.g., :view_tracker, :translations_tracker, :routes_tracker)
105
+ # @param tracker_type [Symbol] The type of tracker to use (e.g., :view_tracker, :translations_tracker, :routes_tracker, :query_burst_tracker)
105
106
  # @param key [String] The key to track
106
107
  # @return [Boolean] True if tracking was successful, false otherwise
107
108
  # @raise [ArgumentError] If the tracker_type is not supported
108
109
  def self.track_key(tracker_type, key)
109
110
  return false unless key
110
111
 
111
- supported_trackers = [:view_tracker, :translations_tracker, :routes_tracker]
112
+ supported_trackers = [:view_tracker, :translations_tracker, :routes_tracker, :query_burst_tracker]
112
113
 
113
114
  unless supported_trackers.include?(tracker_type)
114
115
  raise ArgumentError, "Unsupported tracker type: #{tracker_type}. Must be one of: #{supported_trackers.join(", ")}"
@@ -867,3 +867,30 @@ td:has(> span.green) {
867
867
  background-color: #fbf0c0; }
868
868
  .source_table .skipped:nth-child(even) {
869
869
  background-color: #fbffcf; }
870
+
871
+ /* Dead-code review session */
872
+ #dcs-toolbar { margin: 8px 0 12px 0; padding: 8px 10px; background: #f4f4f4; border: 1px solid #ddd; border-radius: 4px; font-size: 0.85em; }
873
+ #dcs-toolbar label { margin: 0 10px; cursor: pointer; }
874
+ #dcs-toolbar button { margin: 0 4px; cursor: pointer; }
875
+ .dcs-badge { background: #666; color: #fff; border-radius: 8px; padding: 1px 8px; margin: 0 6px; }
876
+ #dcs-chips { margin-top: 6px; display: none; }
877
+ .dcs-chip { display: inline-block; background: #e8e8e8; border: 1px solid #ccc; border-radius: 10px; padding: 1px 6px; margin: 2px; font-family: monospace; }
878
+ .dcs-chip-x { border: none; background: transparent; cursor: pointer; font-weight: bold; margin-left: 4px; }
879
+ .dcs-seg { position: relative; }
880
+ .dcs-actions { display: none; white-space: nowrap; }
881
+ .dcs-seg:hover > .dcs-actions { display: inline; }
882
+ .dcs-actions button { border: none; background: #ddd; border-radius: 3px; cursor: pointer; font-size: 0.85em; padding: 0 3px; margin-left: 2px; line-height: 1.2; }
883
+ .dcs-actions button:hover { background: #bbb; }
884
+ #dcs-mark-popup { position: absolute; z-index: 10000; background: #fff; border: 1px solid #999; border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.3); padding: 10px; width: 320px; }
885
+ #dcs-mark-popup .dcs-popup-path { font-family: monospace; font-weight: bold; margin-bottom: 6px; word-break: break-all; }
886
+ #dcs-mark-popup textarea { width: 100%; box-sizing: border-box; }
887
+ #dcs-mark-popup .dcs-popup-actions { margin-top: 6px; text-align: right; }
888
+ #dcs-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 10001; }
889
+ #dcs-modal { background: #fff; border-radius: 6px; width: 80%; max-width: 900px; max-height: 80%; overflow: auto; margin: 5% auto; padding: 16px; }
890
+ #dcs-modal-head { display: flex; justify-content: space-between; margin-bottom: 10px; }
891
+ #dcs-modal-table { width: 100%; border-collapse: collapse; }
892
+ #dcs-modal-table th, #dcs-modal-table td { border-bottom: 1px solid #eee; padding: 4px 8px; text-align: left; }
893
+ #dcs-modal-foot { margin-top: 12px; text-align: right; }
894
+ #dcs-modal-foot button { margin-left: 8px; }
895
+ .dcs-mono { font-family: monospace; }
896
+ .dcs-comment-edit { width: 100%; box-sizing: border-box; }
@@ -33,8 +33,14 @@ $(document).ready(function () {
33
33
  ],
34
34
  }
35
35
 
36
+ tableOptions.fnDrawCallback = function (oSettings) {
37
+ if (window.deadCodeOnDraw) window.deadCodeOnDraw(oSettings);
38
+ };
39
+
36
40
  $(".file_list").dataTable(tableOptions);
37
41
 
42
+ initDeadCodeSession();
43
+
38
44
  // TODO: add support for searching on server side
39
45
  // best docs on our version of datatables 1.7 https://datatables.net/beta/1.7/examples/server_side/server_side.html
40
46
  if ($(".file_list.unsorted").length == 1) {
@@ -220,4 +226,244 @@ $(document).ready(function () {
220
226
  $("#loading").fadeOut();
221
227
  $("#wrapper").show();
222
228
  $(".dataTables_filter input").focus();
229
+
230
+ function initDeadCodeSession() {
231
+ if (!window.CoverbandDeadCode || !$(".file_list").length) return;
232
+ var D = window.CoverbandDeadCode;
233
+ var storageKey = "coverband_deadcode_session:" + ($(".file_list").data("coverageurl") || "/");
234
+ var state;
235
+ try {
236
+ state = D.parseState(window.localStorage.getItem(storageKey));
237
+ } catch (e) {
238
+ console && console.warn && console.warn("coverband dead-code session: localStorage unavailable", e);
239
+ state = D.emptyState();
240
+ }
241
+ if (!state.startedAt) state.startedAt = new Date().toISOString();
242
+
243
+ function saveState() {
244
+ try {
245
+ window.localStorage.setItem(storageKey, JSON.stringify(state));
246
+ } catch (e) { /* private mode etc.; session still works in-memory */ }
247
+ }
248
+ saveState();
249
+
250
+ var extractPath = D.extractPath;
251
+ var rowRuntime = D.rowRuntime;
252
+
253
+ // Custom filter: hide rows excluded by the session (DataTables 1.7 API)
254
+ $.fn.dataTableExt.afnFiltering.push(function (oSettings, aData, iDataIndex) {
255
+ if (!$(oSettings.nTable).hasClass("file_list")) return true;
256
+ return !D.isExcluded(state, extractPath(aData[0]), rowRuntime(aData));
257
+ });
258
+
259
+ function redrawTables() {
260
+ $(".file_list").each(function () {
261
+ $(this).dataTable().fnDraw();
262
+ });
263
+ updateBadges();
264
+ }
265
+
266
+ function updateBadges() {
267
+ var hidden = 0;
268
+ $(".file_list").each(function () {
269
+ var data = $(this).dataTable().fnGetData();
270
+ for (var i = 0; i < data.length; i++) {
271
+ if (D.isExcludedIgnoringMarks(state, extractPath(data[i][0]), rowRuntime(data[i]))) hidden++;
272
+ }
273
+ });
274
+ $("#dcs-hidden-count").text(hidden + " hidden");
275
+ $("#dcs-marked-count").text(state.markedPaths.length);
276
+ var chips = $("#dcs-chips").empty();
277
+ $.each(state.hiddenPaths, function (_i, entry) {
278
+ var chip = $('<span class="dcs-chip"></span>').text(entry);
279
+ $('<button class="dcs-chip-x" title="Unhide">&times;</button>')
280
+ .appendTo(chip)
281
+ .bind("click", function () {
282
+ D.removeHidden(state, entry);
283
+ saveState();
284
+ redrawTables();
285
+ });
286
+ chips.append(chip);
287
+ });
288
+ chips.toggle(state.hiddenPaths.length > 0 && chips.data("open") === true);
289
+ $("#dcs-chips-toggle").toggle(state.hiddenPaths.length > 0)
290
+ .text("Hidden items (" + state.hiddenPaths.length + ")");
291
+ }
292
+
293
+ // Toolbar
294
+ var toolbar = $(
295
+ '<div id="dcs-toolbar">' +
296
+ ' <strong>Dead-code session</strong>' +
297
+ ' <label><input type="checkbox" id="dcs-runtime-toggle"> Hide files with runtime % &gt; 0</label>' +
298
+ ' <span class="dcs-badge" id="dcs-hidden-count">0 hidden</span>' +
299
+ ' <button type="button" id="dcs-marked-btn">Marked for deletion: <span id="dcs-marked-count">0</span></button>' +
300
+ ' <button type="button" id="dcs-chips-toggle">Hidden items</button>' +
301
+ ' <button type="button" id="dcs-reset">Reset session</button>' +
302
+ ' <div id="dcs-chips"></div>' +
303
+ '</div>'
304
+ );
305
+ $("#content").prepend(toolbar);
306
+
307
+ $("#dcs-runtime-toggle")
308
+ .prop("checked", state.hideRuntimeUsed)
309
+ .bind("change", function () {
310
+ state.hideRuntimeUsed = this.checked;
311
+ saveState();
312
+ redrawTables();
313
+ });
314
+
315
+ $("#dcs-chips-toggle").bind("click", function () {
316
+ var chips = $("#dcs-chips");
317
+ chips.data("open", chips.data("open") !== true);
318
+ updateBadges();
319
+ });
320
+
321
+ $("#dcs-reset").bind("click", function () {
322
+ if (!confirm("Reset session? This unhides everything (marked-for-deletion list is kept).")) return;
323
+ window.CoverbandDeadCode.clearHides(state);
324
+ $("#dcs-runtime-toggle").prop("checked", false);
325
+ saveState();
326
+ redrawTables();
327
+ });
328
+
329
+ // Exposed for Tasks 3-4 and the shared fnDrawCallback
330
+ window.deadCodeSession = {
331
+ D: D, state: state, saveState: saveState,
332
+ redrawTables: redrawTables, updateBadges: updateBadges,
333
+ extractPath: extractPath
334
+ };
335
+ window.deadCodeOnDraw = function (oSettings) {
336
+ if (window.deadCodeDecorate) window.deadCodeDecorate(oSettings);
337
+ updateBadges();
338
+ };
339
+
340
+ var escapeHtml = D.escapeHtml;
341
+
342
+ window.deadCodeDecorate = function (oSettings) {
343
+ $(oSettings.nTable).find("tbody td a.src_link").not(".dcs-done").each(function () {
344
+ var link = $(this);
345
+ var fullPath = link.attr("title");
346
+ if (!fullPath) return;
347
+ var segs = D.segmentPrefixes(fullPath);
348
+ var html = "";
349
+ for (var i = 0; i < segs.length; i++) {
350
+ html += '<span class="dcs-seg" data-prefix="' + escapeHtml(segs[i].prefix) + '">' +
351
+ escapeHtml(segs[i].label) +
352
+ '<span class="dcs-actions">' +
353
+ '<button type="button" class="dcs-hide" title="Hide ' + escapeHtml(segs[i].prefix) + (segs[i].isFile ? "" : " and everything under it") + '">&times;</button>' +
354
+ '<button type="button" class="dcs-mark" title="Mark ' + escapeHtml(segs[i].prefix) + ' for deletion">&#128465;</button>' +
355
+ '</span></span>' + (segs[i].isFile ? "" : "/");
356
+ }
357
+ link.html(html).addClass("dcs-done");
358
+
359
+ link.find(".dcs-hide").bind("click", function (e) {
360
+ e.preventDefault();
361
+ e.stopPropagation();
362
+ D.addHidden(state, $(this).closest(".dcs-seg").attr("data-prefix"));
363
+ saveState();
364
+ redrawTables();
365
+ return false;
366
+ });
367
+ link.find(".dcs-mark").bind("click", function (e) {
368
+ e.preventDefault();
369
+ e.stopPropagation();
370
+ openMarkPopup($(this).closest(".dcs-seg").attr("data-prefix"), e.pageX, e.pageY);
371
+ return false;
372
+ });
373
+ });
374
+ };
375
+
376
+ function closeMarkPopup() { $("#dcs-mark-popup").remove(); }
377
+
378
+ function openMarkPopup(prefix, x, y) {
379
+ closeMarkPopup();
380
+ var popup = $(
381
+ '<div id="dcs-mark-popup">' +
382
+ ' <div class="dcs-popup-path"></div>' +
383
+ ' <textarea id="dcs-mark-comment" rows="2" placeholder="Optional comment (why is this dead?)"></textarea>' +
384
+ ' <div class="dcs-popup-actions">' +
385
+ ' <button type="button" id="dcs-mark-confirm">Mark for deletion</button>' +
386
+ ' <button type="button" id="dcs-mark-cancel">Cancel</button>' +
387
+ ' </div>' +
388
+ '</div>'
389
+ );
390
+ popup.find(".dcs-popup-path").text(prefix);
391
+ popup.css({ left: Math.min(x, $(window).width() - 340) + "px", top: (y + 8) + "px" });
392
+ $("body").append(popup);
393
+ $("#dcs-mark-comment").focus();
394
+ $("#dcs-mark-cancel").bind("click", closeMarkPopup);
395
+ $("#dcs-mark-confirm").bind("click", function () {
396
+ D.addMark(state, prefix, $("#dcs-mark-comment").val(), new Date().toISOString());
397
+ saveState();
398
+ closeMarkPopup();
399
+ redrawTables();
400
+ });
401
+ }
402
+
403
+ function closeMarkedModal() { $("#dcs-modal-overlay").remove(); }
404
+
405
+ function downloadCSV() {
406
+ var blob = new Blob([D.toCSV(state.markedPaths)], { type: "text/csv;charset=utf-8" });
407
+ var a = document.createElement("a");
408
+ a.href = URL.createObjectURL(blob);
409
+ a.download = D.csvFilename(new Date());
410
+ document.body.appendChild(a);
411
+ a.click();
412
+ document.body.removeChild(a);
413
+ URL.revokeObjectURL(a.href);
414
+ }
415
+
416
+ function openMarkedModal() {
417
+ closeMarkedModal();
418
+ var overlay = $('<div id="dcs-modal-overlay"><div id="dcs-modal">' +
419
+ '<div id="dcs-modal-head"><strong>Marked for deletion</strong>' +
420
+ '<button type="button" id="dcs-modal-close">&times;</button></div>' +
421
+ '<table id="dcs-modal-table"><thead><tr>' +
422
+ '<th>Path</th><th>Comment</th><th>Marked at</th><th></th>' +
423
+ '</tr></thead><tbody></tbody></table>' +
424
+ '<div id="dcs-modal-foot">' +
425
+ '<button type="button" id="dcs-csv">Download CSV</button>' +
426
+ '<button type="button" id="dcs-start-over">Start over</button>' +
427
+ '</div></div></div>');
428
+ var tbody = overlay.find("tbody");
429
+ if (state.markedPaths.length === 0) {
430
+ tbody.append('<tr><td colspan="4"><em>Nothing marked yet.</em></td></tr>');
431
+ }
432
+ $.each(state.markedPaths.slice(), function (_i, m) {
433
+ var tr = $("<tr></tr>");
434
+ $('<td class="dcs-mono"></td>').text(m.path).appendTo(tr);
435
+ var commentInput = $('<input type="text" class="dcs-comment-edit">').val(m.comment)
436
+ .bind("change", function () {
437
+ D.updateComment(state, m.path, $(this).val());
438
+ saveState();
439
+ });
440
+ $("<td></td>").append(commentInput).appendTo(tr);
441
+ $('<td class="dcs-mono"></td>').text(m.markedAt).appendTo(tr);
442
+ $("<td></td>").append(
443
+ $('<button type="button" title="Unmark">&times;</button>').bind("click", function () {
444
+ D.removeMark(state, m.path);
445
+ saveState();
446
+ redrawTables();
447
+ openMarkedModal(); // rebuild
448
+ })
449
+ ).appendTo(tr);
450
+ tbody.append(tr);
451
+ });
452
+ $("body").append(overlay);
453
+ $("#dcs-modal-close").bind("click", closeMarkedModal);
454
+ overlay.bind("click", function (e) { if (e.target === this) closeMarkedModal(); });
455
+ $("#dcs-csv").bind("click", downloadCSV);
456
+ $("#dcs-start-over").bind("click", function () {
457
+ if (!confirm("Clear the entire marked-for-deletion list?")) return;
458
+ D.clearMarks(state);
459
+ saveState();
460
+ redrawTables();
461
+ openMarkedModal();
462
+ });
463
+ }
464
+
465
+ $("#dcs-marked-btn").bind("click", openMarkedModal);
466
+
467
+ redrawTables();
468
+ }
223
469
  });