dirlens 1.0.9 → 1.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +23 -15
  2. package/dirlens.py +54 -29
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -119,7 +119,7 @@ Desktop/ (2 dirs, 2 files, 3.74 MB)
119
119
  - **拡張子統計** — ツリー全体のファイル種別を集計してサマリーに表示
120
120
  - **`.gitignore` 対応** — `-g` で除外(サブディレクトリも対応・否定パターン `!` 対応)
121
121
  - **最終更新日時** — `--date` / `-D` で相対表示
122
- - **拡張子フィルタ** — `-t py` など指定した拡張子のみ表示
122
+ - **拡張子フィルタ** — `-e py` など指定した拡張子のみ表示
123
123
  - **パターンフィルタ** — `--exclude` / `-I`、`--include` / `-P` でワイルドカード指定(複数可)
124
124
  - **サイズフィルタ** — `--min-size` / `--max-size` で容量による絞り込み
125
125
  - **空ディレクトリの剪定** — `--prune` でフィルタ後に空になる枝を非表示(tree --prune 互換)
@@ -132,7 +132,7 @@ Desktop/ (2 dirs, 2 files, 3.74 MB)
132
132
  - **Markdown出力** — `-m` でコードブロック形式に出力
133
133
  - **JSON出力** — `--json` / `-J` で機械可読な構造データを出力
134
134
  - **HTMLレポート** — `--html` でブラウザで閲覧できる折りたたみツリーを生成
135
- - **クリップボードコピー** — `-c` で出力を自動コピー(ANSIコードを自動除去)
135
+ - **クリップボードコピー** — `-C` で出力を自動コピー(ANSIコードを自動除去)
136
136
  - **AIモード** — `--ai` 一発で gitignore除外・日時・Markdown・クリップボードコピーを全適用
137
137
  - **隠しファイル対応** — `-a` で表示切り替え
138
138
  - **サイズ順ソート** — `-s` で大きいものから表示
@@ -144,44 +144,50 @@ Desktop/ (2 dirs, 2 files, 3.74 MB)
144
144
  ```bash
145
145
  # ── AI チャットへの貼り付け(最もよく使うコマンド)─────────────
146
146
  dirlens --ai # gitignore除外 + 日時 + Markdown + クリップボードコピー
147
- dirlens --ai -d 3 # 深さ指定と組み合わせ可
147
+ dirlens --ai -L 3 # 深さ指定と組み合わせ可
148
148
 
149
149
  # ── 表示制御 ──────────────────────────────────────────────────
150
150
  dirlens # カレントディレクトリ
151
151
  dirlens ~/Desktop # 指定ディレクトリ
152
- dirlens -d 2 # 深さ2階層まで(tree -L 互換は -L 2)
153
- dirlens -L 2 # 同上(tree -L 互換)
152
+ dirlens -L 2 # 深さ2階層まで(tree -L 互換)
153
+ dirlens -d # ディレクトリのみ表示(tree -d 互換)
154
154
  dirlens -a # 隠しファイルも表示
155
155
  dirlens -r # 逆順ソート(tree -r 互換)
156
156
  dirlens --filesfirst # ファイルをディレクトリより先に表示
157
157
  dirlens -f # ルートからのフルパスで表示(tree -f 互換)
158
158
 
159
159
  # ── フィルタリング ────────────────────────────────────────────
160
- dirlens -g # .gitignore のファイルを除外
161
- dirlens -g --prune # gitignore除外 + 空になった枝を剪定
162
- dirlens -t py # .py のみ表示
160
+ dirlens -G # .gitignore のファイルを除外
161
+ dirlens -G --prune # gitignore除外 + 空になった枝を剪定
162
+ dirlens -e py # .py のみ表示
163
163
  dirlens -P '*.md' # .md のみ表示(tree -P 互換)
164
164
  dirlens -I '*.log' # .log を除外(tree -I 互換)
165
165
  dirlens --exclude 'dist' --exclude '*.log' # 複数除外
166
166
  dirlens --min-size 1M # 1MB 以上のファイルのみ
167
167
  dirlens --max-size 100K # 100KB 以下のファイルのみ
168
168
 
169
+ # ── ソート ────────────────────────────────────────────────────
170
+ dirlens -S # サイズの大きい順に表示
171
+ dirlens -t # 更新日時順にソート(新しい順・tree -t 互換)
172
+ dirlens -c # ステータス変更日時順にソート(tree -c 互換)
173
+ dirlens -t -r # 更新日時順・古い順
174
+
169
175
  # ── 詳細情報 ──────────────────────────────────────────────────
170
- dirlens -s # サイズの大きい順に表示
171
176
  dirlens --date # 最終更新日時を相対表示(tree -D 互換は -D)
172
177
  dirlens -D # 同上(tree -D 互換)
173
178
  dirlens --bar # ディスク占有率バーを表示
174
179
  dirlens --emoji # 絵文字アイコンを表示
175
180
  dirlens -p # パーミッションを表示(tree -p 互換)
176
181
  dirlens -u # 所有者名を表示(tree -u 互換)
177
- dirlens -p -u # 両方表示
182
+ dirlens -g # グループ名を表示(tree -g 互換)
183
+ dirlens -p -u -g # 全部表示
178
184
  dirlens -l # シンボリックリンク先を展開(tree -l 互換)
179
185
 
180
186
  # ── 出力形式 ──────────────────────────────────────────────────
181
187
  dirlens -m # Markdown コードブロック形式で出力
182
188
  dirlens --json # JSON 形式(tree -J 互換は -J)
183
189
  dirlens --html # HTML レポートを生成(デフォルト: dirlens.html)
184
- dirlens -c # クリップボードにコピー
190
+ dirlens -C # クリップボードにコピー
185
191
  dirlens --no-color # カラーなし(tree -n 互換は -n)
186
192
  dirlens --no-color > dirlens.txt # テキストファイルに書き出す
187
193
  ```
