file_tree_visualizer 0.0.3
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 +7 -0
- data/Gemfile +5 -0
- data/README.md +44 -0
- data/Rakefile +3 -0
- data/bin/file-tree-visualizer +6 -0
- data/file_tree_visualizer.gemspec +33 -0
- data/lib/file_tree_visualizer/cli.rb +174 -0
- data/lib/file_tree_visualizer/report_builder.rb +443 -0
- data/lib/file_tree_visualizer/scanner.rb +191 -0
- data/lib/file_tree_visualizer/version.rb +5 -0
- data/lib/file_tree_visualizer.rb +9 -0
- metadata +57 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d9ec7a6ef5ea0597b5efc056da74912985e0ca2f9a784398c13aa83b5e95c5a6
|
|
4
|
+
data.tar.gz: f3a9a8ee8fb984e8b135fefbe9db34b9e8f5df19ccf787538a754b975b5c9022
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: f902440430610f789db16ab282b2890e3569fa354691ee74dd1587539ad299aa2b7db9c66890496fd46429e1369da7b5d84db95b6c1614977559ffbef3b99c68
|
|
7
|
+
data.tar.gz: 9571ce07d3162b7e5477ae40f1a844c61e2ac476780e1665300778e1d811500b1b288e064b376e86a2f2305e1fad3e54a3a2dbbd80c106459f8638d4d96b835c
|
data/Gemfile
ADDED
data/README.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# file_tree_visualizer
|
|
2
|
+
|
|
3
|
+
Gema Ruby para escanear árboles de archivos desde terminal y generar un reporte HTML interactivo 100% offline con los elementos que más espacio ocupan.
|
|
4
|
+
|
|
5
|
+
## Qué hace
|
|
6
|
+
|
|
7
|
+
- Recorre recursivamente una carpeta.
|
|
8
|
+
- Calcula tamaño acumulado por directorio y tamaño por archivo.
|
|
9
|
+
- Genera un reporte HTML con:
|
|
10
|
+
- Árbol de archivos expandible/colapsable.
|
|
11
|
+
- Filtros por texto y tamaño mínimo.
|
|
12
|
+
- Ranking de archivos más pesados.
|
|
13
|
+
- Sin dependencias externas (funciona sin internet).
|
|
14
|
+
|
|
15
|
+
## Uso rápido (sin instalar la gema)
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
ruby bin/file-tree-visualizer /ruta/a/escanear -o reporte.html
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Uso como gema
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
bundle install
|
|
25
|
+
gem build file_tree_visualizer.gemspec
|
|
26
|
+
gem install ./file_tree_visualizer-0.0.3.gem
|
|
27
|
+
file-tree-visualizer /ruta/a/escanear -o reporte.html
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Opciones
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
file-tree-visualizer [ruta] [opciones]
|
|
34
|
+
|
|
35
|
+
Si no indicas `ruta`, se escanea el directorio actual por defecto.
|
|
36
|
+
|
|
37
|
+
Durante el escaneo, en terminal interactiva se muestra una barra de progreso con archivos y directorios procesados vs total detectado.
|
|
38
|
+
|
|
39
|
+
-o, --output FILE Archivo HTML de salida (default: file-tree-report.html)
|
|
40
|
+
-t, --top N Cantidad de archivos pesados a guardar en ranking (default: 200)
|
|
41
|
+
--follow-symlinks Sigue enlaces simbólicos
|
|
42
|
+
--no-progress Desactiva la barra de progreso
|
|
43
|
+
-h, --help Muestra ayuda
|
|
44
|
+
```
|
data/Rakefile
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/file_tree_visualizer/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "file_tree_visualizer"
|
|
7
|
+
spec.version = FileTreeVisualizer::VERSION
|
|
8
|
+
spec.authors = ["jbidwell1"]
|
|
9
|
+
spec.email = ["devnull@example.com"]
|
|
10
|
+
|
|
11
|
+
spec.summary = "CLI Ruby tool to analyze file sizes and generate an interactive HTML report."
|
|
12
|
+
spec.description = "Scans a directory recursively, computes file/directory sizes, and generates an HTML report rendered with React."
|
|
13
|
+
spec.homepage = "https://example.com/file_tree_visualizer"
|
|
14
|
+
spec.license = "MIT"
|
|
15
|
+
spec.required_ruby_version = ">= 2.7"
|
|
16
|
+
|
|
17
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
18
|
+
spec.metadata["source_code_uri"] = spec.homepage
|
|
19
|
+
|
|
20
|
+
spec.files = Dir.chdir(__dir__) do
|
|
21
|
+
Dir[
|
|
22
|
+
"README.md",
|
|
23
|
+
"Gemfile",
|
|
24
|
+
"Rakefile",
|
|
25
|
+
"bin/*",
|
|
26
|
+
"lib/**/*.rb",
|
|
27
|
+
"file_tree_visualizer.gemspec"
|
|
28
|
+
]
|
|
29
|
+
end
|
|
30
|
+
spec.bindir = "bin"
|
|
31
|
+
spec.executables = ["file-tree-visualizer"]
|
|
32
|
+
spec.require_paths = ["lib"]
|
|
33
|
+
end
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module FileTreeVisualizer
|
|
6
|
+
class CLI
|
|
7
|
+
DEFAULT_OUTPUT = "file-tree-report.html"
|
|
8
|
+
|
|
9
|
+
def initialize(argv)
|
|
10
|
+
@argv = argv
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def run
|
|
14
|
+
options, parser = parse_options(@argv.dup)
|
|
15
|
+
if options[:help]
|
|
16
|
+
puts parser
|
|
17
|
+
return 0
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
scanner = Scanner.new(top_limit: options[:top], follow_symlinks: options[:follow_symlinks])
|
|
21
|
+
progress = ProgressRenderer.new(enabled: options[:progress])
|
|
22
|
+
callback = options[:progress] ? progress.method(:render) : nil
|
|
23
|
+
result = scanner.scan(options[:path], progress: callback)
|
|
24
|
+
progress.finish
|
|
25
|
+
report = ReportBuilder.new(result: result, source_path: options[:path]).build
|
|
26
|
+
|
|
27
|
+
output_path = File.expand_path(options[:output])
|
|
28
|
+
File.write(output_path, report)
|
|
29
|
+
|
|
30
|
+
puts "Reporte generado en: #{output_path}"
|
|
31
|
+
puts "Escaneado: #{result.stats[:directories]} directorios, #{result.stats[:files]} archivos, #{result.stats[:skipped]} omitidos."
|
|
32
|
+
puts "Tamaño total: #{format_bytes(result.stats[:bytes])}"
|
|
33
|
+
0
|
|
34
|
+
rescue OptionParser::ParseError, ArgumentError => e
|
|
35
|
+
warn e.message
|
|
36
|
+
warn "Usa --help para ver las opciones disponibles."
|
|
37
|
+
1
|
|
38
|
+
rescue StandardError => e
|
|
39
|
+
warn "Error inesperado: #{e.message}"
|
|
40
|
+
1
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
class ProgressRenderer
|
|
46
|
+
BAR_WIDTH = 28
|
|
47
|
+
|
|
48
|
+
def initialize(io: $stdout, enabled: true)
|
|
49
|
+
@io = io
|
|
50
|
+
@enabled = enabled && io.tty?
|
|
51
|
+
@last_draw_at = nil
|
|
52
|
+
@has_rendered = false
|
|
53
|
+
@spinner_index = 0
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def render(phase:, **data)
|
|
57
|
+
return unless @enabled
|
|
58
|
+
|
|
59
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
60
|
+
return if @last_draw_at && now - @last_draw_at < 0.05
|
|
61
|
+
|
|
62
|
+
@last_draw_at = now
|
|
63
|
+
|
|
64
|
+
if phase == :counting
|
|
65
|
+
render_counting(data)
|
|
66
|
+
else
|
|
67
|
+
render_scanning(data)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
@has_rendered = true
|
|
71
|
+
@io.flush
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def finish
|
|
75
|
+
return unless @enabled
|
|
76
|
+
|
|
77
|
+
@io.print("\n") if @has_rendered
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def render_counting(data)
|
|
83
|
+
spinner = %w[| / - \\][@spinner_index % 4]
|
|
84
|
+
@spinner_index += 1
|
|
85
|
+
@io.print(format("\rPreparando escaneo %<spinner>s Arch:%<files>d Dir:%<dirs>d",
|
|
86
|
+
spinner: spinner,
|
|
87
|
+
files: data.fetch(:discovered_files, 0),
|
|
88
|
+
dirs: data.fetch(:discovered_directories, 0)))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def render_scanning(data)
|
|
92
|
+
processed_files = data.fetch(:processed_files, 0)
|
|
93
|
+
total_files = data.fetch(:total_files, 0)
|
|
94
|
+
processed_directories = data.fetch(:processed_directories, 0)
|
|
95
|
+
total_directories = data.fetch(:total_directories, 0)
|
|
96
|
+
total_items = total_files + total_directories
|
|
97
|
+
processed_items = [processed_files + processed_directories, total_items].min
|
|
98
|
+
|
|
99
|
+
safe_total_items = total_items.positive? ? total_items : 1
|
|
100
|
+
progress_ratio = [processed_items.to_f / safe_total_items, 1.0].min
|
|
101
|
+
filled = (progress_ratio * BAR_WIDTH).round
|
|
102
|
+
empty = BAR_WIDTH - filled
|
|
103
|
+
percent = progress_ratio * 100
|
|
104
|
+
|
|
105
|
+
@io.print(format("\rEscaneando [%<bar>s] %<percent>6.2f%% Arch:%<pf>d/%<tf>d Dir:%<pd>d/%<td>d",
|
|
106
|
+
bar: ("#" * filled) + ("-" * empty),
|
|
107
|
+
percent: percent,
|
|
108
|
+
pf: processed_files,
|
|
109
|
+
tf: total_files,
|
|
110
|
+
pd: processed_directories,
|
|
111
|
+
td: total_directories))
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def parse_options(argv)
|
|
116
|
+
options = {
|
|
117
|
+
path: Dir.pwd,
|
|
118
|
+
output: DEFAULT_OUTPUT,
|
|
119
|
+
top: Scanner::DEFAULT_TOP_LIMIT,
|
|
120
|
+
follow_symlinks: false,
|
|
121
|
+
progress: true,
|
|
122
|
+
help: false
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
parser = OptionParser.new do |opts|
|
|
126
|
+
opts.banner = "Uso: file-tree-visualizer [ruta] [opciones]"
|
|
127
|
+
opts.separator ""
|
|
128
|
+
opts.separator "Opciones:"
|
|
129
|
+
|
|
130
|
+
opts.on("-o", "--output FILE", "Archivo HTML de salida (default: #{DEFAULT_OUTPUT})") do |value|
|
|
131
|
+
options[:output] = value
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
opts.on("-t", "--top N", Integer, "Cantidad de archivos pesados en ranking (default: #{Scanner::DEFAULT_TOP_LIMIT})") do |value|
|
|
135
|
+
options[:top] = value
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
opts.on("--follow-symlinks", "Sigue enlaces simbólicos durante el escaneo") do
|
|
139
|
+
options[:follow_symlinks] = true
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
opts.on("--no-progress", "Desactiva la barra de progreso") do
|
|
143
|
+
options[:progress] = false
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
opts.on("-h", "--help", "Muestra esta ayuda") do
|
|
147
|
+
options[:help] = true
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
remaining = parser.parse(argv)
|
|
152
|
+
options[:path] = File.expand_path(remaining.first || options[:path])
|
|
153
|
+
raise ArgumentError, "--top debe ser mayor que 0" unless options[:top].positive?
|
|
154
|
+
|
|
155
|
+
[options, parser]
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def format_bytes(bytes)
|
|
159
|
+
return "#{bytes} B" if bytes < 1024
|
|
160
|
+
|
|
161
|
+
units = %w[KB MB GB TB PB]
|
|
162
|
+
value = bytes.to_f / 1024
|
|
163
|
+
unit_index = 0
|
|
164
|
+
|
|
165
|
+
while value >= 1024 && unit_index < units.length - 1
|
|
166
|
+
value /= 1024
|
|
167
|
+
unit_index += 1
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
precision = value < 10 ? 2 : 1
|
|
171
|
+
format("%.#{precision}f %s", value, units[unit_index])
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module FileTreeVisualizer
|
|
7
|
+
class ReportBuilder
|
|
8
|
+
def initialize(result:, source_path:, generated_at: Time.now.utc)
|
|
9
|
+
@result = result
|
|
10
|
+
@source_path = File.expand_path(source_path.to_s)
|
|
11
|
+
@generated_at = generated_at
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def build
|
|
15
|
+
payload_json = JSON.generate(payload).gsub("</", "<\\/")
|
|
16
|
+
<<~HTML
|
|
17
|
+
<!doctype html>
|
|
18
|
+
<html lang="es">
|
|
19
|
+
<head>
|
|
20
|
+
<meta charset="utf-8" />
|
|
21
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
22
|
+
<title>Reporte de archivos</title>
|
|
23
|
+
<style>
|
|
24
|
+
:root {
|
|
25
|
+
color-scheme: dark;
|
|
26
|
+
--bg: #0f172a;
|
|
27
|
+
--panel: #111827;
|
|
28
|
+
--panel-soft: #1f2937;
|
|
29
|
+
--text: #e5e7eb;
|
|
30
|
+
--muted: #94a3b8;
|
|
31
|
+
--accent: #38bdf8;
|
|
32
|
+
--ok: #34d399;
|
|
33
|
+
--border: #334155;
|
|
34
|
+
}
|
|
35
|
+
* {
|
|
36
|
+
box-sizing: border-box;
|
|
37
|
+
}
|
|
38
|
+
body {
|
|
39
|
+
margin: 0;
|
|
40
|
+
font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
41
|
+
background: var(--bg);
|
|
42
|
+
color: var(--text);
|
|
43
|
+
}
|
|
44
|
+
.container {
|
|
45
|
+
max-width: 1400px;
|
|
46
|
+
margin: 0 auto;
|
|
47
|
+
padding: 1rem;
|
|
48
|
+
display: flex;
|
|
49
|
+
flex-direction: column;
|
|
50
|
+
gap: 1rem;
|
|
51
|
+
}
|
|
52
|
+
.header {
|
|
53
|
+
display: flex;
|
|
54
|
+
flex-wrap: wrap;
|
|
55
|
+
justify-content: space-between;
|
|
56
|
+
align-items: center;
|
|
57
|
+
gap: 1rem;
|
|
58
|
+
border: 1px solid var(--border);
|
|
59
|
+
background: var(--panel);
|
|
60
|
+
border-radius: 12px;
|
|
61
|
+
padding: 1rem;
|
|
62
|
+
}
|
|
63
|
+
.header h1 {
|
|
64
|
+
margin: 0;
|
|
65
|
+
font-size: 1.2rem;
|
|
66
|
+
}
|
|
67
|
+
.meta {
|
|
68
|
+
color: var(--muted);
|
|
69
|
+
font-size: 0.9rem;
|
|
70
|
+
}
|
|
71
|
+
.stats {
|
|
72
|
+
display: grid;
|
|
73
|
+
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
74
|
+
gap: 0.6rem;
|
|
75
|
+
}
|
|
76
|
+
.stat {
|
|
77
|
+
border: 1px solid var(--border);
|
|
78
|
+
background: var(--panel);
|
|
79
|
+
border-radius: 10px;
|
|
80
|
+
padding: 0.7rem;
|
|
81
|
+
}
|
|
82
|
+
.stat-label {
|
|
83
|
+
color: var(--muted);
|
|
84
|
+
font-size: 0.82rem;
|
|
85
|
+
}
|
|
86
|
+
.stat-value {
|
|
87
|
+
font-size: 1rem;
|
|
88
|
+
margin-top: 0.2rem;
|
|
89
|
+
}
|
|
90
|
+
.filters {
|
|
91
|
+
border: 1px solid var(--border);
|
|
92
|
+
background: var(--panel);
|
|
93
|
+
border-radius: 10px;
|
|
94
|
+
padding: 0.8rem;
|
|
95
|
+
display: grid;
|
|
96
|
+
gap: 0.7rem;
|
|
97
|
+
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
|
98
|
+
}
|
|
99
|
+
.filters label {
|
|
100
|
+
display: flex;
|
|
101
|
+
flex-direction: column;
|
|
102
|
+
gap: 0.3rem;
|
|
103
|
+
font-size: 0.85rem;
|
|
104
|
+
color: var(--muted);
|
|
105
|
+
}
|
|
106
|
+
.filters input {
|
|
107
|
+
background: #0b1220;
|
|
108
|
+
border: 1px solid var(--border);
|
|
109
|
+
color: var(--text);
|
|
110
|
+
border-radius: 6px;
|
|
111
|
+
padding: 0.45rem 0.55rem;
|
|
112
|
+
}
|
|
113
|
+
.layout {
|
|
114
|
+
display: grid;
|
|
115
|
+
grid-template-columns: 2fr 1fr;
|
|
116
|
+
gap: 1rem;
|
|
117
|
+
}
|
|
118
|
+
.panel {
|
|
119
|
+
border: 1px solid var(--border);
|
|
120
|
+
background: var(--panel);
|
|
121
|
+
border-radius: 12px;
|
|
122
|
+
min-height: 400px;
|
|
123
|
+
overflow: hidden;
|
|
124
|
+
display: flex;
|
|
125
|
+
flex-direction: column;
|
|
126
|
+
}
|
|
127
|
+
.panel h2 {
|
|
128
|
+
margin: 0;
|
|
129
|
+
padding: 0.85rem 1rem;
|
|
130
|
+
border-bottom: 1px solid var(--border);
|
|
131
|
+
font-size: 1rem;
|
|
132
|
+
}
|
|
133
|
+
.panel-body {
|
|
134
|
+
padding: 0.75rem;
|
|
135
|
+
overflow: auto;
|
|
136
|
+
}
|
|
137
|
+
.node-row {
|
|
138
|
+
display: flex;
|
|
139
|
+
align-items: center;
|
|
140
|
+
gap: 0.4rem;
|
|
141
|
+
padding: 0.2rem 0;
|
|
142
|
+
}
|
|
143
|
+
.toggle-btn {
|
|
144
|
+
width: 1.2rem;
|
|
145
|
+
height: 1.2rem;
|
|
146
|
+
border: 1px solid var(--border);
|
|
147
|
+
background: var(--panel-soft);
|
|
148
|
+
color: var(--text);
|
|
149
|
+
border-radius: 4px;
|
|
150
|
+
cursor: pointer;
|
|
151
|
+
line-height: 1;
|
|
152
|
+
font-size: 0.8rem;
|
|
153
|
+
}
|
|
154
|
+
.file-dot {
|
|
155
|
+
width: 1.2rem;
|
|
156
|
+
text-align: center;
|
|
157
|
+
color: var(--muted);
|
|
158
|
+
}
|
|
159
|
+
.node-name {
|
|
160
|
+
flex: 1;
|
|
161
|
+
overflow: hidden;
|
|
162
|
+
text-overflow: ellipsis;
|
|
163
|
+
white-space: nowrap;
|
|
164
|
+
}
|
|
165
|
+
.node-size {
|
|
166
|
+
font-variant-numeric: tabular-nums;
|
|
167
|
+
color: var(--ok);
|
|
168
|
+
margin-left: auto;
|
|
169
|
+
}
|
|
170
|
+
table {
|
|
171
|
+
width: 100%;
|
|
172
|
+
border-collapse: collapse;
|
|
173
|
+
}
|
|
174
|
+
th,
|
|
175
|
+
td {
|
|
176
|
+
text-align: left;
|
|
177
|
+
padding: 0.45rem;
|
|
178
|
+
border-bottom: 1px solid var(--border);
|
|
179
|
+
font-size: 0.85rem;
|
|
180
|
+
vertical-align: top;
|
|
181
|
+
}
|
|
182
|
+
th {
|
|
183
|
+
color: var(--muted);
|
|
184
|
+
position: sticky;
|
|
185
|
+
top: 0;
|
|
186
|
+
background: var(--panel);
|
|
187
|
+
}
|
|
188
|
+
a {
|
|
189
|
+
color: var(--accent);
|
|
190
|
+
text-decoration: none;
|
|
191
|
+
}
|
|
192
|
+
a:hover {
|
|
193
|
+
text-decoration: underline;
|
|
194
|
+
}
|
|
195
|
+
.hint {
|
|
196
|
+
font-size: 0.8rem;
|
|
197
|
+
color: var(--muted);
|
|
198
|
+
margin-top: 0.2rem;
|
|
199
|
+
}
|
|
200
|
+
.empty-state {
|
|
201
|
+
color: var(--muted);
|
|
202
|
+
font-size: 0.9rem;
|
|
203
|
+
padding: 0.5rem 0.25rem;
|
|
204
|
+
}
|
|
205
|
+
@media (max-width: 1080px) {
|
|
206
|
+
.layout {
|
|
207
|
+
grid-template-columns: 1fr;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
</style>
|
|
211
|
+
</head>
|
|
212
|
+
<body>
|
|
213
|
+
<div id="app"></div>
|
|
214
|
+
<script id="scan-data" type="application/json">#{payload_json}</script>
|
|
215
|
+
<script>
|
|
216
|
+
(function () {
|
|
217
|
+
const data = JSON.parse(document.getElementById("scan-data").textContent);
|
|
218
|
+
const app = document.getElementById("app");
|
|
219
|
+
|
|
220
|
+
const state = {
|
|
221
|
+
query: "",
|
|
222
|
+
minSizeMb: 0,
|
|
223
|
+
expanded: new Set([data.root.path])
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const formatBytes = (bytes) => {
|
|
227
|
+
if (bytes < 1024) return bytes + " B";
|
|
228
|
+
const units = ["KB", "MB", "GB", "TB", "PB"];
|
|
229
|
+
let value = bytes / 1024;
|
|
230
|
+
let unitIndex = 0;
|
|
231
|
+
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
232
|
+
value /= 1024;
|
|
233
|
+
unitIndex += 1;
|
|
234
|
+
}
|
|
235
|
+
return value.toFixed(value < 10 ? 2 : 1) + " " + units[unitIndex];
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const nodeMatches = (node, query, minBytes) => {
|
|
239
|
+
const pathText = (node.name + " " + node.path).toLowerCase();
|
|
240
|
+
const queryMatch = !query || pathText.includes(query);
|
|
241
|
+
const sizeMatch = node.size >= minBytes;
|
|
242
|
+
|
|
243
|
+
if (node.type === "file") return queryMatch && sizeMatch;
|
|
244
|
+
if (queryMatch && sizeMatch) return true;
|
|
245
|
+
return (node.children || []).some((child) => nodeMatches(child, query, minBytes));
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const filters = () => ({
|
|
249
|
+
query: state.query.trim().toLowerCase(),
|
|
250
|
+
minBytes: Math.max(0, Number(state.minSizeMb || 0)) * 1024 * 1024
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const formatNumber = (value) => Number(value || 0).toLocaleString();
|
|
254
|
+
|
|
255
|
+
app.innerHTML = [
|
|
256
|
+
'<div class="container">',
|
|
257
|
+
' <header class="header">',
|
|
258
|
+
' <div>',
|
|
259
|
+
' <h1>Reporte de espacio en disco</h1>',
|
|
260
|
+
' <div class="meta" id="meta-source"></div>',
|
|
261
|
+
' <div class="meta" id="meta-generated"></div>',
|
|
262
|
+
' </div>',
|
|
263
|
+
' </header>',
|
|
264
|
+
' <section class="stats">',
|
|
265
|
+
' <div class="stat"><div class="stat-label">Tamaño total</div><div class="stat-value" id="stat-bytes"></div></div>',
|
|
266
|
+
' <div class="stat"><div class="stat-label">Archivos</div><div class="stat-value" id="stat-files"></div></div>',
|
|
267
|
+
' <div class="stat"><div class="stat-label">Directorios</div><div class="stat-value" id="stat-directories"></div></div>',
|
|
268
|
+
' <div class="stat"><div class="stat-label">Entradas omitidas</div><div class="stat-value" id="stat-skipped"></div></div>',
|
|
269
|
+
' </section>',
|
|
270
|
+
' <section class="filters">',
|
|
271
|
+
' <label>Buscar por nombre/ruta<input id="query-input" type="text" placeholder="ej: node_modules, .mp4, backup" /></label>',
|
|
272
|
+
' <label>Tamaño mínimo (MB)<input id="min-size-input" type="number" min="0" value="0" /><span class="hint">Filtra nodos y ranking por tamaño mínimo.</span></label>',
|
|
273
|
+
' </section>',
|
|
274
|
+
' <section class="layout">',
|
|
275
|
+
' <article class="panel">',
|
|
276
|
+
' <h2>Árbol de archivos</h2>',
|
|
277
|
+
' <div class="panel-body" id="tree-panel"></div>',
|
|
278
|
+
' </article>',
|
|
279
|
+
' <article class="panel">',
|
|
280
|
+
' <h2>Top archivos más pesados</h2>',
|
|
281
|
+
' <div class="panel-body">',
|
|
282
|
+
' <table>',
|
|
283
|
+
' <thead><tr><th>#</th><th>Tamaño</th><th>Ruta</th></tr></thead>',
|
|
284
|
+
' <tbody id="top-files-body"></tbody>',
|
|
285
|
+
' </table>',
|
|
286
|
+
' </div>',
|
|
287
|
+
' </article>',
|
|
288
|
+
' </section>',
|
|
289
|
+
'</div>'
|
|
290
|
+
].join("");
|
|
291
|
+
|
|
292
|
+
const queryInput = document.getElementById("query-input");
|
|
293
|
+
const minSizeInput = document.getElementById("min-size-input");
|
|
294
|
+
const treePanel = document.getElementById("tree-panel");
|
|
295
|
+
const topFilesBody = document.getElementById("top-files-body");
|
|
296
|
+
|
|
297
|
+
document.getElementById("meta-source").textContent = "Origen: " + data.source_path;
|
|
298
|
+
document.getElementById("meta-generated").textContent = "Generado: " + data.generated_at;
|
|
299
|
+
document.getElementById("stat-bytes").textContent = formatBytes(data.stats.bytes);
|
|
300
|
+
document.getElementById("stat-files").textContent = formatNumber(data.stats.files);
|
|
301
|
+
document.getElementById("stat-directories").textContent = formatNumber(data.stats.directories);
|
|
302
|
+
document.getElementById("stat-skipped").textContent = formatNumber(data.stats.skipped);
|
|
303
|
+
|
|
304
|
+
queryInput.addEventListener("input", (event) => {
|
|
305
|
+
state.query = event.target.value || "";
|
|
306
|
+
render();
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
minSizeInput.addEventListener("input", (event) => {
|
|
310
|
+
state.minSizeMb = event.target.value;
|
|
311
|
+
render();
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
const appendTreeNode = (node, depth, parent, activeFilters) => {
|
|
315
|
+
if (!nodeMatches(node, activeFilters.query, activeFilters.minBytes)) return;
|
|
316
|
+
|
|
317
|
+
const row = document.createElement("div");
|
|
318
|
+
row.className = "node-row";
|
|
319
|
+
row.style.paddingLeft = (depth * 14) + "px";
|
|
320
|
+
|
|
321
|
+
if (node.type === "directory") {
|
|
322
|
+
const button = document.createElement("button");
|
|
323
|
+
button.className = "toggle-btn";
|
|
324
|
+
button.textContent = state.expanded.has(node.path) ? "▾" : "▸";
|
|
325
|
+
button.addEventListener("click", () => {
|
|
326
|
+
if (state.expanded.has(node.path)) {
|
|
327
|
+
state.expanded.delete(node.path);
|
|
328
|
+
} else {
|
|
329
|
+
state.expanded.add(node.path);
|
|
330
|
+
}
|
|
331
|
+
renderTree(activeFilters);
|
|
332
|
+
});
|
|
333
|
+
row.appendChild(button);
|
|
334
|
+
} else {
|
|
335
|
+
const dot = document.createElement("span");
|
|
336
|
+
dot.className = "file-dot";
|
|
337
|
+
dot.textContent = "•";
|
|
338
|
+
row.appendChild(dot);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const name = document.createElement("span");
|
|
342
|
+
name.className = "node-name";
|
|
343
|
+
name.title = node.path;
|
|
344
|
+
name.textContent = node.name;
|
|
345
|
+
row.appendChild(name);
|
|
346
|
+
|
|
347
|
+
const size = document.createElement("span");
|
|
348
|
+
size.className = "node-size";
|
|
349
|
+
size.textContent = formatBytes(node.size);
|
|
350
|
+
row.appendChild(size);
|
|
351
|
+
|
|
352
|
+
parent.appendChild(row);
|
|
353
|
+
|
|
354
|
+
if (node.type === "directory" && state.expanded.has(node.path)) {
|
|
355
|
+
const childrenWrap = document.createElement("div");
|
|
356
|
+
parent.appendChild(childrenWrap);
|
|
357
|
+
(node.children || []).forEach((child) => {
|
|
358
|
+
appendTreeNode(child, depth + 1, childrenWrap, activeFilters);
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const renderTree = (activeFilters) => {
|
|
364
|
+
treePanel.innerHTML = "";
|
|
365
|
+
if (!nodeMatches(data.root, activeFilters.query, activeFilters.minBytes)) {
|
|
366
|
+
const empty = document.createElement("div");
|
|
367
|
+
empty.className = "empty-state";
|
|
368
|
+
empty.textContent = "Sin resultados con los filtros actuales.";
|
|
369
|
+
treePanel.appendChild(empty);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
appendTreeNode(data.root, 0, treePanel, activeFilters);
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
const renderTopFiles = (activeFilters) => {
|
|
376
|
+
topFilesBody.innerHTML = "";
|
|
377
|
+
const filtered = (data.top_files || []).filter((item) => {
|
|
378
|
+
const pathMatch = !activeFilters.query || item.path.toLowerCase().includes(activeFilters.query);
|
|
379
|
+
return pathMatch && item.size >= activeFilters.minBytes;
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
if (!filtered.length) {
|
|
383
|
+
const emptyRow = document.createElement("tr");
|
|
384
|
+
const cell = document.createElement("td");
|
|
385
|
+
cell.colSpan = 3;
|
|
386
|
+
cell.className = "empty-state";
|
|
387
|
+
cell.textContent = "Sin archivos para mostrar con los filtros actuales.";
|
|
388
|
+
emptyRow.appendChild(cell);
|
|
389
|
+
topFilesBody.appendChild(emptyRow);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
filtered.forEach((item, index) => {
|
|
394
|
+
const row = document.createElement("tr");
|
|
395
|
+
|
|
396
|
+
const indexCell = document.createElement("td");
|
|
397
|
+
indexCell.textContent = String(index + 1);
|
|
398
|
+
row.appendChild(indexCell);
|
|
399
|
+
|
|
400
|
+
const sizeCell = document.createElement("td");
|
|
401
|
+
sizeCell.textContent = formatBytes(item.size);
|
|
402
|
+
row.appendChild(sizeCell);
|
|
403
|
+
|
|
404
|
+
const pathCell = document.createElement("td");
|
|
405
|
+
const link = document.createElement("a");
|
|
406
|
+
link.href = "file://" + encodeURI(item.path);
|
|
407
|
+
link.target = "_blank";
|
|
408
|
+
link.rel = "noreferrer";
|
|
409
|
+
link.textContent = item.path;
|
|
410
|
+
pathCell.appendChild(link);
|
|
411
|
+
row.appendChild(pathCell);
|
|
412
|
+
|
|
413
|
+
topFilesBody.appendChild(row);
|
|
414
|
+
});
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
const render = () => {
|
|
418
|
+
const activeFilters = filters();
|
|
419
|
+
renderTree(activeFilters);
|
|
420
|
+
renderTopFiles(activeFilters);
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
render();
|
|
424
|
+
})();
|
|
425
|
+
</script>
|
|
426
|
+
</body>
|
|
427
|
+
</html>
|
|
428
|
+
HTML
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
private
|
|
432
|
+
|
|
433
|
+
def payload
|
|
434
|
+
{
|
|
435
|
+
source_path: @source_path,
|
|
436
|
+
generated_at: @generated_at.iso8601,
|
|
437
|
+
stats: @result.stats,
|
|
438
|
+
root: @result.root,
|
|
439
|
+
top_files: @result.top_files
|
|
440
|
+
}
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
end
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FileTreeVisualizer
|
|
4
|
+
class Scanner
|
|
5
|
+
DEFAULT_TOP_LIMIT = 200
|
|
6
|
+
Result = Struct.new(:root, :top_files, :stats, keyword_init: true)
|
|
7
|
+
|
|
8
|
+
def initialize(top_limit: DEFAULT_TOP_LIMIT, follow_symlinks: false)
|
|
9
|
+
@top_limit = top_limit
|
|
10
|
+
@follow_symlinks = follow_symlinks
|
|
11
|
+
reset!
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def scan(path, progress: nil)
|
|
15
|
+
reset!
|
|
16
|
+
expanded_path = File.expand_path(path.to_s)
|
|
17
|
+
raise ArgumentError, "La ruta no es un directorio: #{expanded_path}" unless File.directory?(expanded_path)
|
|
18
|
+
|
|
19
|
+
@progress_callback = progress
|
|
20
|
+
notify_counting_progress(force: true)
|
|
21
|
+
totals = count_totals(expanded_path)
|
|
22
|
+
notify_counting_progress(force: true)
|
|
23
|
+
@total_files = totals[:files]
|
|
24
|
+
@total_directories = totals[:directories]
|
|
25
|
+
@processed_files = 0
|
|
26
|
+
@processed_directories = 0
|
|
27
|
+
notify_progress
|
|
28
|
+
|
|
29
|
+
root = scan_directory(expanded_path)
|
|
30
|
+
@processed_files = @total_files if @processed_files < @total_files
|
|
31
|
+
@processed_directories = @total_directories if @processed_directories < @total_directories
|
|
32
|
+
notify_progress
|
|
33
|
+
top_files = @top_files.sort_by { |item| -item[:size] }
|
|
34
|
+
Result.new(
|
|
35
|
+
root: root,
|
|
36
|
+
top_files: top_files,
|
|
37
|
+
stats: {
|
|
38
|
+
bytes: root[:size],
|
|
39
|
+
files: @files_count,
|
|
40
|
+
directories: @directories_count,
|
|
41
|
+
skipped: @skipped_count
|
|
42
|
+
}
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def reset!
|
|
49
|
+
@top_files = []
|
|
50
|
+
@files_count = 0
|
|
51
|
+
@directories_count = 0
|
|
52
|
+
@skipped_count = 0
|
|
53
|
+
@progress_callback = nil
|
|
54
|
+
@total_files = 0
|
|
55
|
+
@processed_files = 0
|
|
56
|
+
@total_directories = 0
|
|
57
|
+
@processed_directories = 0
|
|
58
|
+
@counted_files = 0
|
|
59
|
+
@counted_directories = 0
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def scan_directory(path)
|
|
63
|
+
directory_name = File.basename(path)
|
|
64
|
+
directory_name = path if directory_name.nil? || directory_name.empty?
|
|
65
|
+
|
|
66
|
+
node = {
|
|
67
|
+
name: directory_name,
|
|
68
|
+
path: path,
|
|
69
|
+
type: "directory",
|
|
70
|
+
size: 0,
|
|
71
|
+
children: []
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
@directories_count += 1
|
|
75
|
+
@processed_directories += 1
|
|
76
|
+
notify_progress
|
|
77
|
+
|
|
78
|
+
entries = Dir.children(path)
|
|
79
|
+
entries.each do |entry_name|
|
|
80
|
+
child_path = File.join(path, entry_name)
|
|
81
|
+
process_child_node(child_path, entry_name, node)
|
|
82
|
+
rescue StandardError
|
|
83
|
+
@skipped_count += 1
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
node[:children].sort_by! { |child| [-child[:size], child[:name].downcase] }
|
|
87
|
+
node
|
|
88
|
+
rescue SystemCallError
|
|
89
|
+
@skipped_count += 1
|
|
90
|
+
node[:error] = true
|
|
91
|
+
node
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def process_child_node(child_path, entry_name, parent_node)
|
|
95
|
+
if File.symlink?(child_path) && !@follow_symlinks
|
|
96
|
+
@skipped_count += 1
|
|
97
|
+
return
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
if File.directory?(child_path)
|
|
101
|
+
child_node = scan_directory(child_path)
|
|
102
|
+
parent_node[:children] << child_node
|
|
103
|
+
parent_node[:size] += child_node[:size]
|
|
104
|
+
elsif File.file?(child_path)
|
|
105
|
+
file_size = File.size(child_path)
|
|
106
|
+
file_node = {
|
|
107
|
+
name: entry_name,
|
|
108
|
+
path: child_path,
|
|
109
|
+
type: "file",
|
|
110
|
+
size: file_size
|
|
111
|
+
}
|
|
112
|
+
parent_node[:children] << file_node
|
|
113
|
+
parent_node[:size] += file_size
|
|
114
|
+
@files_count += 1
|
|
115
|
+
@processed_files += 1
|
|
116
|
+
notify_progress
|
|
117
|
+
remember_top_file(file_node)
|
|
118
|
+
else
|
|
119
|
+
@skipped_count += 1
|
|
120
|
+
end
|
|
121
|
+
rescue SystemCallError
|
|
122
|
+
@skipped_count += 1
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def remember_top_file(file_node)
|
|
126
|
+
@top_files << {
|
|
127
|
+
name: file_node[:name],
|
|
128
|
+
path: file_node[:path],
|
|
129
|
+
size: file_node[:size]
|
|
130
|
+
}
|
|
131
|
+
return unless @top_files.length > @top_limit
|
|
132
|
+
|
|
133
|
+
@top_files.sort_by! { |item| item[:size] }
|
|
134
|
+
overflow = @top_files.length - @top_limit
|
|
135
|
+
@top_files.shift(overflow)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def count_totals(path)
|
|
139
|
+
totals = { files: 0, directories: 1 }
|
|
140
|
+
@counted_directories += 1
|
|
141
|
+
notify_counting_progress
|
|
142
|
+
entries = Dir.children(path)
|
|
143
|
+
entries.each do |entry_name|
|
|
144
|
+
child_path = File.join(path, entry_name)
|
|
145
|
+
|
|
146
|
+
begin
|
|
147
|
+
if File.symlink?(child_path) && !@follow_symlinks
|
|
148
|
+
next
|
|
149
|
+
elsif File.directory?(child_path)
|
|
150
|
+
child_totals = count_totals(child_path)
|
|
151
|
+
totals[:files] += child_totals[:files]
|
|
152
|
+
totals[:directories] += child_totals[:directories]
|
|
153
|
+
elsif File.file?(child_path)
|
|
154
|
+
totals[:files] += 1
|
|
155
|
+
@counted_files += 1
|
|
156
|
+
notify_counting_progress
|
|
157
|
+
end
|
|
158
|
+
rescue SystemCallError
|
|
159
|
+
next
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
totals
|
|
164
|
+
rescue SystemCallError
|
|
165
|
+
totals
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def notify_progress
|
|
169
|
+
return unless @progress_callback
|
|
170
|
+
|
|
171
|
+
@progress_callback.call(
|
|
172
|
+
phase: :scanning,
|
|
173
|
+
processed_files: @processed_files,
|
|
174
|
+
total_files: @total_files,
|
|
175
|
+
processed_directories: @processed_directories,
|
|
176
|
+
total_directories: @total_directories
|
|
177
|
+
)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def notify_counting_progress(force: false)
|
|
181
|
+
return unless @progress_callback
|
|
182
|
+
return unless force || ((@counted_files + @counted_directories) % 200).zero?
|
|
183
|
+
|
|
184
|
+
@progress_callback.call(
|
|
185
|
+
phase: :counting,
|
|
186
|
+
discovered_files: @counted_files,
|
|
187
|
+
discovered_directories: @counted_directories
|
|
188
|
+
)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "file_tree_visualizer/version"
|
|
4
|
+
require_relative "file_tree_visualizer/scanner"
|
|
5
|
+
require_relative "file_tree_visualizer/report_builder"
|
|
6
|
+
require_relative "file_tree_visualizer/cli"
|
|
7
|
+
|
|
8
|
+
module FileTreeVisualizer
|
|
9
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: file_tree_visualizer
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.0.3
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- jbidwell1
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-04-04 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: Scans a directory recursively, computes file/directory sizes, and generates
|
|
14
|
+
an HTML report rendered with React.
|
|
15
|
+
email:
|
|
16
|
+
- devnull@example.com
|
|
17
|
+
executables:
|
|
18
|
+
- file-tree-visualizer
|
|
19
|
+
extensions: []
|
|
20
|
+
extra_rdoc_files: []
|
|
21
|
+
files:
|
|
22
|
+
- Gemfile
|
|
23
|
+
- README.md
|
|
24
|
+
- Rakefile
|
|
25
|
+
- bin/file-tree-visualizer
|
|
26
|
+
- file_tree_visualizer.gemspec
|
|
27
|
+
- lib/file_tree_visualizer.rb
|
|
28
|
+
- lib/file_tree_visualizer/cli.rb
|
|
29
|
+
- lib/file_tree_visualizer/report_builder.rb
|
|
30
|
+
- lib/file_tree_visualizer/scanner.rb
|
|
31
|
+
- lib/file_tree_visualizer/version.rb
|
|
32
|
+
homepage: https://example.com/file_tree_visualizer
|
|
33
|
+
licenses:
|
|
34
|
+
- MIT
|
|
35
|
+
metadata:
|
|
36
|
+
homepage_uri: https://example.com/file_tree_visualizer
|
|
37
|
+
source_code_uri: https://example.com/file_tree_visualizer
|
|
38
|
+
post_install_message:
|
|
39
|
+
rdoc_options: []
|
|
40
|
+
require_paths:
|
|
41
|
+
- lib
|
|
42
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '2.7'
|
|
47
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
48
|
+
requirements:
|
|
49
|
+
- - ">="
|
|
50
|
+
- !ruby/object:Gem::Version
|
|
51
|
+
version: '0'
|
|
52
|
+
requirements: []
|
|
53
|
+
rubygems_version: 3.5.22
|
|
54
|
+
signing_key:
|
|
55
|
+
specification_version: 4
|
|
56
|
+
summary: CLI Ruby tool to analyze file sizes and generate an interactive HTML report.
|
|
57
|
+
test_files: []
|