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.
- checksums.yaml +4 -4
- data/.github/workflows/main.yml +10 -2
- data/.gitignore +2 -1
- data/README.md +119 -2
- data/Rakefile +9 -1
- data/changes.md +16 -0
- data/lib/coverband/collectors/abstract_tracker.rb +17 -1
- data/lib/coverband/collectors/delta.rb +21 -7
- data/lib/coverband/collectors/query_burst_tracker.rb +268 -0
- data/lib/coverband/collectors/view_tracker.rb +11 -1
- data/lib/coverband/configuration.rb +14 -0
- data/lib/coverband/mcp/http_handler.rb +27 -26
- data/lib/coverband/reporters/web.rb +28 -2
- data/lib/coverband/utils/configuration_template.rb +4 -0
- data/lib/coverband/utils/file_list.rb +1 -0
- data/lib/coverband/utils/html_formatter.rb +7 -2
- data/lib/coverband/utils/tasks.rb +18 -0
- data/lib/coverband/version.rb +1 -1
- data/lib/coverband.rb +3 -2
- data/public/application.css +27 -0
- data/public/application.js +246 -0
- data/public/dead_code_session.js +173 -0
- data/test/benchmarks/benchmark.rake +18 -10
- data/test/benchmarks/benchmark_file_list_covered_lines.rb +54 -0
- data/test/coverband/benchmarks/benchmark_memory_leak_check_test.rb +43 -0
- data/test/coverband/collectors/delta_test.rb +17 -0
- data/test/coverband/collectors/query_burst_tracker_test.rb +77 -0
- data/test/coverband/collectors/view_tracker_test.rb +27 -0
- data/test/coverband/configuration_test.rb +12 -0
- data/test/coverband/mcp/http_handler_test.rb +58 -46
- data/test/coverband/reporters/web_test.rb +67 -0
- data/test/coverband/track_key_test.rb +9 -0
- data/test/forked/rails_route_tracker_stack_test.rb +1 -1
- data/test/forked/rails_view_tracker_stack_test.rb +1 -1
- data/test/integration/mcp_integration_test.rb +3 -3
- data/test/javascript/dead_code_session.test.mjs +123 -0
- data/views/abstract_tracker.erb +49 -2
- data/views/layout.erb +1 -0
- data/views/nav.erb +5 -0
- 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
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
102
|
-
@
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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
|
data/lib/coverband/version.rb
CHANGED
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(", ")}"
|
data/public/application.css
CHANGED
|
@@ -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; }
|
data/public/application.js
CHANGED
|
@@ -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">×</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 % > 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") + '">×</button>' +
|
|
354
|
+
'<button type="button" class="dcs-mark" title="Mark ' + escapeHtml(segs[i].prefix) + ' for deletion">🗑</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">×</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">×</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
|
});
|