file_tree_visualizer 0.0.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d9ec7a6ef5ea0597b5efc056da74912985e0ca2f9a784398c13aa83b5e95c5a6
4
- data.tar.gz: f3a9a8ee8fb984e8b135fefbe9db34b9e8f5df19ccf787538a754b975b5c9022
3
+ metadata.gz: b43d301f84619b7a620a0cbcef1d135d4906073efaecf03b737b43572ebd6d89
4
+ data.tar.gz: 65de782e8d5094d981f0bedd23e6307d1b6cd4eedebd6b8bbb412a9b303e330e
5
5
  SHA512:
6
- metadata.gz: f902440430610f789db16ab282b2890e3569fa354691ee74dd1587539ad299aa2b7db9c66890496fd46429e1369da7b5d84db95b6c1614977559ffbef3b99c68
7
- data.tar.gz: 9571ce07d3162b7e5477ae40f1a844c61e2ac476780e1665300778e1d811500b1b288e064b376e86a2f2305e1fad3e54a3a2dbbd80c106459f8638d4d96b835c
6
+ metadata.gz: f6a58da21e94d26fd62fa5a05bc5d0946a16525c187f021e7184b010515051f346b2c8786067879c278c34ca3abfbe61dc416f1a2bd170201654c71d477bffd0
7
+ data.tar.gz: ba3cb73876856ee460c66f1b34a61f1965cb6deda2021f52dba5904a8500b995e8cd4c28fd91bb1f388462a88c2d62e381024e94d1430dfaf591555344c674a8
data/Gemfile CHANGED
@@ -3,3 +3,5 @@
3
3
  source "https://rubygems.org"
4
4
 
5
5
  gemspec
6
+ gem "rake", "~> 13.0"
7
+ gem "minitest", "~> 5.20"
data/README.md CHANGED
@@ -1,44 +1,132 @@
1
1
  # file_tree_visualizer
