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 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gemspec
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,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/file_tree_visualizer"
5
+
6
+ exit(FileTreeVisualizer::CLI.new(ARGV).run)
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FileTreeVisualizer
4
+ VERSION = "0.0.3"
5
+ 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: []