@@ -198,6 +204,7 @@ dirlens --no-color > dirlens.txt # テキストファイルに書き出す
198
204
  | `--all` | `-a` | 隠しファイル・ディレクトリも表示 |
199
205
  | `-d` | — | ディレクトリのみ表示 |
200
206
  | `--sort-size` | `-S` | サイズが大きい順に並べる |
207
+ | `-s` | — | サイズ表示(tree互換・常時表示されているため実質no-op) |
201
208
  | `-t` | — | 更新日時順にソート(新しい順) |
202
209
  | `-c` | — | ステータス変更日時順にソート |
203
210
  | `--reverse` | `-r` | ソート順を逆にする |
@@ -241,9 +248,10 @@ dirlens --no-color > dirlens.txt # テキストファイルに書き出す
241
248
 
242
249
  - ディレクトリのサイズは **全サブファイルの合計**(隠しファイルを含む、`.gitignore` 対象も含む)
243
250
  - ディレクトリサイズはルート直下を **並列プリフェッチ** して高速化(透過的な最適化)
251
+ - **`+` 表記** — 一部のサブディレクトリが読めなかった場合、サイズは `1.5+ KB`(少なくとも 1.5 KB)、件数は `3+ dirs` のように表示。個々のファイルサイズは親ディレクトリのスキャン時に取得できるので `+` なし(正確な値)
252
+ - **アクセス拒否** — 読めないディレクトリは赤太字で `[アクセス拒否]` を表示してスキップ。そのディレクトリは `0+ dirs, 0+ files, 0+ bytes` として表示される
244
253
  - **シンボリックリンク** は `→ リンク先パス` で表示。`-l` でリンク先ディレクトリを展開(循環検出あり)
245
- - 権限がないディレクトリは `[アクセス拒否]` と表示してスキップ
246
- - 非常に深いディレクトリ(1万階層以上)は `-d`/`-L` で深さを制限してください
254
+ - 非常に深いディレクトリ(1万階層以上)は `-L` で深さを制限してください
247
255
  - **ホームフォルダ(`~/`)やルート(`/`)で実行すると固まる場合があります** — サイズ計算は表示深さに関わらず底まで全再帰するため、`~/Library` や iCloud Drive など大容量ディレクトリで時間がかかります
248
- - **`-g` の否定パターン(`!`)** は対応していますが、ディレクトリを除外した後にその中のファイルを `!` で復活させることは非対応です(`*.log` + `!important.log` のようなファイル単位の否定は動作します)
249
- - `-p`(パーミッション)・`-u`(ユーザー名)は macOS / Linux のみ対応(Windows では UID 番号を表示)
256
+ - **`-G` の否定パターン(`!`)** は対応していますが、ディレクトリを除外した後にその中のファイルを `!` で復活させることは非対応です(`*.log` + `!important.log` のようなファイル単位の否定は動作します)
257
+ - `-p`(パーミッション)・`-u`(ユーザー名)・`-g`(グループ名)は macOS / Linux のみ対応(Windows では ID 番号を表示)
package/dirlens.py CHANGED
@@ -26,21 +26,27 @@ def _enable_color():
26
26
  USE_COLOR = _enable_color()
27
27
  RESET = "\033[0m"; BOLD = "\033[1m"; DIM = "\033[2m"
28
28
  BLUE = "\033[34m"; CYAN = "\033[36m"; GREEN = "\033[32m"; MAGENTA = "\033[35m"
29
+ RED = "\033[31m"
29
30
 
30
31
  def c(text, *codes):
31
32
  return ("".join(codes) + text + RESET) if USE_COLOR else text
32
33
 
33
34
 
34
35
  # ─── フォーマット ─────────────────────────────────────────────
35
- def fmt_size(n):
36
- if n == 0: return "0 bytes"
36
+ def fmt_size(n, partial=False):
37
+ sfx = "+" if partial else ""
38
+ if n == 0: return f"0{sfx} bytes"
37
39
  for unit, f in (("TB",1<<40),("GB",1<<30),("MB",1<<20),("KB",1<<10)):
38
40
  if n >= f:
39
- return f"{str(f'{n/f:.2f}').rstrip('0').rstrip('.')} {unit}"
40
- return f"{n} {'byte' if n==1 else 'bytes'}"
41
+ return f"{str(f'{n/f:.2f}').rstrip('0').rstrip('.')}{sfx} {unit}"
42
+ return f"{n}{sfx} {'byte' if (n==1 and not partial) else 'bytes'}"
41
43
 
42
- def fmt_count(nd, nf):
43
- return f"{nd} {'dir' if nd==1 else 'dirs'}, {nf} {'file' if nf==1 else 'files'}"
44
+ def fmt_count(nd, nf, denied=False):
45
+ sfx = "+" if denied else ""
46
+ # denied=True のとき数が不明なので複数形固定
47
+ d_word = "dir" if (nd == 1 and not denied) else "dirs"
48
+ f_word = "file" if (nf == 1 and not denied) else "files"
49
+ return f"{nd}{sfx} {d_word}, {nf}{sfx} {f_word}"
44
50
 
45
51
  def fmt_date(mtime):
46
52
  sec = int((datetime.datetime.now() - datetime.datetime.fromtimestamp(mtime)).total_seconds())
@@ -208,8 +214,10 @@ def _extend_pats(active_pats, path, cfg):
208
214
  _sz_cache = {}
209
215
 
210
216
  def dir_size(path):
217
+ """ディレクトリの合計サイズを返す。(size, has_errors) のタプル。"""
211
218
  if path in _sz_cache: return _sz_cache[path]
212
219
  total = 0
220
+ has_errors = False
213
221
  try:
214
222
  with os.scandir(path) as it:
215
223
  for e in it:
@@ -217,11 +225,16 @@ def dir_size(path):
217
225
  if e.is_file(follow_symlinks=False):
218
226
  total += e.stat(follow_symlinks=False).st_size
219
227
  elif e.is_dir(follow_symlinks=False):
220
- total += dir_size(e.path)
221
- except OSError: pass
222
- except OSError: pass
223
- _sz_cache[path] = total
224
- return total
228
+ sub_sz, sub_err = dir_size(e.path)
229
+ total += sub_sz
230
+ if sub_err: has_errors = True
231
+ except OSError:
232
+ has_errors = True
233
+ except OSError:
234
+ has_errors = True
235
+ result = (total, has_errors)
236
+ _sz_cache[path] = result
237
+ return result
225
238
 
226
239
  def _prefetch_sizes(root_path):
227
240
  try:
@@ -266,7 +279,7 @@ class Cfg:
266
279
  # ─── 共通フィルタリング ───────────────────────────────────────
267
280
  def _filter(path, cfg, active_pats):
268
281
  try: raw = list(os.scandir(path))
269
- except PermissionError: return [], []
282
+ except PermissionError: return None, None # アクセス拒否シグナル
270
283
 
271
284
  entries = [e for e in raw if cfg.show_all or not e.name.startswith(".")]
272
285
 
@@ -316,12 +329,14 @@ def _filter(path, cfg, active_pats):
316
329
  def count_entries(path, cfg, active_pats):
317
330
  pats = _extend_pats(active_pats, path, cfg)
318
331
  dirs, files = _filter(path, cfg, pats)
319
- return len(dirs), len(files)
332
+ if dirs is None: return 0, 0, True # アクセス拒否
333
+ return len(dirs), len(files), False
320
334
 
321
335
  def _has_content(path, depth, cfg, active_pats):
322
336
  if cfg.max_depth is not None and depth >= cfg.max_depth: return False
323
337
  pats = _extend_pats(active_pats, path, cfg)
324
338
  dirs, files = _filter(path, cfg, pats)
339
+ if dirs is None: return False # アクセス拒否は空とみなす
325
340
  if files: return True
326
341
  for d in dirs:
327
342
  if _has_content(d.path, depth + 1, cfg, pats): return True
@@ -349,7 +364,7 @@ def _sort_entries(dirs, files, cfg):
349
364
  dirs.sort(key=ectime, reverse=not rev)
350
365
  files.sort(key=ectime, reverse=not rev)
351
366
  elif cfg.by_size: # -S: サイズ順(大きい順)
352
- dirs.sort(key=lambda e: dir_size(e.path), reverse=not rev)
367
+ dirs.sort(key=lambda e: dir_size(e.path)[0], reverse=not rev)
353
368
  files.sort(key=esz, reverse=not rev)
354
369
  else: # デフォルト: アルファベット順
355
370
  dirs.sort(key=lambda e: e.name.casefold(), reverse=rev)
@@ -374,13 +389,16 @@ def render(path, prefix, depth, cfg, stats, active_pats, _seen=None):
374
389
 
375
390
  cur_pats = _extend_pats(active_pats, path, cfg)
376
391
  dirs, files = _filter(path, cfg, cur_pats)
392
+ if dirs is None: # アクセス拒否
393
+ print(f"{prefix}{LAST}{c('[アクセス拒否]', BOLD, RED)}")
394
+ return
377
395
 
378
396
  if cfg.prune:
379
397
  dirs = [d for d in dirs if _has_content(d.path, depth + 1, cfg, cur_pats)]
380
398
 
381
399
  dirs, files = _sort_entries(dirs, files, cfg)
382
400
  combined = (files + dirs) if cfg.files_first else (dirs + files)
383
- cur_dir_size = dir_size(path)
401
+ cur_dir_size, _ = dir_size(path)
384
402
 
385
403
  def esz(e):
386
404
  try: return e.stat(follow_symlinks=True).st_size
@@ -409,12 +427,12 @@ def render(path, prefix, depth, cfg, stats, active_pats, _seen=None):
409
427
  (cfg.follow_syms and entry.is_symlink() and entry.is_dir(follow_symlinks=True))
410
428
 
411
429
  if is_dir_entry:
412
- sz = dir_size(entry.path)
413
- nd, nf = count_entries(entry.path, cfg, cur_pats)
430
+ sz, sz_err = dir_size(entry.path)
431
+ nd, nf, denied = count_entries(entry.path, cfg, cur_pats)
414
432
  stats["dirs"] += 1
415
433
 
416
434
  emoji = (get_emoji(entry.name, is_dir=True) + " ") if cfg.show_emoji else ""
417
- parts = [fmt_count(nd, nf), fmt_size(sz)]
435
+ parts = [fmt_count(nd, nf, denied), fmt_size(sz, sz_err)]
418
436
  if cfg.show_date:
419
437
  try: parts.append(fmt_date(entry.stat(follow_symlinks=False).st_mtime))
420
438
  except OSError: pass
@@ -433,7 +451,7 @@ def render(path, prefix, depth, cfg, stats, active_pats, _seen=None):
433
451
  stats["extensions"].get(ext or "(no ext)", 0) + 1
434
452
 
435
453
  emoji = (get_emoji(entry.name) + " ") if cfg.show_emoji else ""
436
- parts = [fmt_size(sz)]
454
+ parts = [fmt_size(sz)] # ファイルサイズは stat() で正確に取れるので + なし
437
455
  if cfg.show_date:
438
456
  mt = emtime(entry)
439
457
  if mt: parts.append(fmt_date(mt))
@@ -449,10 +467,12 @@ def render(path, prefix, depth, cfg, stats, active_pats, _seen=None):
449
467
  def build_json_tree(path, depth, cfg, active_pats):
450
468
  cur_pats = _extend_pats(active_pats, path, cfg)
451
469
  dirs, files = _filter(path, cfg, cur_pats)
470
+ denied = dirs is None
471
+ if denied: dirs, files = [], []
452
472
  if cfg.prune:
453
473
  dirs = [d for d in dirs if _has_content(d.path, depth + 1, cfg, cur_pats)]
454
474
  dirs, files = _sort_entries(dirs, files, cfg)
455
- sz = dir_size(path)
475
+ sz, sz_err = dir_size(path)
456
476
 
457
477
  def esz(e):
458
478
  try: return e.stat(follow_symlinks=True).st_size
@@ -476,9 +496,10 @@ def build_json_tree(path, depth, cfg, active_pats):
476
496
  name = os.path.basename(path) or path
477
497
  return {
478
498
  "name": name, "type": "directory",
479
- "size": sz, "size_human": fmt_size(sz),
499
+ "size": sz, "size_human": fmt_size(sz, sz_err),
480
500
  "path": os.path.relpath(path, cfg.root) if path != cfg.root else ".",
481
- "item_count": {"dirs": len(dirs), "files": len(files)},
501
+ "item_count": {"dirs": len(dirs), "files": len(files),
502
+ "permission_denied": denied},
482
503
  "children": children,
483
504
  }
484
505
 
@@ -488,10 +509,12 @@ def generate_html(root_path, cfg, active_pats):
488
509
  def _node(path, depth, cur_pats):
489
510
  pats = _extend_pats(cur_pats, path, cfg)
490
511
  dirs, files = _filter(path, cfg, pats)
512
+ denied = dirs is None
513
+ if denied: dirs, files = [], []
491
514
  if cfg.prune:
492
515
  dirs = [d for d in dirs if _has_content(d.path, depth + 1, cfg, pats)]
493
516
  dirs, files = _sort_entries(dirs, files, cfg)
494
- sz = dir_size(path)
517
+ sz, sz_err = dir_size(path)
495
518
  name = os.path.basename(path) or path
496
519
 
497
520
  def esz(e):
@@ -505,8 +528,9 @@ def generate_html(root_path, cfg, active_pats):
505
528
  if cfg.max_depth is None or depth < cfg.max_depth:
506
529
  ch += _node(entry.path, depth + 1, pats)
507
530
  else:
531
+ e_sz, e_err = dir_size(entry.path)
508
532
  ch += (f'<div class="item dir-leaf">📁 {entry.name}/'
509
- f' <span class="sz">{fmt_size(dir_size(entry.path))}</span></div>\n')
533
+ f' <span class="sz">{fmt_size(e_sz, e_err)}</span></div>\n')
510
534
  else:
511
535
  f_sz = esz(entry)
512
536
  sym = ""
@@ -521,7 +545,7 @@ def generate_html(root_path, cfg, active_pats):
521
545
  nd, nf = len(dirs), len(files)
522
546
  opened = " open" if depth == 0 else ""
523
547
  return (f'<details{opened}><summary>📁 <strong>{name}/</strong>'
524
- f' <span class="sz">({fmt_count(nd, nf)}, {fmt_size(sz)})</span>'
548
+ f' <span class="sz">({fmt_count(nd, nf, denied)}, {fmt_size(sz, sz_err)})</span>'
525
549
  f'</summary><div class="ch">{ch}</div></details>\n')
526
550
 
527
551
  root_name = os.path.basename(root_path) or root_path
@@ -709,10 +733,11 @@ def main():
709
733
 
710
734
  if args.markdown: print("```")
711
735
 
712
- root_sz = dir_size(str(target))
713
- root_nd, root_nf = count_entries(str(target), cfg, active_pats)
714
- root_label = target.name if target.name else str(target)
715
- root_parts = [fmt_count(root_nd, root_nf), fmt_size(root_sz)]
736
+ root_sz, root_sz_err = dir_size(str(target))
737
+ root_nd, root_nf, root_denied = count_entries(str(target), cfg, active_pats)
738
+ root_label = target.name if target.name else str(target)
739
+
740
+ root_parts = [fmt_count(root_nd, root_nf, root_denied), fmt_size(root_sz, root_sz_err)]
716
741
  if args.date:
717
742
  try: root_parts.append(fmt_date(target.stat().st_mtime))
718
743
  except OSError: pass
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dirlens",
3
- "version": "1.0.9",
3
+ "version": "1.0.10",
4
4
  "description": "Directory tree viewer with file sizes / ファイルサイズ付きディレクトリツリー表示ツール",
5
5
  "bin": {
6
6
  "dirlens": "./bin/dirlens"