2
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.
3
+ [![CI](https://github.com/JohnBidwellB/file-tree-visualizer/actions/workflows/release.yml/badge.svg)](https://github.com/JohnBidwellB/file-tree-visualizer/actions/workflows/release.yml)
4
+ [![RubyGems](https://badge.fury.io/rb/file_tree_visualizer.svg)](https://rubygems.org/gems/file_tree_visualizer)
4
5
 
5
- ## Qué hace
6
+ Ruby gem to scan file trees from the terminal and generate a fully offline interactive HTML report with the items that consume the most disk space.
6
7
 
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).
8
+ ## What it does
14
9
 
15
- ## Uso rápido (sin instalar la gema)
10
+ - Recursively scans a directory.
11
+ - Computes cumulative size per directory and size per file.
12
+ - Generates an HTML report with:
13
+ - Expand/collapse file tree.
14
+ - Text and minimum-size filters.
15
+ - Ranking of the largest files.
16
+ - No external dependencies (works offline).
17
+
18
+ ## Quick usage (without installing the gem)
19
+
20
+ ```bash
21
+ ruby bin/file-tree-visualizer /path/to/scan -o report.html
22
+ ```
23
+
24
+ ## Install from RubyGems
25
+
26
+ Gem page: https://rubygems.org/gems/file_tree_visualizer
16
27
 
17
28
  ```bash
18
- ruby bin/file-tree-visualizer /ruta/a/escanear -o reporte.html
29
+ gem install file_tree_visualizer
30
+ file-tree-visualizer /path/to/scan -o report.html
19
31
  ```
20
32
 
21
- ## Uso como gema
33
+ Install a specific version:
34
+
35
+ ```bash
36
+ gem install file_tree_visualizer -v 0.2.0
37
+ ```
38
+
39
+ ## Local development install
22
40
 
23
41
  ```bash
24
42
  bundle install
25
43
  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
44
+ gem install ./file_tree_visualizer-0.2.0.gem
45
+ file-tree-visualizer /path/to/scan -o report.html
46
+ ```
47
+
48
+ ## Options
49
+
50
+ ```bash
51
+ file-tree-visualizer [path] [options]
52
+
53
+ If no `path` is provided, the current directory is scanned by default.
54
+
55
+ During scanning, an interactive terminal shows a progress bar with processed files/directories vs discovered totals.
56
+
57
+ -o, --output FILE Output HTML file (default: file-tree-report.html)
58
+ -t, --top N Number of largest files to keep in ranking (default: 200)
59
+ --follow-symlinks Follow symbolic links
60
+ --no-progress Disable progress bar
61
+ --exclude PATTERNS Exclude patterns (comma-separated or repeated)
62
+ --summary Print summary to terminal
63
+ --json FILE Export full result as JSON
64
+ --csv FILE Export top files as CSV
65
+ --lang LANG Language for CLI and report (en|es)
66
+ -h, --help Show help
67
+
68
+ Language resolution priority:
69
+ - `--lang`
70
+ - `LANG` environment variable (for example `es_CL.UTF-8`)
71
+ - fallback to English (`en`)
28
72
  ```
29
73
 
30
- ## Opciones
74
+ ## Common workflows
75
+
76
+ Summary only (no HTML file generated unless `--output` is explicitly provided):
31
77
 
32
78
  ```bash
33
- file-tree-visualizer [ruta] [opciones]
79
+ file-tree-visualizer --summary
80
+ ```
34
81
 
35
- Si no indicas `ruta`, se escanea el directorio actual por defecto.
82
+ Summary + HTML report:
36
83
 
37
- Durante el escaneo, en terminal interactiva se muestra una barra de progreso con archivos y directorios procesados vs total detectado.
84
+ ```bash
85
+ file-tree-visualizer . --summary --output report.html
86
+ ```
38
87
 
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
88
+ Exclude directories and files by pattern:
89
+
90
+ ```bash
91
+ file-tree-visualizer . --exclude "node_modules,.git,*.log" --exclude "tmp/**"
92
+ ```
93
+
94
+ Export JSON and CSV in one run:
95
+
96
+ ```bash
97
+ file-tree-visualizer . --summary --json scan.json --csv top-files.csv
98
+ ```
99
+
100
+ ## Contributing
101
+
102
+ 1. Create a feature branch from `main`.
103
+ 2. Implement your change and run tests locally.
104
+ 3. Push your branch and open a Pull Request.
105
+ 4. Wait for CI to pass and at least one approval.
106
+
107
+ ```bash
108
+ git checkout -b feat/short-description
109
+ bundle exec rake test
110
+ git push origin feat/short-description
111
+ ```
112
+
113
+ Branch policy:
114
+ - For collaborators, `main` is protected and requires PR + CI + approval.
115
+ - Repository admins can bypass these rules when needed.
116
+
117
+ ## Automatic release to RubyGems
118
+
119
+ - This repository publishes the gem automatically when pushing a tag with format `v*` (for example `v0.2.0`).
120
+ - Before using it, configure the `RUBYGEMS_API_KEY` secret in GitHub Actions.
121
+ - You can also trigger the workflow manually from GitHub Actions (`workflow_dispatch`).
122
+
123
+ ```bash
124
+ git tag v0.2.0
125
+ git push origin v0.2.0
126
+ ```
127
+
128
+ Reusable release notes template:
129
+
130
+ ```bash
131
+ gh release create v0.2.0 --title "v0.2.0" --notes-file .github/release-template.md
44
132
  ```
data/Rakefile CHANGED
@@ -1,3 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test"
8
+ t.pattern = "test/**/*_test.rb"
9
+ end
10
+
11
+ task default: :test
@@ -1,33 +1,35 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "lib/file_tree_visualizer/version"
3
+ require_relative 'lib/file_tree_visualizer/version'
4
4
 
5
5
  Gem::Specification.new do |spec|
6
- spec.name = "file_tree_visualizer"
6
+ spec.name = 'file_tree_visualizer'
7
7
  spec.version = FileTreeVisualizer::VERSION
8
- spec.authors = ["jbidwell1"]
9
- spec.email = ["devnull@example.com"]
8
+ spec.authors = ['John Bidwell']
9
+ spec.email = ['johnbidwellb@gmail.com']
10
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"
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://github.com/JohnBidwellB/file-tree-visualizer'
14
+ spec.license = 'MIT'
15
+ spec.required_ruby_version = '>= 2.7'
16
16
 
17
- spec.metadata["homepage_uri"] = spec.homepage
18
- spec.metadata["source_code_uri"] = spec.homepage
17
+ spec.metadata['source_code_uri'] = "#{spec.homepage}/tree/main"
18
+ spec.metadata['bug_tracker_uri'] = "#{spec.homepage}/issues"
19
+ spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/main/CHANGELOG.md"
19
20
 
20
21
  spec.files = Dir.chdir(__dir__) do
21
22
  Dir[
22
- "README.md",
23
- "Gemfile",
24
- "Rakefile",
25
- "bin/*",
26
- "lib/**/*.rb",
27
- "file_tree_visualizer.gemspec"
23
+ 'README.md',
24
+ 'Gemfile',
25
+ 'Rakefile',
26
+ 'bin/*',
27
+ 'lib/**/*.rb',
28
+ 'file_tree_visualizer.gemspec'
28
29
  ]
29
30
  end
30
- spec.bindir = "bin"
31
- spec.executables = ["file-tree-visualizer"]
32
- spec.require_paths = ["lib"]
31
+ spec.bindir = 'bin'
32
+ spec.executables = ['file-tree-visualizer']
33
+ spec.require_paths = ['lib']
34
+ spec.add_dependency 'csv', '~> 3.3'
33
35
  end
@@ -1,42 +1,64 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "optparse"
3
+ require 'optparse'
4
+ require 'csv'
5
+ require 'json'
4
6
 
5
7
  module FileTreeVisualizer
6
8
  class CLI
7
- DEFAULT_OUTPUT = "file-tree-report.html"
9
+ DEFAULT_OUTPUT = 'file-tree-report.html'
10
+ DEFAULT_LOCALE = 'en'
8
11
 
9
12
  def initialize(argv)
10
13
  @argv = argv
11
14
  end
12
15
 
13
16
  def run
14
- options, parser = parse_options(@argv.dup)
17
+ options, parser, locale = parse_options(@argv.dup)
15
18
  if options[:help]
16
19
  puts parser
17
20
  return 0
18
21
  end
19
22
 
20
- scanner = Scanner.new(top_limit: options[:top], follow_symlinks: options[:follow_symlinks])
21
- progress = ProgressRenderer.new(enabled: options[:progress])
23
+ expanded_path = File.expand_path(options[:path])
24
+ unless File.directory?(expanded_path)
25
+ raise ArgumentError,
26
+ t(locale, 'cli.errors.invalid_directory', path: expanded_path)
27
+ end
28
+
29
+ scanner = Scanner.new(
30
+ top_limit: options[:top],
31
+ follow_symlinks: options[:follow_symlinks],
32
+ excludes: options[:exclude_patterns]
33
+ )
34
+ progress = ProgressRenderer.new(enabled: options[:progress], locale: locale)
22
35
  callback = options[:progress] ? progress.method(:render) : nil
23
- result = scanner.scan(options[:path], progress: callback)
36
+ result = scanner.scan(expanded_path, progress: callback)
24
37
  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])}"
38
+ generated_at = Time.now.utc
39
+ output_path = maybe_write_report(options, expanded_path, result, locale, generated_at)
40
+ json_path = write_json_export(options[:json_output], expanded_path, result, locale, generated_at)
41
+ csv_path = write_csv_export(options[:csv_output], result)
42
+ print_summary(expanded_path, result, locale) if options[:summary]
43
+ puts t(locale, 'cli.messages.report_generated', path: output_path) if output_path
44
+ puts t(locale, 'cli.messages.json_generated', path: json_path) if json_path
45
+ puts t(locale, 'cli.messages.csv_generated', path: csv_path) if csv_path
46
+ unless options[:summary]
47
+ puts t(locale, 'cli.messages.scanned',
48
+ directories: result.stats[:directories],
49
+ files: result.stats[:files],
50
+ skipped: result.stats[:skipped])
51
+ end
52
+ puts t(locale, 'cli.messages.total_size', size: format_bytes(result.stats[:bytes])) unless options[:summary]
33
53
  0
34
54
  rescue OptionParser::ParseError, ArgumentError => e
55
+ locale = error_locale
35
56
  warn e.message
36
- warn "Usa --help para ver las opciones disponibles."
57
+ warn t(locale, 'cli.errors.use_help')
37
58
  1
38
59
  rescue StandardError => e
39
- warn "Error inesperado: #{e.message}"
60
+ locale = error_locale
61
+ warn t(locale, 'cli.errors.unexpected', message: e.message)
40
62
  1
41
63
  end
42
64
 
@@ -45,9 +67,10 @@ module FileTreeVisualizer
45
67
  class ProgressRenderer
46
68
  BAR_WIDTH = 28
47
69
 
48
- def initialize(io: $stdout, enabled: true)
70
+ def initialize(io: $stdout, enabled: true, locale: DEFAULT_LOCALE)
49
71
  @io = io
50
72
  @enabled = enabled && io.tty?
73
+ @locale = locale
51
74
  @last_draw_at = nil
52
75
  @has_rendered = false
53
76
  @spinner_index = 0
@@ -82,7 +105,7 @@ module FileTreeVisualizer
82
105
  def render_counting(data)
83
106
  spinner = %w[| / - \\][@spinner_index % 4]
84
107
  @spinner_index += 1
85
- @io.print(format("\rPreparando escaneo %<spinner>s Arch:%<files>d Dir:%<dirs>d",
108
+ @io.print(format("\r#{I18n.t(@locale, 'cli.progress.preparing')}",
86
109
  spinner: spinner,
87
110
  files: data.fetch(:discovered_files, 0),
88
111
  dirs: data.fetch(:discovered_directories, 0)))
@@ -102,9 +125,9 @@ module FileTreeVisualizer
102
125
  empty = BAR_WIDTH - filled
103
126
  percent = progress_ratio * 100
104
127
 
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,
128
+ @io.print(format("\r#{I18n.t(@locale, 'cli.progress.scanning')}",
129
+ bar: ('#' * filled) + ('-' * empty),
130
+ percent: format('%6.2f', percent),
108
131
  pf: processed_files,
109
132
  tf: total_files,
110
133
  pd: processed_directories,
@@ -113,46 +136,110 @@ module FileTreeVisualizer
113
136
  end
114
137
 
115
138
  def parse_options(argv)
139
+ locale = I18n.resolve_locale(cli_value: extract_lang_argument(argv), env_lang: ENV['LANG'])
116
140
  options = {
117
141
  path: Dir.pwd,
118
142
  output: DEFAULT_OUTPUT,
119
143
  top: Scanner::DEFAULT_TOP_LIMIT,
120
144
  follow_symlinks: false,
121
145
  progress: true,
146
+ summary: false,
147
+ output_given: false,
148
+ json_output: nil,
149
+ csv_output: nil,
150
+ exclude_patterns: [],
151
+ lang: nil,
122
152
  help: false
123
153
  }
124
154
 
125
- parser = OptionParser.new do |opts|
126
- opts.banner = "Uso: file-tree-visualizer [ruta] [opciones]"
127
- opts.separator ""
128
- opts.separator "Opciones:"
155
+ parser = build_parser(locale, options)
156
+ remaining = parser.parse(argv)
157
+
158
+ if options[:lang] && !I18n.supported_locale?(options[:lang])
159
+ raise ArgumentError, t(locale,
160
+ 'cli.errors.invalid_lang',
161
+ lang: options[:lang],
162
+ supported: I18n::SUPPORTED_LOCALES.join(', '))
163
+ end
164
+
165
+ locale = I18n.resolve_locale(cli_value: options[:lang], env_lang: ENV['LANG'])
166
+ parser = build_parser(locale, options)
129
167
 
130
- opts.on("-o", "--output FILE", "Archivo HTML de salida (default: #{DEFAULT_OUTPUT})") do |value|
168
+ options[:path] = File.expand_path(remaining.first || options[:path])
169
+ raise ArgumentError, t(locale, 'cli.errors.invalid_top') unless options[:top].positive?
170
+
171
+ [options, parser, locale]
172
+ end
173
+
174
+ def build_parser(locale, options)
175
+ OptionParser.new do |opts|
176
+ opts.banner = t(locale, 'cli.banner')
177
+ opts.separator ''
178
+ opts.separator t(locale, 'cli.options_header')
179
+
180
+ opts.on('-o', '--output FILE', t(locale, 'cli.output_option', default: DEFAULT_OUTPUT)) do |value|
131
181
  options[:output] = value
182
+ options[:output_given] = true
132
183
  end
133
184
 
134
- opts.on("-t", "--top N", Integer, "Cantidad de archivos pesados en ranking (default: #{Scanner::DEFAULT_TOP_LIMIT})") do |value|
185
+ opts.on('-t', '--top N', Integer, t(locale, 'cli.top_option', default: Scanner::DEFAULT_TOP_LIMIT)) do |value|
135
186
  options[:top] = value
136
187
  end
137
188
 
138
- opts.on("--follow-symlinks", "Sigue enlaces simbólicos durante el escaneo") do
189
+ opts.on('--follow-symlinks', t(locale, 'cli.follow_symlinks_option')) do
139
190
  options[:follow_symlinks] = true
140
191
  end
141
192
 
142
- opts.on("--no-progress", "Desactiva la barra de progreso") do
193
+ opts.on('--no-progress', t(locale, 'cli.no_progress_option')) do
143
194
  options[:progress] = false
144
195
  end
145
196
 
146
- opts.on("-h", "--help", "Muestra esta ayuda") do
197
+ opts.on('--exclude PATTERNS', t(locale, 'cli.exclude_option')) do |value|
198
+ options[:exclude_patterns].concat(split_patterns(value))
199
+ end
200
+
201
+ opts.on('--summary', t(locale, 'cli.summary_option')) do
202
+ options[:summary] = true
203
+ end
204
+
205
+ opts.on('--json FILE', t(locale, 'cli.json_option')) do |value|
206
+ options[:json_output] = value
207
+ end
208
+
209
+ opts.on('--csv FILE', t(locale, 'cli.csv_option')) do |value|
210
+ options[:csv_output] = value
211
+ end
212
+
213
+ opts.on('--lang LANG', t(locale, 'cli.lang_option')) do |value|
214
+ options[:lang] = value
215
+ end
216
+
217
+ opts.on('-h', '--help', t(locale, 'cli.help_option')) do
147
218
  options[:help] = true
148
219
  end
149
220
  end
221
+ end
222
+
223
+ def extract_lang_argument(argv)
224
+ argv.each_with_index do |arg, index|
225
+ return arg.split('=', 2).last if arg.start_with?('--lang=')
226
+ next unless arg == '--lang'
227
+
228
+ return argv[index + 1]
229
+ end
230
+ nil
231
+ end
150
232
 
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?
233
+ def split_patterns(value)
234
+ value.to_s.split(',').map(&:strip).reject(&:empty?)
235
+ end
154
236
 
155
- [options, parser]
237
+ def t(locale, key, **vars)
238
+ I18n.t(locale, key, **vars)
239
+ end
240
+
241
+ def error_locale
242
+ I18n.resolve_locale(cli_value: extract_lang_argument(@argv), env_lang: ENV['LANG'])
156
243
  end
157
244
 
158
245
  def format_bytes(bytes)
@@ -170,5 +257,72 @@ module FileTreeVisualizer
170
257
  precision = value < 10 ? 2 : 1
171
258
  format("%.#{precision}f %s", value, units[unit_index])
172
259
  end
260
+
261
+ def maybe_write_report(options, source_path, result, locale, generated_at)
262
+ should_write = !options[:summary] || options[:output_given]
263
+ return nil unless should_write
264
+
265
+ report = ReportBuilder.new(
266
+ result: result,
267
+ source_path: source_path,
268
+ locale: locale,
269
+ generated_at: generated_at
270
+ ).build
271
+ output_path = File.expand_path(options[:output])
272
+ File.write(output_path, report)
273
+ output_path
274
+ end
275
+
276
+ def write_json_export(target_path, source_path, result, locale, generated_at)
277
+ return nil unless target_path
278
+
279
+ output_path = File.expand_path(target_path)
280
+ payload = {
281
+ source_path: source_path,
282
+ generated_at: generated_at.iso8601,
283
+ locale: locale,
284
+ version: FileTreeVisualizer::VERSION,
285
+ stats: result.stats,
286
+ root: result.root,
287
+ top_files: result.top_files
288
+ }
289
+ File.write(output_path, JSON.pretty_generate(payload))
290
+ output_path
291
+ end
292
+
293
+ def write_csv_export(target_path, result)
294
+ return nil unless target_path
295
+
296
+ output_path = File.expand_path(target_path)
297
+ CSV.open(output_path, 'w') do |csv|
298
+ csv << %w[rank path name size_bytes size_human]
299
+ result.top_files.each_with_index do |item, index|
300
+ csv << [index + 1, item[:path], item[:name], item[:size], format_bytes(item[:size])]
301
+ end
302
+ end
303
+ output_path
304
+ end
305
+
306
+ def print_summary(source_path, result, locale)
307
+ puts t(locale, 'cli.summary.heading')
308
+ puts t(locale, 'cli.summary.source', path: source_path)
309
+ puts t(locale, 'cli.summary.total_size', size: format_bytes(result.stats[:bytes]))
310
+ puts t(locale, 'cli.summary.files', count: result.stats[:files])
311
+ puts t(locale, 'cli.summary.directories', count: result.stats[:directories])
312
+ puts t(locale, 'cli.summary.skipped', count: result.stats[:skipped])
313
+ puts t(locale, 'cli.summary.top_files')
314
+
315
+ if result.top_files.empty?
316
+ puts " #{t(locale, 'cli.summary.none')}"
317
+ return
318
+ end
319
+
320
+ result.top_files.each_with_index do |item, index|
321
+ puts format(' %<rank>d. %<size>s %<path>s',
322
+ rank: index + 1,
323
+ size: format_bytes(item[:size]),
324
+ path: item[:path])
325
+ end
326
+ end
173
327
  end
174
328
  end
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FileTreeVisualizer
4
+ module I18n
5
+ SUPPORTED_LOCALES = %w[en es].freeze
6
+
7
+ TRANSLATIONS = {
8
+ 'en' => {
9
+ 'cli' => {
10
+ 'banner' => 'Usage: file-tree-visualizer [path] [options]',
11
+ 'options_header' => 'Options:',
12
+ 'output_option' => 'Output HTML file (default: %<default>s)',
13
+ 'top_option' => 'Number of largest files in ranking (default: %<default>s)',
14
+ 'follow_symlinks_option' => 'Follow symbolic links during scan',
15
+ 'no_progress_option' => 'Disable progress bar',
16
+ 'exclude_option' => 'Exclude patterns (comma-separated or repeated)',
17
+ 'summary_option' => 'Print terminal summary',
18
+ 'json_option' => 'Export scan result as JSON',
19
+ 'csv_option' => 'Export top files as CSV',
20
+ 'lang_option' => 'Language for CLI and report (en|es)',
21
+ 'help_option' => 'Show this help',
22
+ 'messages' => {
23
+ 'report_generated' => 'Report generated at: %<path>s',
24
+ 'json_generated' => 'JSON exported at: %<path>s',
25
+ 'csv_generated' => 'CSV exported at: %<path>s',
26
+ 'scanned' => 'Scanned: %<directories>s directories, %<files>s files, %<skipped>s skipped.',
27
+ 'total_size' => 'Total size: %<size>s'
28
+ },
29
+ 'errors' => {
30
+ 'invalid_top' => '--top must be greater than 0',
31
+ 'invalid_lang' => "Unsupported language '%<lang>s'. Supported values: %<supported>s",
32
+ 'invalid_directory' => 'Path is not a directory: %<path>s',
33
+ 'use_help' => 'Use --help to see available options.',
34
+ 'unexpected' => 'Unexpected error: %<message>s'
35
+ },
36
+ 'progress' => {
37
+ 'preparing' => 'Preparing scan %<spinner>s Files:%<files>s Dirs:%<dirs>s',
38
+ 'scanning' => 'Scanning [%<bar>s] %<percent>s%% Files:%<pf>s/%<tf>s Dirs:%<pd>s/%<td>s'
39
+ },
40
+ 'summary' => {
41
+ 'heading' => 'Summary',
42
+ 'source' => 'Source: %<path>s',
43
+ 'total_size' => 'Total size: %<size>s',
44
+ 'files' => 'Files: %<count>s',
45
+ 'directories' => 'Directories: %<count>s',
46
+ 'skipped' => 'Skipped entries: %<count>s',
47
+ 'top_files' => 'Top files:',
48
+ 'none' => '(none)'
49
+ }
50
+ },
51
+ 'report' => {
52
+ 'page_title' => 'File Report',
53
+ 'header_title' => 'Disk usage report',
54
+ 'meta_source_prefix' => 'Source: ',
55
+ 'meta_generated_prefix' => 'Generated: ',
56
+ 'stats' => {
57
+ 'total_size' => 'Total size',
58
+ 'files' => 'Files',
59
+ 'directories' => 'Directories',
60
+ 'skipped' => 'Skipped entries'
61
+ },
62
+ 'filters' => {
63
+ 'search_label' => 'Search by name/path',
64
+ 'search_placeholder' => 'eg: node_modules, .mp4, backup',
65
+ 'min_size_label' => 'Minimum size (MB)',
66
+ 'min_size_hint' => 'Filters tree nodes and ranking by minimum size.'
67
+ },
68
+ 'panels' => {
69
+ 'tree' => 'File tree',
70
+ 'top_files' => 'Top largest files'
71
+ },
72
+ 'table' => {
73
+ 'index' => '#',
74
+ 'size' => 'Size',
75
+ 'path' => 'Path'
76
+ },
77
+ 'empty' => {
78
+ 'tree' => 'No results with current filters.',
79
+ 'top_files' => 'No files to show with current filters.'
80
+ }
81
+ }
82
+ },
83
+ 'es' => {
84
+ 'cli' => {
85
+ 'banner' => 'Uso: file-tree-visualizer [ruta] [opciones]',
86
+ 'options_header' => 'Opciones:',
87
+ 'output_option' => 'Archivo HTML de salida (default: %<default>s)',
88
+ 'top_option' => 'Cantidad de archivos pesados en ranking (default: %<default>s)',
89
+ 'follow_symlinks_option' => 'Sigue enlaces simbólicos durante el escaneo',
90
+ 'no_progress_option' => 'Desactiva la barra de progreso',
91
+ 'exclude_option' => 'Excluye patrones (separados por coma o repetidos)',
92
+ 'summary_option' => 'Imprime resumen en terminal',
93
+ 'json_option' => 'Exporta resultado del escaneo como JSON',
94
+ 'csv_option' => 'Exporta top de archivos como CSV',
95
+ 'lang_option' => 'Idioma para CLI y reporte (en|es)',
96
+ 'help_option' => 'Muestra esta ayuda',
97
+ 'messages' => {
98
+ 'report_generated' => 'Reporte generado en: %<path>s',
99
+ 'json_generated' => 'JSON exportado en: %<path>s',
100
+ 'csv_generated' => 'CSV exportado en: %<path>s',
101
+ 'scanned' => 'Escaneado: %<directories>s directorios, %<files>s archivos, %<skipped>s omitidos.',
102
+ 'total_size' => 'Tamano total: %<size>s'
103
+ },
104
+ 'errors' => {
105
+ 'invalid_top' => '--top debe ser mayor que 0',
106
+ 'invalid_lang' => "Idioma no soportado '%<lang>s'. Valores permitidos: %<supported>s",
107
+ 'invalid_directory' => 'La ruta no es un directorio: %<path>s',
108
+ 'use_help' => 'Usa --help para ver las opciones disponibles.',
109
+ 'unexpected' => 'Error inesperado: %<message>s'
110
+ },
111
+ 'progress' => {
112
+ 'preparing' => 'Preparando escaneo %<spinner>s Arch:%<files>s Dir:%<dirs>s',
113
+ 'scanning' => 'Escaneando [%<bar>s] %<percent>s%% Arch:%<pf>s/%<tf>s Dir:%<pd>s/%<td>s'
114
+ },
115
+ 'summary' => {
116
+ 'heading' => 'Resumen',
117
+ 'source' => 'Origen: %<path>s',
118
+ 'total_size' => 'Tamano total: %<size>s',
119
+ 'files' => 'Archivos: %<count>s',
120
+ 'directories' => 'Directorios: %<count>s',
121
+ 'skipped' => 'Entradas omitidas: %<count>s',
122
+ 'top_files' => 'Top archivos:',
123
+ 'none' => '(ninguno)'
124
+ }
125
+ },
126
+ 'report' => {
127
+ 'page_title' => 'Reporte de archivos',
128
+ 'header_title' => 'Reporte de espacio en disco',
129
+ 'meta_source_prefix' => 'Origen: ',
130
+ 'meta_generated_prefix' => 'Generado: ',
131
+ 'stats' => {
132
+ 'total_size' => 'Tamano total',
133
+ 'files' => 'Archivos',
134
+ 'directories' => 'Directorios',
135
+ 'skipped' => 'Entradas omitidas'
136
+ },
137
+ 'filters' => {
138
+ 'search_label' => 'Buscar por nombre/ruta',
139
+ 'search_placeholder' => 'ej: node_modules, .mp4, backup',
140
+ 'min_size_label' => 'Tamano minimo (MB)',
141
+ 'min_size_hint' => 'Filtra nodos y ranking por tamano minimo.'
142
+ },
143
+ 'panels' => {
144
+ 'tree' => 'Arbol de archivos',
145
+ 'top_files' => 'Top archivos mas pesados'
146
+ },
147
+ 'table' => {
148
+ 'index' => '#',
149
+ 'size' => 'Tamano',
150
+ 'path' => 'Ruta'
151
+ },
152
+ 'empty' => {
153
+ 'tree' => 'Sin resultados con los filtros actuales.',
154
+ 'top_files' => 'Sin archivos para mostrar con los filtros actuales.'
155
+ }
156
+ }
157
+ }
158
+ }.freeze
159
+
160
+ module_function
161
+
162
+ def supported_locale?(value)
163
+ SUPPORTED_LOCALES.include?(normalize_locale(value))
164
+ end
165
+
166
+ def resolve_locale(cli_value: nil, env_lang: nil)
167
+ normalize_locale(cli_value) || normalize_locale(env_lang) || 'en'
168
+ end
169
+
170
+ def normalize_locale(value)
171
+ return nil if value.nil?
172
+
173
+ normalized = value.to_s.strip.downcase
174
+ return nil if normalized.empty?
175
+
176
+ return 'es' if normalized.start_with?('es')
177
+ return 'en' if normalized.start_with?('en')
178
+
179
+ nil
180
+ end
181
+
182
+ def t(locale, key, **vars)
183
+ template = lookup(locale, key) || lookup('en', key)
184
+ return key unless template.is_a?(String)
185
+
186
+ return template if vars.empty?
187
+
188
+ template % vars.transform_keys(&:to_sym)
189
+ rescue KeyError
190
+ template
191
+ end
192
+
193
+ def lookup(locale, key)
194
+ current = TRANSLATIONS[locale]
195
+ return nil unless current
196
+
197
+ key.to_s.split('.').each do |part|
198
+ return nil unless current.is_a?(Hash)
199
+
200
+ current = current[part]
201
+ end
202
+ current
203
+ end
204
+ end
205
+ end
@@ -1,25 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
- require "time"
3
+ require 'json'
4
+ require 'time'
5
5
 
6
6
  module FileTreeVisualizer
7
7
  class ReportBuilder
8
- def initialize(result:, source_path:, generated_at: Time.now.utc)
8
+ def initialize(result:, source_path:, generated_at: Time.now.utc, locale: 'en')
9
9
  @result = result
10
10
  @source_path = File.expand_path(source_path.to_s)
11
11
  @generated_at = generated_at
12
+ @locale = I18n.resolve_locale(cli_value: locale)
12
13
  end
13
14
 
14
15
  def build
15
- payload_json = JSON.generate(payload).gsub("</", "<\\/")
16
+ payload_json = JSON.generate(payload).gsub('</', '<\\/')
17
+ translations_json = JSON.generate(translation_payload).gsub('</', '<\\/')
16
18
  <<~HTML
17
19
  <!doctype html>
18
- <html lang="es">
20
+ <html lang="#{@locale}">
19
21
  <head>
20
22
  <meta charset="utf-8" />
21
23
  <meta name="viewport" content="width=device-width, initial-scale=1" />
22
- <title>Reporte de archivos</title>
24
+ <title>#{t('report.page_title')}</title>
23
25
  <style>
24
26
  :root {
25
27
  color-scheme: dark;
@@ -215,6 +217,7 @@ module FileTreeVisualizer
215
217
  <script>
216
218
  (function () {
217
219
  const data = JSON.parse(document.getElementById("scan-data").textContent);
220
+ const i18n = #{translations_json};
218
221
  const app = document.getElementById("app");
219
222
 
220
223
  const state = {
@@ -256,31 +259,31 @@ module FileTreeVisualizer
256
259
  '<div class="container">',
257
260
  ' <header class="header">',
258
261
  ' <div>',
259
- ' <h1>Reporte de espacio en disco</h1>',
262
+ ' <h1>' + i18n.headerTitle + '</h1>',
260
263
  ' <div class="meta" id="meta-source"></div>',
261
264
  ' <div class="meta" id="meta-generated"></div>',
262
265
  ' </div>',
263
266
  ' </header>',
264
267
  ' <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>',
268
+ ' <div class="stat"><div class="stat-label">' + i18n.statsTotalSize + '</div><div class="stat-value" id="stat-bytes"></div></div>',
269
+ ' <div class="stat"><div class="stat-label">' + i18n.statsFiles + '</div><div class="stat-value" id="stat-files"></div></div>',
270
+ ' <div class="stat"><div class="stat-label">' + i18n.statsDirectories + '</div><div class="stat-value" id="stat-directories"></div></div>',
271
+ ' <div class="stat"><div class="stat-label">' + i18n.statsSkipped + '</div><div class="stat-value" id="stat-skipped"></div></div>',
269
272
  ' </section>',
270
273
  ' <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>',
274
+ ' <label>' + i18n.searchLabel + '<input id="query-input" type="text" placeholder="' + i18n.searchPlaceholder + '" /></label>',
275
+ ' <label>' + i18n.minSizeLabel + '<input id="min-size-input" type="number" min="0" value="0" /><span class="hint">' + i18n.minSizeHint + '</span></label>',
273
276
  ' </section>',
274
277
  ' <section class="layout">',
275
278
  ' <article class="panel">',
276
- ' <h2>Árbol de archivos</h2>',
279
+ ' <h2>' + i18n.treeTitle + '</h2>',
277
280
  ' <div class="panel-body" id="tree-panel"></div>',
278
281
  ' </article>',
279
282
  ' <article class="panel">',
280
- ' <h2>Top archivos más pesados</h2>',
283
+ ' <h2>' + i18n.topFilesTitle + '</h2>',
281
284
  ' <div class="panel-body">',
282
285
  ' <table>',
283
- ' <thead><tr><th>#</th><th>Tamaño</th><th>Ruta</th></tr></thead>',
286
+ ' <thead><tr><th>' + i18n.tableIndex + '</th><th>' + i18n.tableSize + '</th><th>' + i18n.tablePath + '</th></tr></thead>',
284
287
  ' <tbody id="top-files-body"></tbody>',
285
288
  ' </table>',
286
289
  ' </div>',
@@ -294,8 +297,8 @@ module FileTreeVisualizer
294
297
  const treePanel = document.getElementById("tree-panel");
295
298
  const topFilesBody = document.getElementById("top-files-body");
296
299
 
297
- document.getElementById("meta-source").textContent = "Origen: " + data.source_path;
298
- document.getElementById("meta-generated").textContent = "Generado: " + data.generated_at;
300
+ document.getElementById("meta-source").textContent = i18n.metaSourcePrefix + data.source_path;
301
+ document.getElementById("meta-generated").textContent = i18n.metaGeneratedPrefix + data.generated_at;
299
302
  document.getElementById("stat-bytes").textContent = formatBytes(data.stats.bytes);
300
303
  document.getElementById("stat-files").textContent = formatNumber(data.stats.files);
301
304
  document.getElementById("stat-directories").textContent = formatNumber(data.stats.directories);
@@ -365,7 +368,7 @@ module FileTreeVisualizer
365
368
  if (!nodeMatches(data.root, activeFilters.query, activeFilters.minBytes)) {
366
369
  const empty = document.createElement("div");
367
370
  empty.className = "empty-state";
368
- empty.textContent = "Sin resultados con los filtros actuales.";
371
+ empty.textContent = i18n.emptyTree;
369
372
  treePanel.appendChild(empty);
370
373
  return;
371
374
  }
@@ -384,7 +387,7 @@ module FileTreeVisualizer
384
387
  const cell = document.createElement("td");
385
388
  cell.colSpan = 3;
386
389
  cell.className = "empty-state";
387
- cell.textContent = "Sin archivos para mostrar con los filtros actuales.";
390
+ cell.textContent = i18n.emptyTopFiles;
388
391
  emptyRow.appendChild(cell);
389
392
  topFilesBody.appendChild(emptyRow);
390
393
  return;
@@ -439,5 +442,32 @@ module FileTreeVisualizer
439
442
  top_files: @result.top_files
440
443
  }
441
444
  end
445
+
446
+ def translation_payload
447
+ {
448
+ headerTitle: t('report.header_title'),
449
+ metaSourcePrefix: t('report.meta_source_prefix'),
450
+ metaGeneratedPrefix: t('report.meta_generated_prefix'),
451
+ statsTotalSize: t('report.stats.total_size'),
452
+ statsFiles: t('report.stats.files'),
453
+ statsDirectories: t('report.stats.directories'),
454
+ statsSkipped: t('report.stats.skipped'),
455
+ searchLabel: t('report.filters.search_label'),
456
+ searchPlaceholder: t('report.filters.search_placeholder'),
457
+ minSizeLabel: t('report.filters.min_size_label'),
458
+ minSizeHint: t('report.filters.min_size_hint'),
459
+ treeTitle: t('report.panels.tree'),
460
+ topFilesTitle: t('report.panels.top_files'),
461
+ tableIndex: t('report.table.index'),
462
+ tableSize: t('report.table.size'),
463
+ tablePath: t('report.table.path'),
464
+ emptyTree: t('report.empty.tree'),
465
+ emptyTopFiles: t('report.empty.top_files')
466
+ }
467
+ end
468
+
469
+ def t(key)
470
+ I18n.t(@locale, key)
471
+ end
442
472
  end
443
473
  end
@@ -5,16 +5,19 @@ module FileTreeVisualizer
5
5
  DEFAULT_TOP_LIMIT = 200
6
6
  Result = Struct.new(:root, :top_files, :stats, keyword_init: true)
7
7
 
8
- def initialize(top_limit: DEFAULT_TOP_LIMIT, follow_symlinks: false)
8
+ def initialize(top_limit: DEFAULT_TOP_LIMIT, follow_symlinks: false, excludes: [])
9
9
  @top_limit = top_limit
10
10
  @follow_symlinks = follow_symlinks
11
+ @excludes = Array(excludes).map(&:to_s).reject(&:empty?)
11
12
  reset!
12
13
  end
13
14
 
14
15
  def scan(path, progress: nil)
15
16
  reset!
16
17
  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
+ raise ArgumentError, "Path is not a directory: #{expanded_path}" unless File.directory?(expanded_path)
19
+
20
+ @root_path = expanded_path
18
21
 
19
22
  @progress_callback = progress
20
23
  notify_counting_progress(force: true)
@@ -57,6 +60,7 @@ module FileTreeVisualizer
57
60
  @processed_directories = 0
58
61
  @counted_files = 0
59
62
  @counted_directories = 0
63
+ @root_path = nil
60
64
  end
61
65
 
62
66
  def scan_directory(path)
@@ -66,7 +70,7 @@ module FileTreeVisualizer
66
70
  node = {
67
71
  name: directory_name,
68
72
  path: path,
69
- type: "directory",
73
+ type: 'directory',
70
74
  size: 0,
71
75
  children: []
72
76
  }
@@ -78,6 +82,11 @@ module FileTreeVisualizer
78
82
  entries = Dir.children(path)
79
83
  entries.each do |entry_name|
80
84
  child_path = File.join(path, entry_name)
85
+ if excluded_path?(child_path)
86
+ @skipped_count += 1
87
+ next
88
+ end
89
+
81
90
  process_child_node(child_path, entry_name, node)
82
91
  rescue StandardError
83
92
  @skipped_count += 1
@@ -106,7 +115,7 @@ module FileTreeVisualizer
106
115
  file_node = {
107
116
  name: entry_name,
108
117
  path: child_path,
109
- type: "file",
118
+ type: 'file',
110
119
  size: file_size
111
120
  }
112
121
  parent_node[:children] << file_node
@@ -142,6 +151,7 @@ module FileTreeVisualizer
142
151
  entries = Dir.children(path)
143
152
  entries.each do |entry_name|
144
153
  child_path = File.join(path, entry_name)
154
+ next if excluded_path?(child_path)
145
155
 
146
156
  begin
147
157
  if File.symlink?(child_path) && !@follow_symlinks
@@ -165,6 +175,22 @@ module FileTreeVisualizer
165
175
  totals
166
176
  end
167
177
 
178
+ def excluded_path?(path)
179
+ return false if @excludes.empty?
180
+
181
+ basename = File.basename(path)
182
+ relative = relative_path(path)
183
+ flags = File::FNM_PATHNAME | File::FNM_DOTMATCH | File::FNM_EXTGLOB
184
+
185
+ @excludes.any? do |pattern|
186
+ File.fnmatch?(pattern, basename, flags) || File.fnmatch?(pattern, relative, flags)
187
+ end
188
+ end
189
+
190
+ def relative_path(path)
191
+ path.delete_prefix("#{@root_path}/")
192
+ end
193
+
168
194
  def notify_progress
169
195
  return unless @progress_callback
170
196
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FileTreeVisualizer
4
- VERSION = "0.0.3"
4
+ VERSION = '0.2.0'
5
5
  end
@@ -1,9 +1,10 @@
1
1
  # frozen_string_literal: true
2
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"
3
+ require_relative 'file_tree_visualizer/version'
4
+ require_relative 'file_tree_visualizer/i18n'
5
+ require_relative 'file_tree_visualizer/scanner'
6
+ require_relative 'file_tree_visualizer/report_builder'
7
+ require_relative 'file_tree_visualizer/cli'
7
8
 
8
9
  module FileTreeVisualizer
9
10
  end
metadata CHANGED
@@ -1,19 +1,33 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: file_tree_visualizer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.3
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
- - jbidwell1
7
+ - John Bidwell
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
11
  date: 2026-04-04 00:00:00.000000000 Z
12
- dependencies: []
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: csv
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '3.3'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '3.3'
13
27
  description: Scans a directory recursively, computes file/directory sizes, and generates
14
28
  an HTML report rendered with React.
15
29
  email:
16
- - devnull@example.com
30
+ - johnbidwellb@gmail.com
17
31
  executables:
18
32
  - file-tree-visualizer
19
33
  extensions: []
@@ -26,15 +40,17 @@ files:
26
40
  - file_tree_visualizer.gemspec
27
41
  - lib/file_tree_visualizer.rb
28
42
  - lib/file_tree_visualizer/cli.rb
43
+ - lib/file_tree_visualizer/i18n.rb
29
44
  - lib/file_tree_visualizer/report_builder.rb
30
45
  - lib/file_tree_visualizer/scanner.rb
31
46
  - lib/file_tree_visualizer/version.rb
32
- homepage: https://example.com/file_tree_visualizer
47
+ homepage: https://github.com/JohnBidwellB/file-tree-visualizer
33
48
  licenses:
34
49
  - MIT
35
50
  metadata:
36
- homepage_uri: https://example.com/file_tree_visualizer
37
- source_code_uri: https://example.com/file_tree_visualizer
51
+ source_code_uri: https://github.com/JohnBidwellB/file-tree-visualizer/tree/main
52
+ bug_tracker_uri: https://github.com/JohnBidwellB/file-tree-visualizer/issues
53
+ changelog_uri: https://github.com/JohnBidwellB/file-tree-visualizer/blob/main/CHANGELOG.md
38
54
  post_install_message:
39
55
  rdoc_options: []
40
56
  require_paths: