dirlens 1.0.8 → 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 +103 -99
  2. package/dirlens.py +377 -193
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -111,24 +111,30 @@ Desktop/ (2 dirs, 2 files, 3.74 MB)
111
111
  ## 特徴
112
112
 
113
113
  - **クロスプラットフォーム** — macOS / Linux / Windows
114
+ - **tree コマンドとの高い互換性** — **`-a -d -f -g -l -p -u -r -s -t -c -L -D -P -I -n -J --prune --gitignore` など主要フラグが `tree` と互換**。dirlens 独自機能は `-G`(gitignore)・`-S`(サイズ順)・`-e`(拡張子)・`-C`(クリップボード)で提供
114
115
  - **カラー表示** — ディレクトリ・ファイル・シンボリックリンクを色で識別
115
116
  - **自動サイズ変換** — bytes / KB / MB / GB / TB
116
- - **ディレクトリサイズ** — サブディレクトリの合計サイズを自動計算
117
+ - **ディレクトリサイズ** — サブディレクトリの合計サイズを自動計算(並列プリフェッチで高速化)
117
118
  - **アイテム数表示** — 各ディレクトリの直下にある dirs / files 数を表示
118
119
  - **拡張子統計** — ツリー全体のファイル種別を集計してサマリーに表示
119
- - **`.gitignore` 対応** — `-g` `node_modules/` などを自動除外(サブディレクトリも対応)
120
- - **最終更新日時** — `--date` で各ファイル・ディレクトリの更新日時を相対表示
121
- - **拡張子フィルタ** — `-t py` など指定した拡張子のみ表示
122
- - **パターンフィルタ** — `--exclude` / `--include` でワイルドカード指定(複数可)
120
+ - **`.gitignore` 対応** — `-g` で除外(サブディレクトリも対応・否定パターン `!` 対応)
121
+ - **最終更新日時** — `--date` / `-D` で相対表示
122
+ - **拡張子フィルタ** — `-e py` など指定した拡張子のみ表示
123
+ - **パターンフィルタ** — `--exclude` / `-I`、`--include` / `-P` でワイルドカード指定(複数可)
123
124
  - **サイズフィルタ** — `--min-size` / `--max-size` で容量による絞り込み
125
+ - **空ディレクトリの剪定** — `--prune` でフィルタ後に空になる枝を非表示(tree --prune 互換)
126
+ - **パーミッション表示** — `-p` でパーミッション文字列、`-u` で所有者名(tree -p/-u 互換)
127
+ - **シンボリックリンク展開** — `-l` でリンク先を追跡、リンク先パスを `→` で表示
128
+ - **フルパス表示** — `-f` でルートからのパスを表示(tree -f 互換)
129
+ - **逆順ソート** — `-r` でソート順を逆転(tree -r 互換)
124
130
  - **ディスク占有率バー** — `--bar` で親ディレクトリに対する占有率を視覚表示
125
131
  - **絵文字アイコン** — `--emoji` で拡張子に応じた絵文字を付与
126
- - **Markdown出力** — `-m` でコードブロック形式に出力、AIチャットへそのままペースト可
127
- - **JSON出力** — `--json` で機械可読な構造データを出力、スクリプト連携に
132
+ - **Markdown出力** — `-m` でコードブロック形式に出力
133
+ - **JSON出力** — `--json` / `-J` で機械可読な構造データを出力
128
134
  - **HTMLレポート** — `--html` でブラウザで閲覧できる折りたたみツリーを生成
129
- - **クリップボードコピー** — `-c` で出力を自動コピー、AIチャットへの貼り付けが一発
135
+ - **クリップボードコピー** — `-C` で出力を自動コピー(ANSIコードを自動除去)
130
136
  - **AIモード** — `--ai` 一発で gitignore除外・日時・Markdown・クリップボードコピーを全適用
131
- - **隠しファイル対応** — `-a` で表示切り替え(アイテム数・統計にも反映)
137
+ - **隠しファイル対応** — `-a` で表示切り替え
132
138
  - **サイズ順ソート** — `-s` で大きいものから表示
133
139
 
134
140
  ---
@@ -137,97 +143,92 @@ Desktop/ (2 dirs, 2 files, 3.74 MB)
137
143
 
138
144
  ```bash
139
145
  # ── AI チャットへの貼り付け(最もよく使うコマンド)─────────────
140
- # gitignore除外 + 日時 + Markdown + クリップボードコピーを一発で実行
141
- dirlens --ai
142
-
143
- # 深さを指定したい場合は組み合わせ可能
144
- dirlens --ai -d 3
145
-
146
- # カレントディレクトリを表示
147
- dirlens
148
-
149
- # 特定のディレクトリを表示
150
- dirlens ~/Desktop
151
-
152
- # 深さ 2 階層まで表示
153
- dirlens -d 2
154
-
155
- # 隠しファイル・ディレクトリ (.xxx) も表示
156
- dirlens -a
157
-
158
- # サイズの大きい順に並べる
159
- dirlens -s
160
-
161
- # .gitignore のファイルを除外(node_modules など)
162
- dirlens -g
163
-
164
- # 最終更新日時を相対表示(例: 3日前、2時間前)
165
- dirlens --date
166
-
167
- # ディスク占有率バーを表示
168
- dirlens --bar
169
-
170
- # 拡張子に応じた絵文字アイコンを表示
171
- dirlens --emoji
172
-
173
- # 指定した拡張子のファイルのみ表示
174
- dirlens -t py
175
-
176
- # パターンで除外(複数指定可)
177
- dirlens --exclude '*.log' --exclude 'dist'
178
-
179
- # パターンで抽出(複数指定可)
180
- dirlens --include 'test_*'
181
-
182
- # サイズで絞り込み
183
- dirlens --min-size 1M # 1MB 以上のみ
184
- dirlens --max-size 100K # 100KB 以下のみ
185
-
186
- # Markdown コードブロック形式で出力
187
- dirlens -m
188
-
189
- # JSON 形式で出力(スクリプト連携)
190
- dirlens --json
191
-
192
- # HTML レポートを生成(デフォルト: dirlens.html)
193
- dirlens --html
194
- dirlens --html report.html # ファイル名を指定
195
-
196
- # 出力をクリップボードにコピー
197
- dirlens -c
198
-
199
- # カラーなし(パイプ・ファイル書き出し向け)
200
- dirlens --no-color
201
-
202
- # テキストファイルに書き出す
203
- dirlens --no-color > dirlens.txt
146
+ dirlens --ai # gitignore除外 + 日時 + Markdown + クリップボードコピー
147
+ dirlens --ai -L 3 # 深さ指定と組み合わせ可
148
+
149
+ # ── 表示制御 ──────────────────────────────────────────────────
150
+ dirlens # カレントディレクトリ
151
+ dirlens ~/Desktop # 指定ディレクトリ
152
+ dirlens -L 2 # 深さ2階層まで(tree -L 互換)
153
+ dirlens -d # ディレクトリのみ表示(tree -d 互換)
154
+ dirlens -a # 隠しファイルも表示
155
+ dirlens -r # 逆順ソート(tree -r 互換)
156
+ dirlens --filesfirst # ファイルをディレクトリより先に表示
157
+ dirlens -f # ルートからのフルパスで表示(tree -f 互換)
158
+
159
+ # ── フィルタリング ────────────────────────────────────────────
160
+ dirlens -G # .gitignore のファイルを除外
161
+ dirlens -G --prune # gitignore除外 + 空になった枝を剪定
162
+ dirlens -e py # .py のみ表示
163
+ dirlens -P '*.md' # .md のみ表示(tree -P 互換)
164
+ dirlens -I '*.log' # .log を除外(tree -I 互換)
165
+ dirlens --exclude 'dist' --exclude '*.log' # 複数除外
166
+ dirlens --min-size 1M # 1MB 以上のファイルのみ
167
+ dirlens --max-size 100K # 100KB 以下のファイルのみ
168
+
169
+ # ── ソート ────────────────────────────────────────────────────
170
+ dirlens -S # サイズの大きい順に表示
171
+ dirlens -t # 更新日時順にソート(新しい順・tree -t 互換)
172
+ dirlens -c # ステータス変更日時順にソート(tree -c 互換)
173
+ dirlens -t -r # 更新日時順・古い順
174
+
175
+ # ── 詳細情報 ──────────────────────────────────────────────────
176
+ dirlens --date # 最終更新日時を相対表示(tree -D 互換は -D)
177
+ dirlens -D # 同上(tree -D 互換)
178
+ dirlens --bar # ディスク占有率バーを表示
179
+ dirlens --emoji # 絵文字アイコンを表示
180
+ dirlens -p # パーミッションを表示(tree -p 互換)
181
+ dirlens -u # 所有者名を表示(tree -u 互換)
182
+ dirlens -g # グループ名を表示(tree -g 互換)
183
+ dirlens -p -u -g # 全部表示
184
+ dirlens -l # シンボリックリンク先を展開(tree -l 互換)
185
+
186
+ # ── 出力形式 ──────────────────────────────────────────────────
187
+ dirlens -m # Markdown コードブロック形式で出力
188
+ dirlens --json # JSON 形式(tree -J 互換は -J)
189
+ dirlens --html # HTML レポートを生成(デフォルト: dirlens.html)
190
+ dirlens -C # クリップボードにコピー
191
+ dirlens --no-color # カラーなし(tree -n 互換は -n)
192
+ dirlens --no-color > dirlens.txt # テキストファイルに書き出す
204
193
  ```
205
194
 
206
195
  ---
207
196
 
208
197
  ## オプション一覧
209
198
 
210
- | オプション | 省略形 | 説明 |
211
- |---------------------|----------|------------------------------------------------------------|
212
- | `path` | — | 対象ディレクトリ(省略時はカレント) |
213
- | **`--ai`** | — | **`-g --date -m -c` のショートカット。AIチャット貼り付け専用** |
214
- | `--depth N` | `-d N` | 表示する最大の深さ |
215
- | `--all` | `-a` | 隠しファイル・ディレクトリも表示 |
216
- | `--sort-size` | `-s` | サイズが大きい順に並べる |
217
- | `--gitignore` | `-g` | `.gitignore` に記載されたファイルを除外(サブディレクトリも対応)|
218
- | `--date` | — | 最終更新日時を相対表示(例: 3日前) |
219
- | `--type EXT` | `-t EXT` | 指定した拡張子のファイルのみ表示(例: `-t py`) |
220
- | `--bar` | — | 親ディレクトリに対するディスク占有率バーを表示 |
221
- | `--emoji` | | 拡張子に応じた絵文字アイコンを表示 |
222
- | `--exclude PATTERN` | — | 除外パターン(複数指定可、例: `--exclude '*.log'`) |
223
- | `--include PATTERN` | | このパターンのみ表示(複数指定可) |
224
- | `--min-size SIZE` | — | 指定サイズ以上のファイルのみ表示(例: `1M`, `500K`) |
225
- | `--max-size SIZE` | | 指定サイズ以下のファイルのみ表示(例: `10M`) |
226
- | `--markdown` | `-m` | Markdown コードブロック形式で出力(カラー自動無効) |
227
- | `--json` | | JSON 形式で標準出力に出力 |
228
- | `--html [FILE]` | — | HTML レポートを生成(デフォルト: `dirlens.html`) |
229
- | `--copy` | `-c` | 出力をクリップボードにコピー |
230
- | `--no-color` | | カラー表示を無効化(リダイレクト時に推奨) |
199
+ | オプション | 省略形 | 説明 |
200
+ |---------------------|--------------|-------------------------------------------------------------|
201
+ | `path` | — | 対象ディレクトリ(省略時はカレント) |
202
+ | **`--ai`** | — | **`-G --date -m -C` のショートカット。AIチャット用** |
203
+ | `--depth N` | `-L N` | 表示する最大の深さ |
204
+ | `--all` | `-a` | 隠しファイル・ディレクトリも表示 |
205
+ | `-d` | | ディレクトリのみ表示 |
206
+ | `--sort-size` | `-S` | サイズが大きい順に並べる |
207
+ | `-s` | — | サイズ表示(tree互換・常時表示されているため実質no-op) |
208
+ | `-t` | | 更新日時順にソート(新しい順) |
209
+ | `-c` | — | ステータス変更日時順にソート |
210
+ | `--reverse` | `-r` | ソート順を逆にする |
211
+ | `--filesfirst` | — | ファイルをディレクトリより先に表示 |
212
+ | `--gitignore` | `-G` | `.gitignore` に記載されたファイルを除外(サブディレクトリも対応)|
213
+ | `--prune` | — | フィルタ後に空になるディレクトリを非表示 |
214
+ | `--date` | `-D` | 最終更新日時を相対表示 |
215
+ | `--perms` | `-p` | パーミッション文字列を表示 |
216
+ | `--user` | `-u` | 所有者のユーザー名を表示 |
217
+ | `-g` | — | グループ名を表示 |
218
+ | `--follow` | `-l` | シンボリックリンク先ディレクトリを展開(循環検出あり) |
219
+ | `--full-path` | `-f` | ルートからのフルパスで表示 |
220
+ | `--type EXT` | `-e EXT` | 指定した拡張子のファイルのみ表示(例: `-e py`) |
221
+ | `--include PATTERN` | `-P PATTERN` | このパターンのみ表示(複数指定可) |
222
+ | `--exclude PATTERN` | `-I PATTERN` | 除外パターン(複数指定可) |
223
+ | `--min-size SIZE` | — | 指定サイズ以上のファイルのみ表示(例: `1M`, `500K`) |
224
+ | `--max-size SIZE` | — | 指定サイズ以下のファイルのみ表示 |
225
+ | `--bar` | — | ディスク占有率バーを表示 |
226
+ | `--emoji` | — | 拡張子に応じた絵文字アイコンを表示 |
227
+ | `--markdown` | `-m` | Markdown コードブロック形式で出力(カラー自動無効) |
228
+ | `--json` | `-J` | JSON 形式で標準出力に出力 |
229
+ | `--html [FILE]` | — | HTML レポートを生成(デフォルト: `dirlens.html`) |
230
+ | `--copy` | `-C` | 出力をクリップボードにコピー(ANSIコードを自動除去) |
231
+ | `--no-color` | `-n` | カラー表示を無効化 |
231
232
 
232
233
  ---
233
234
 
@@ -246,8 +247,11 @@ dirlens --no-color > dirlens.txt
246
247
  ## 仕様・注意事項
247
248
 
248
249
  - ディレクトリのサイズは **全サブファイルの合計**(隠しファイルを含む、`.gitignore` 対象も含む)
249
- - **シンボリックリンク先のディレクトリ**は展開せず `→` マークで表示
250
- - 権限がないディレクトリは `[アクセス拒否]` と表示してスキップ
251
- - 非常に深いディレクトリ(1万階層以上)は `-d` で深さを制限してください
252
- - **ホームフォルダ(`~/`)やルート(`/`)で実行すると固まる場合があります** サイズ計算は `-d` の表示制限に関わらず底まで全再帰するため、`~/Library` や iCloud Drive など大容量・ネットワークマウントのディレクトリで時間がかかります。プロジェクトフォルダなど範囲を絞って実行してください
253
- - **`-g` の否定パターン(`!` から始まる行)は現時点で非対応**です
250
+ - ディレクトリサイズはルート直下を **並列プリフェッチ** して高速化(透過的な最適化)
251
+ - **`+` 表記** — 一部のサブディレクトリが読めなかった場合、サイズは `1.5+ KB`(少なくとも 1.5 KB)、件数は `3+ dirs` のように表示。個々のファイルサイズは親ディレクトリのスキャン時に取得できるので `+` なし(正確な値)
252
+ - **アクセス拒否** — 読めないディレクトリは赤太字で `[アクセス拒否]` を表示してスキップ。そのディレクトリは `0+ dirs, 0+ files, 0+ bytes` として表示される
253
+ - **シンボリックリンク** `→ リンク先パス` で表示。`-l` でリンク先ディレクトリを展開(循環検出あり)
254
+ - 非常に深いディレクトリ(1万階層以上)は `-L` で深さを制限してください
255
+ - **ホームフォルダ(`~/`)やルート(`/`)で実行すると固まる場合があります** — サイズ計算は表示深さに関わらず底まで全再帰するため、`~/Library` や iCloud Drive など大容量ディレクトリで時間がかかります
256
+ - **`-G` の否定パターン(`!`)** は対応していますが、ディレクトリを除外した後にその中のファイルを `!` で復活させることは非対応です(`*.log` + `!important.log` のようなファイル単位の否定は動作します)
257
+ - `-p`(パーミッション)・`-u`(ユーザー名)・`-g`(グループ名)は macOS / Linux のみ対応(Windows では ID 番号を表示)
package/dirlens.py CHANGED
@@ -4,7 +4,8 @@ dirlens – ファイルサイズ付きディレクトリツリー表示ツー
4
4
  対応環境: macOS / Linux / Windows (Python 3.8+)
5
5
  """
6
6
 
7
- import io, json, os, sys, argparse, fnmatch, datetime, subprocess
7
+ import io, json, os, sys, stat as _stat, argparse, fnmatch, datetime, subprocess
8
+ from concurrent.futures import ThreadPoolExecutor
8
9
  from pathlib import Path
9
10
 
10
11
  # ─── カラー設定 ──────────────────────────────────────────────
@@ -25,21 +26,27 @@ def _enable_color():
25
26
  USE_COLOR = _enable_color()
26
27
  RESET = "\033[0m"; BOLD = "\033[1m"; DIM = "\033[2m"
27
28
  BLUE = "\033[34m"; CYAN = "\033[36m"; GREEN = "\033[32m"; MAGENTA = "\033[35m"
29
+ RED = "\033[31m"
28
30
 
29
31
  def c(text, *codes):
30
32
  return ("".join(codes) + text + RESET) if USE_COLOR else text
31
33
 
32
34
 
33
35
  # ─── フォーマット ─────────────────────────────────────────────
34
- def fmt_size(n):
35
- 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"
36
39
  for unit, f in (("TB",1<<40),("GB",1<<30),("MB",1<<20),("KB",1<<10)):
37
40
  if n >= f:
38
- return f"{str(f'{n/f:.2f}').rstrip('0').rstrip('.')} {unit}"
39
- 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'}"
40
43
 
41
- def fmt_count(nd, nf):
42
- 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}"
43
50
 
44
51
  def fmt_date(mtime):
45
52
  sec = int((datetime.datetime.now() - datetime.datetime.fromtimestamp(mtime)).total_seconds())
@@ -69,34 +76,52 @@ def parse_size(s):
69
76
  except ValueError:
70
77
  raise argparse.ArgumentTypeError(f"無効なサイズ: '{s}'(例: 50M, 1G, 500K)")
71
78
 
79
+ def fmt_perm_info(entry, cfg):
80
+ """パーミッション・ユーザー・グループ情報を返す。"""
81
+ if not cfg.show_perms and not cfg.show_user and not cfg.show_group:
82
+ return ""
83
+ try:
84
+ st = entry.stat(follow_symlinks=False)
85
+ except OSError:
86
+ return ""
87
+ parts = []
88
+ if cfg.show_perms:
89
+ parts.append(_stat.filemode(st.st_mode))
90
+ if cfg.show_user:
91
+ try:
92
+ import pwd
93
+ parts.append(pwd.getpwuid(st.st_uid).pw_name)
94
+ except (ImportError, KeyError, AttributeError):
95
+ parts.append(str(getattr(st, "st_uid", "?")))
96
+ if cfg.show_group:
97
+ try:
98
+ import grp
99
+ parts.append(grp.getgrgid(st.st_gid).gr_name)
100
+ except (ImportError, KeyError, AttributeError):
101
+ parts.append(str(getattr(st, "st_gid", "?")))
102
+ return c("[" + " ".join(parts) + "] ", DIM) if parts else ""
103
+
72
104
 
73
105
  # ─── クリップボード ───────────────────────────────────────────
74
106
  def copy_to_clipboard(text):
75
107
  try:
76
108
  if sys.platform == "darwin":
77
109
  subprocess.run(["pbcopy"], input=text.encode(), check=True,
78
- stderr=subprocess.DEVNULL)
79
- return True
110
+ stderr=subprocess.DEVNULL); return True
80
111
  if sys.platform == "win32":
81
112
  subprocess.run(["clip"], input=text.encode("utf-16"), check=True,
82
- stderr=subprocess.DEVNULL)
83
- return True
84
- for cmd in [["wl-copy"],
85
- ["xclip", "-selection", "clipboard"],
86
- ["xsel", "--clipboard", "--input"]]:
113
+ stderr=subprocess.DEVNULL); return True
114
+ for cmd in [["wl-copy"],["xclip","-selection","clipboard"],
115
+ ["xsel","--clipboard","--input"]]:
87
116
  try:
88
117
  subprocess.run(cmd, input=text.encode(), check=True,
89
- stderr=subprocess.DEVNULL)
90
- return True
91
- except FileNotFoundError:
92
- continue
93
- return False
94
- except Exception:
118
+ stderr=subprocess.DEVNULL); return True
119
+ except FileNotFoundError: continue
95
120
  return False
121
+ except Exception: return False
96
122
 
97
123
  import re as _re
98
124
  def strip_ansi(text):
99
- """ANSIエスケープシーケンスを除去する。"""
100
125
  return _re.sub(r'\033\[[0-9;]*[mK]', '', text)
101
126
 
102
127
 
@@ -128,7 +153,7 @@ def get_emoji(name, is_dir=False):
128
153
  return _EMOJI_NAME.get(lower) or _EMOJI_EXT.get(os.path.splitext(lower)[1], "📄")
129
154
 
130
155
 
131
- # ─── .gitignore ───────────────────────────────────────────────
156
+ # ─── .gitignore(否定パターン対応) ──────────────────────────
132
157
  _gi_cache = {}
133
158
 
134
159
  def load_gitignore(directory):
@@ -147,22 +172,28 @@ def load_gitignore(directory):
147
172
  return pats
148
173
 
149
174
  def is_ignored(name, rel_path, is_dir, patterns):
175
+ """パターンを順番に評価し最後にマッチしたルールが勝つ(!否定対応)。"""
150
176
  rel = rel_path.replace("\\", "/")
177
+ result = False
151
178
  for pat in patterns:
152
- if pat.startswith("!"): continue
153
- dir_only = pat.endswith("/")
154
- p = pat.rstrip("/")
155
- if dir_only and not is_dir: continue
179
+ negated = pat.startswith("!")
180
+ p = pat.lstrip("!")
181
+ dir_only = p.endswith("/")
182
+ p = p.rstrip("/")
183
+ if dir_only and not is_dir:
184
+ continue
185
+ matched = False
156
186
  if p.startswith("/"):
157
- if fnmatch.fnmatch(rel, p.lstrip("/")): return True
187
+ matched = fnmatch.fnmatch(rel, p.lstrip("/"))
158
188
  else:
159
- if fnmatch.fnmatch(name, p): return True
160
- if fnmatch.fnmatch(rel, p): return True
161
- if fnmatch.fnmatch(rel, "*/" + p): return True
162
- return False
189
+ matched = (fnmatch.fnmatch(name, p) or
190
+ fnmatch.fnmatch(rel, p) or
191
+ fnmatch.fnmatch(rel, "*/" + p))
192
+ if matched:
193
+ result = not negated
194
+ return result
163
195
 
164
196
  def _extend_pats(active_pats, path, cfg):
165
- """サブディレクトリの .gitignore を読み込んでパターンを拡張する(ルートは除く)。"""
166
197
  if not cfg.use_gitignore: return active_pats
167
198
  if os.path.normpath(path) == os.path.normpath(cfg.root): return active_pats
168
199
  local = load_gitignore(path)
@@ -170,8 +201,10 @@ def _extend_pats(active_pats, path, cfg):
170
201
  rel_dir = os.path.relpath(path, cfg.root).replace("\\", "/")
171
202
  adjusted = []
172
203
  for pat in local:
173
- if not pat.startswith("!") and pat.startswith("/"):
174
- adjusted.append("/" + rel_dir + pat)
204
+ neg = pat.startswith("!")
205
+ p = pat.lstrip("!")
206
+ if p.startswith("/"):
207
+ adjusted.append(("!" if neg else "") + "/" + rel_dir + p)
175
208
  else:
176
209
  adjusted.append(pat)
177
210
  return active_pats + adjusted
@@ -181,8 +214,10 @@ def _extend_pats(active_pats, path, cfg):
181
214
  _sz_cache = {}
182
215
 
183
216
  def dir_size(path):
217
+ """ディレクトリの合計サイズを返す。(size, has_errors) のタプル。"""
184
218
  if path in _sz_cache: return _sz_cache[path]
185
219
  total = 0
220
+ has_errors = False
186
221
  try:
187
222
  with os.scandir(path) as it:
188
223
  for e in it:
@@ -190,11 +225,26 @@ def dir_size(path):
190
225
  if e.is_file(follow_symlinks=False):
191
226
  total += e.stat(follow_symlinks=False).st_size
192
227
  elif e.is_dir(follow_symlinks=False):
193
- total += dir_size(e.path)
194
- except OSError: pass
195
- except OSError: pass
196
- _sz_cache[path] = total
197
- 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
238
+
239
+ def _prefetch_sizes(root_path):
240
+ try:
241
+ top = [e.path for e in os.scandir(root_path)
242
+ if e.is_dir(follow_symlinks=False)]
243
+ except OSError: return
244
+ if len(top) < 2: return
245
+ workers = min(len(top), (os.cpu_count() or 1), 8)
246
+ with ThreadPoolExecutor(max_workers=workers) as ex:
247
+ list(ex.map(dir_size, top))
198
248
 
199
249
 
200
250
  # ─── 設定クラス ───────────────────────────────────────────────
@@ -203,23 +253,33 @@ class Cfg:
203
253
  self.root = root
204
254
  self.max_depth = args.depth
205
255
  self.show_all = args.all
206
- self.by_size = args.sort_size
256
+ self.by_size = args.sort_size # -S
257
+ self.sort_mtime = args.sort_mtime # -t (tree)
258
+ self.sort_ctime = args.sort_ctime # -c (tree)
207
259
  self.show_date = args.date
208
- self.use_gitignore = args.gitignore
260
+ self.use_gitignore = args.gitignore # -G
209
261
  self.show_bar = args.bar
210
262
  self.min_size = parse_size(args.min_size) if args.min_size else None
211
263
  self.max_size = parse_size(args.max_size) if args.max_size else None
212
264
  self.excludes = args.exclude or []
213
265
  self.includes = args.include or []
214
266
  self.show_emoji = args.emoji
215
- self.type_ext = ("." + args.type.lstrip(".")).lower() if args.type else None
267
+ self.type_ext = ("." + args.type.lstrip(".")).lower() if args.type else None # -e
268
+ self.show_perms = args.perms
269
+ self.show_user = args.user
270
+ self.show_group = args.show_group # -g (tree)
271
+ self.dirs_only = args.dirs_only # -d (tree)
272
+ self.follow_syms = args.follow
273
+ self.full_path = args.full_path
274
+ self.prune = args.prune
275
+ self.reverse = args.reverse
276
+ self.files_first = args.filesfirst
216
277
 
217
278
 
218
279
  # ─── 共通フィルタリング ───────────────────────────────────────
219
280
  def _filter(path, cfg, active_pats):
220
- """エントリを取得してフィルタリングする。"""
221
281
  try: raw = list(os.scandir(path))
222
- except PermissionError: return [], []
282
+ except PermissionError: return None, None # アクセス拒否シグナル
223
283
 
224
284
  entries = [e for e in raw if cfg.show_all or not e.name.startswith(".")]
225
285
 
@@ -230,8 +290,20 @@ def _filter(path, cfg, active_pats):
230
290
  e.is_dir(follow_symlinks=False),
231
291
  active_pats)]
232
292
 
233
- dirs = [e for e in entries if e.is_dir(follow_symlinks=False)]
234
- files = [e for e in entries if not e.is_dir(follow_symlinks=False)]
293
+ dirs = [e for e in entries if e.is_dir(follow_symlinks=False)]
294
+
295
+ if cfg.follow_syms:
296
+ sym_dirs = [e for e in entries
297
+ if e.is_symlink() and not e.is_dir(follow_symlinks=False)
298
+ and e.is_dir(follow_symlinks=True)]
299
+ dirs = dirs + sym_dirs
300
+
301
+ # -d (dirs only): ファイルを非表示
302
+ if cfg.dirs_only:
303
+ files = []
304
+ else:
305
+ files = [e for e in entries if not e.is_dir(follow_symlinks=False)
306
+ and not (cfg.follow_syms and e.is_symlink() and e.is_dir(follow_symlinks=True))]
235
307
 
236
308
  if cfg.excludes:
237
309
  dirs = [d for d in dirs if not any(fnmatch.fnmatch(d.name, p) for p in cfg.excludes)]
@@ -257,17 +329,76 @@ def _filter(path, cfg, active_pats):
257
329
  def count_entries(path, cfg, active_pats):
258
330
  pats = _extend_pats(active_pats, path, cfg)
259
331
  dirs, files = _filter(path, cfg, pats)
260
- return len(dirs), len(files)
332
+ if dirs is None: return 0, 0, True # アクセス拒否
333
+ return len(dirs), len(files), False
334
+
335
+ def _has_content(path, depth, cfg, active_pats):
336
+ if cfg.max_depth is not None and depth >= cfg.max_depth: return False
337
+ pats = _extend_pats(active_pats, path, cfg)
338
+ dirs, files = _filter(path, cfg, pats)
339
+ if dirs is None: return False # アクセス拒否は空とみなす
340
+ if files: return True
341
+ for d in dirs:
342
+ if _has_content(d.path, depth + 1, cfg, pats): return True
343
+ return False
344
+
345
+
346
+ # ─── ソートヘルパー ───────────────────────────────────────────
347
+ def _sort_entries(dirs, files, cfg):
348
+ """設定に基づいてdirs/filesをソートする(-t/-c/-S/-r対応)。"""
349
+ def emtime(e):
350
+ try: return e.stat(follow_symlinks=True).st_mtime
351
+ except OSError: return 0
352
+ def ectime(e):
353
+ try: return e.stat(follow_symlinks=True).st_ctime
354
+ except OSError: return 0
355
+ def esz(e):
356
+ try: return e.stat(follow_symlinks=True).st_size
357
+ except OSError: return 0
358
+
359
+ rev = cfg.reverse
360
+ if cfg.sort_mtime: # -t: 更新日時順(新しい順)
361
+ dirs.sort(key=emtime, reverse=not rev)
362
+ files.sort(key=emtime, reverse=not rev)
363
+ elif cfg.sort_ctime: # -c: ctime順(新しい順)
364
+ dirs.sort(key=ectime, reverse=not rev)
365
+ files.sort(key=ectime, reverse=not rev)
366
+ elif cfg.by_size: # -S: サイズ順(大きい順)
367
+ dirs.sort(key=lambda e: dir_size(e.path)[0], reverse=not rev)
368
+ files.sort(key=esz, reverse=not rev)
369
+ else: # デフォルト: アルファベット順
370
+ dirs.sort(key=lambda e: e.name.casefold(), reverse=rev)
371
+ files.sort(key=lambda e: e.name.casefold(), reverse=rev)
372
+
373
+ return dirs, files
261
374
 
262
375
 
263
376
  # ─── ツリー描画 ───────────────────────────────────────────────
264
377
  PIPE = "│ "; FORK = "├── "; LAST = "└── "; BLANK = " "
265
378
 
266
- def render(path, prefix, depth, cfg, stats, active_pats):
379
+ def render(path, prefix, depth, cfg, stats, active_pats, _seen=None):
267
380
  if cfg.max_depth is not None and depth >= cfg.max_depth: return
268
381
 
382
+ if cfg.follow_syms:
383
+ if _seen is None: _seen = set()
384
+ real = os.path.realpath(path)
385
+ if real in _seen:
386
+ print(f"{prefix}{LAST}{c('[循環リンク]', DIM)}")
387
+ return
388
+ _seen = _seen | {real}
389
+
269
390
  cur_pats = _extend_pats(active_pats, path, cfg)
270
391
  dirs, files = _filter(path, cfg, cur_pats)
392
+ if dirs is None: # アクセス拒否
393
+ print(f"{prefix}{LAST}{c('[アクセス拒否]', BOLD, RED)}")
394
+ return
395
+
396
+ if cfg.prune:
397
+ dirs = [d for d in dirs if _has_content(d.path, depth + 1, cfg, cur_pats)]
398
+
399
+ dirs, files = _sort_entries(dirs, files, cfg)
400
+ combined = (files + dirs) if cfg.files_first else (dirs + files)
401
+ cur_dir_size, _ = dir_size(path)
271
402
 
272
403
  def esz(e):
273
404
  try: return e.stat(follow_symlinks=True).st_size
@@ -276,94 +407,99 @@ def render(path, prefix, depth, cfg, stats, active_pats):
276
407
  try: return e.stat(follow_symlinks=True).st_mtime
277
408
  except OSError: return 0
278
409
 
279
- if cfg.by_size:
280
- dirs.sort(key=lambda e: dir_size(e.path), reverse=True)
281
- files.sort(key=lambda e: esz(e), reverse=True)
282
- else:
283
- dirs.sort(key=lambda e: e.name.casefold())
284
- files.sort(key=lambda e: e.name.casefold())
285
-
286
- combined = dirs + files
287
- cur_dir_size = dir_size(path)
288
-
289
410
  for i, entry in enumerate(combined):
290
411
  is_last = (i == len(combined) - 1)
291
412
  branch = LAST if is_last else FORK
292
413
  cont = BLANK if is_last else PIPE
293
414
 
294
- if entry.is_dir(follow_symlinks=False):
295
- sz = dir_size(entry.path)
296
- nd, nf = count_entries(entry.path, cfg, cur_pats)
415
+ if entry.is_symlink():
416
+ try: sym_target = f" → {os.readlink(entry.path)}"
417
+ except OSError: sym_target = " →"
418
+ else:
419
+ sym_target = ""
420
+
421
+ display = ("./" + os.path.relpath(entry.path, cfg.root).replace("\\", "/")) \
422
+ if cfg.full_path else entry.name
423
+
424
+ perm_prefix = fmt_perm_info(entry, cfg)
425
+
426
+ is_dir_entry = entry.is_dir(follow_symlinks=False) or \
427
+ (cfg.follow_syms and entry.is_symlink() and entry.is_dir(follow_symlinks=True))
428
+
429
+ if is_dir_entry:
430
+ sz, sz_err = dir_size(entry.path)
431
+ nd, nf, denied = count_entries(entry.path, cfg, cur_pats)
297
432
  stats["dirs"] += 1
298
433
 
299
434
  emoji = (get_emoji(entry.name, is_dir=True) + " ") if cfg.show_emoji else ""
300
- parts = [fmt_count(nd, nf), fmt_size(sz)]
435
+ parts = [fmt_count(nd, nf, denied), fmt_size(sz, sz_err)]
301
436
  if cfg.show_date:
302
437
  try: parts.append(fmt_date(entry.stat(follow_symlinks=False).st_mtime))
303
438
  except OSError: pass
304
439
  bar = (" " + fmt_bar(sz, cur_dir_size)) if cfg.show_bar and cur_dir_size else ""
305
440
 
306
- name = c(f"{emoji}{entry.name}/", BOLD, CYAN)
441
+ name = c(f"{emoji}{display}{sym_target}/", BOLD, CYAN)
307
442
  meta = c(f"({', '.join(parts)}){bar}", DIM)
308
- print(f"{prefix}{branch}{name} {meta}")
309
- render(entry.path, prefix + cont, depth + 1, cfg, stats, cur_pats)
443
+ print(f"{prefix}{branch}{perm_prefix}{name} {meta}")
444
+ render(entry.path, prefix + cont, depth + 1, cfg, stats, cur_pats, _seen)
310
445
 
311
446
  else:
312
447
  sz = esz(entry)
313
- sym = " →" if entry.is_symlink() else ""
314
448
  stats["files"] += 1
315
449
  ext = os.path.splitext(entry.name)[1].lower()
316
450
  stats["extensions"][ext or "(no ext)"] = \
317
451
  stats["extensions"].get(ext or "(no ext)", 0) + 1
318
452
 
319
453
  emoji = (get_emoji(entry.name) + " ") if cfg.show_emoji else ""
320
- parts = [fmt_size(sz)]
454
+ parts = [fmt_size(sz)] # ファイルサイズは stat() で正確に取れるので + なし
321
455
  if cfg.show_date:
322
456
  mt = emtime(entry)
323
457
  if mt: parts.append(fmt_date(mt))
324
458
  bar = (" " + fmt_bar(sz, cur_dir_size)) if cfg.show_bar and cur_dir_size else ""
325
459
 
326
- name = c(f"{emoji}{entry.name}{sym}", MAGENTA if entry.is_symlink() else GREEN)
460
+ name = c(f"{emoji}{display}{sym_target}",
461
+ MAGENTA if entry.is_symlink() else GREEN)
327
462
  meta = c(f"({', '.join(parts)}){bar}", DIM)
328
- print(f"{prefix}{branch}{name} {meta}")
463
+ print(f"{prefix}{branch}{perm_prefix}{name} {meta}")
329
464
 
330
465
 
331
466
  # ─── JSON出力 ─────────────────────────────────────────────────
332
467
  def build_json_tree(path, depth, cfg, active_pats):
333
468
  cur_pats = _extend_pats(active_pats, path, cfg)
334
469
  dirs, files = _filter(path, cfg, cur_pats)
335
- sz = dir_size(path)
470
+ denied = dirs is None
471
+ if denied: dirs, files = [], []
472
+ if cfg.prune:
473
+ dirs = [d for d in dirs if _has_content(d.path, depth + 1, cfg, cur_pats)]
474
+ dirs, files = _sort_entries(dirs, files, cfg)
475
+ sz, sz_err = dir_size(path)
336
476
 
337
477
  def esz(e):
338
478
  try: return e.stat(follow_symlinks=True).st_size
339
479
  except OSError: return 0
340
480
 
341
- if cfg.by_size:
342
- dirs.sort(key=lambda e: dir_size(e.path), reverse=True)
343
- files.sort(key=lambda e: esz(e), reverse=True)
344
- else:
345
- dirs.sort(key=lambda e: e.name.casefold())
346
- files.sort(key=lambda e: e.name.casefold())
347
-
348
481
  children = []
349
482
  if cfg.max_depth is None or depth < cfg.max_depth:
350
- for d in dirs:
351
- children.append(build_json_tree(d.path, depth + 1, cfg, cur_pats))
352
- for f in files:
353
- f_sz = esz(f)
354
- children.append({
355
- "name": f.name, "type": "file",
356
- "size": f_sz, "size_human": fmt_size(f_sz),
357
- "ext": os.path.splitext(f.name)[1].lower(),
358
- "path": os.path.relpath(f.path, cfg.root),
359
- })
483
+ combined = (files + dirs) if cfg.files_first else (dirs + files)
484
+ for entry in combined:
485
+ if entry.is_dir(follow_symlinks=False):
486
+ children.append(build_json_tree(entry.path, depth + 1, cfg, cur_pats))
487
+ else:
488
+ f_sz = esz(entry)
489
+ children.append({
490
+ "name": entry.name, "type": "file",
491
+ "size": f_sz, "size_human": fmt_size(f_sz),
492
+ "ext": os.path.splitext(entry.name)[1].lower(),
493
+ "path": os.path.relpath(entry.path, cfg.root),
494
+ })
360
495
 
361
496
  name = os.path.basename(path) or path
362
497
  return {
363
498
  "name": name, "type": "directory",
364
- "size": sz, "size_human": fmt_size(sz),
499
+ "size": sz, "size_human": fmt_size(sz, sz_err),
365
500
  "path": os.path.relpath(path, cfg.root) if path != cfg.root else ".",
366
- "item_count": {"dirs": len(dirs), "files": len(files)},
501
+ "item_count": {"dirs": len(dirs), "files": len(files),
502
+ "permission_denied": denied},
367
503
  "children": children,
368
504
  }
369
505
 
@@ -373,49 +509,50 @@ def generate_html(root_path, cfg, active_pats):
373
509
  def _node(path, depth, cur_pats):
374
510
  pats = _extend_pats(cur_pats, path, cfg)
375
511
  dirs, files = _filter(path, cfg, pats)
376
- sz = dir_size(path)
512
+ denied = dirs is None
513
+ if denied: dirs, files = [], []
514
+ if cfg.prune:
515
+ dirs = [d for d in dirs if _has_content(d.path, depth + 1, cfg, pats)]
516
+ dirs, files = _sort_entries(dirs, files, cfg)
517
+ sz, sz_err = dir_size(path)
377
518
  name = os.path.basename(path) or path
378
519
 
379
520
  def esz(e):
380
521
  try: return e.stat(follow_symlinks=True).st_size
381
522
  except OSError: return 0
382
523
 
383
- if cfg.by_size:
384
- dirs.sort(key=lambda e: dir_size(e.path), reverse=True)
385
- files.sort(key=lambda e: esz(e), reverse=True)
386
- else:
387
- dirs.sort(key=lambda e: e.name.casefold())
388
- files.sort(key=lambda e: e.name.casefold())
389
-
524
+ combined = (files + dirs) if cfg.files_first else (dirs + files)
390
525
  ch = ""
391
- if cfg.max_depth is None or depth < cfg.max_depth:
392
- for d in dirs:
393
- ch += _node(d.path, depth + 1, pats)
394
- else:
395
- for d in dirs:
396
- ch += (f'<div class="item dir-leaf">📁 {d.name}/'
397
- f' <span class="sz">{fmt_size(dir_size(d.path))}</span></div>\n')
398
-
399
- for f in files:
400
- f_sz = esz(f)
401
- ch += (f'<div class="item file">'
402
- f'<span class="emoji">{get_emoji(f.name)}</span>'
403
- f'<span class="fname"> {f.name}</span>'
404
- f'<span class="sz"> {fmt_size(f_sz)}</span></div>\n')
526
+ for entry in combined:
527
+ if entry.is_dir(follow_symlinks=False):
528
+ if cfg.max_depth is None or depth < cfg.max_depth:
529
+ ch += _node(entry.path, depth + 1, pats)
530
+ else:
531
+ e_sz, e_err = dir_size(entry.path)
532
+ ch += (f'<div class="item dir-leaf">📁 {entry.name}/'
533
+ f' <span class="sz">{fmt_size(e_sz, e_err)}</span></div>\n')
534
+ else:
535
+ f_sz = esz(entry)
536
+ sym = ""
537
+ if entry.is_symlink():
538
+ try: sym = f' {os.readlink(entry.path)}'
539
+ except OSError: sym = ' →'
540
+ ch += (f'<div class="item file">'
541
+ f'<span class="emoji">{get_emoji(entry.name)}</span>'
542
+ f'<span class="fname"> {entry.name}{sym}</span>'
543
+ f'<span class="sz"> {fmt_size(f_sz)}</span></div>\n')
405
544
 
406
545
  nd, nf = len(dirs), len(files)
407
546
  opened = " open" if depth == 0 else ""
408
547
  return (f'<details{opened}><summary>📁 <strong>{name}/</strong>'
409
- 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>'
410
549
  f'</summary><div class="ch">{ch}</div></details>\n')
411
550
 
412
551
  root_name = os.path.basename(root_path) or root_path
413
552
  tree = _node(root_path, 0, active_pats)
414
-
415
553
  return f'''<!DOCTYPE html>
416
554
  <html lang="ja"><head>
417
- <meta charset="UTF-8">
418
- <meta name="viewport" content="width=device-width,initial-scale=1">
555
+ <meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
419
556
  <title>dirlens — {root_name}</title>
420
557
  <style>
421
558
  *{{box-sizing:border-box;margin:0;padding:0}}
@@ -434,22 +571,32 @@ summary:hover{{background:rgba(255,255,255,.06)}}
434
571
  .ch{{border-left:1px solid rgba(255,255,255,.08);margin-left:10px}}
435
572
  .item{{padding:2px 6px;white-space:nowrap;margin-left:18px}}
436
573
  .item:hover{{background:rgba(255,255,255,.05);border-radius:4px}}
437
- .fname{{color:#a6e3a1}}
438
- .sz{{color:#585b70;font-size:12px}}
439
- .emoji{{width:1.6em;display:inline-block}}
440
- .hidden{{display:none!important}}
574
+ .fname{{color:#a6e3a1}}.sz{{color:#585b70;font-size:12px}}
575
+ .emoji{{width:1.6em;display:inline-block}}.hidden{{display:none!important}}
441
576
  </style></head><body>
442
577
  <h1>🌳 dirlens — {root_name}</h1>
443
578
  <input id="q" type="text" placeholder="ファイル名で検索…" oninput="search(this.value)">
444
579
  <div id="tree">{tree}</div>
445
580
  <script>
446
581
  function search(q){{
447
- q=q.toLowerCase();
582
+ q=q.toLowerCase().trim();
583
+ // まず全要素を表示に戻す
584
+ document.querySelectorAll('.hidden').forEach(el=>el.classList.remove('hidden'));
585
+ if(!q) return;
586
+ // 全ディレクトリを展開
587
+ document.querySelectorAll('details').forEach(d=>d.open=true);
588
+ // マッチしないファイルを非表示
448
589
  document.querySelectorAll('.file').forEach(el=>{{
449
590
  const n=el.querySelector('.fname')?.textContent.toLowerCase()||'';
450
- el.classList.toggle('hidden',!!q&&!n.includes(q));
591
+ if(!n.includes(q)) el.classList.add('hidden');
592
+ }});
593
+ // visible なファイルを1つも持たないディレクトリを非表示
594
+ // (空ディレクトリ・検索に引っかからないディレクトリ両方を処理)
595
+ document.querySelectorAll('#tree details').forEach(detail=>{{
596
+ const hasVisible=[...detail.querySelectorAll('.file')]
597
+ .some(f=>!f.classList.contains('hidden'));
598
+ if(!hasVisible) detail.classList.add('hidden');
451
599
  }});
452
- if(q) document.querySelectorAll('details').forEach(d=>d.open=true);
453
600
  }}
454
601
  </script></body></html>'''
455
602
 
@@ -465,53 +612,91 @@ def main():
465
612
  formatter_class=argparse.RawDescriptionHelpFormatter,
466
613
  epilog=(
467
614
  "使用例:\n"
468
- " dirlens --ai AIチャット貼り付け用形式で出力(推奨)\n"
469
- " dirlens -g -m -c gitignore除外→Markdown→クリップボードコピー\n"
470
- " dirlens --bar ディスク占有率バーを表示\n"
471
- " dirlens --min-size 1M 1MB以上のファイルのみ\n"
472
- " dirlens --exclude '*.log' パターンで除外(複数指定可)\n"
473
- " dirlens --include 'test_*' パターンで抽出(複数指定可)\n"
474
- " dirlens --emoji 絵文字アイコンを表示\n"
475
- " dirlens --json JSON形式で出力\n"
476
- " dirlens --html HTMLレポートを生成 (dirlens.html)\n"
477
- " dirlens --no-color カラーなしで表示\n"
478
- " dirlens > dirlens.txt dirlens.txt に書き出す"
615
+ " dirlens --ai AIチャット貼り付け用(推奨)\n"
616
+ " dirlens -d ディレクトリのみ表示(tree -d 互換)\n"
617
+ " dirlens -L 2 深さ 2 まで表示(tree -L 互換)\n"
618
+ " dirlens -t 更新日時順にソート(tree -t 互換)\n"
619
+ " dirlens -G --prune gitignore除外 + 空枝を剪定\n"
620
+ " dirlens -p -u -g パーミッション・ユーザー・グループ表示\n"
621
+ " dirlens -e py .pyのみ表示(旧 -t)\n"
622
+ " dirlens -S サイズ順ソート(旧 -s)\n"
623
+ " dirlens -C クリップボードにコピー(旧 -c)\n"
624
+ " dirlens --no-color > dirlens.txt ファイルに書き出す"
479
625
  ),
480
626
  )
481
- # ── 既存オプション ────────────────────────────────────────
482
- ap.add_argument("path", nargs="?", default=".")
483
- ap.add_argument("-d", "--depth", type=int, metavar="N")
627
+
628
+ # ── tree互換フラグ(5つ) ─────────────────────────────────
629
+ ap.add_argument("-d", action="store_true", dest="dirs_only",
630
+ help="ディレクトリのみ表示(tree -d 互換)")
631
+ ap.add_argument("-g", action="store_true", dest="show_group",
632
+ help="グループ名を表示(tree -g 互換)")
633
+ ap.add_argument("-s", action="store_true", dest="show_size_compat",
634
+ help="サイズ表示(常時有効・tree -s 互換)")
635
+ ap.add_argument("-t", action="store_true", dest="sort_mtime",
636
+ help="更新日時順にソート(tree -t 互換)")
637
+ ap.add_argument("-c", action="store_true", dest="sort_ctime",
638
+ help="ステータス変更日時順にソート(tree -c 互換)")
639
+
640
+ # ── dirlens独自フラグ(tree互換でないもの) ───────────────
641
+ ap.add_argument("-G", "--gitignore", action="store_true",
642
+ help=".gitignoreのファイルを除外(旧 -g)")
643
+ ap.add_argument("-S", "--sort-size", action="store_true",
644
+ help="サイズ順にソート(旧 -s)")
645
+ ap.add_argument("-e", "--type", metavar="EXT",
646
+ help="指定した拡張子のみ表示(旧 -t)")
647
+ ap.add_argument("-C", "--copy", action="store_true",
648
+ help="クリップボードにコピー(旧 -c)")
649
+
650
+ # ── tree互換フラグ(変更なし) ────────────────────────────
484
651
  ap.add_argument("-a", "--all", action="store_true")
485
- ap.add_argument("-s", "--sort-size", action="store_true")
486
- ap.add_argument("-g", "--gitignore", action="store_true")
652
+ ap.add_argument("-f", "--full-path", action="store_true", dest="full_path")
653
+ ap.add_argument("-l", "--follow", action="store_true")
654
+ ap.add_argument("-p", "--perms", action="store_true")
655
+ ap.add_argument("-u", "--user", action="store_true")
656
+ ap.add_argument("-r", "--reverse", action="store_true")
657
+ ap.add_argument("-n", dest="no_color_tree", action="store_true",
658
+ help="カラーなし(tree -n 互換)")
659
+ ap.add_argument("-J", dest="json_tree", action="store_true",
660
+ help="JSON形式で出力(tree -J 互換)")
661
+ ap.add_argument("-L", dest="level", type=int, metavar="N",
662
+ help="表示する最大の深さ(tree -L 互換)")
663
+ ap.add_argument("-D", dest="date_tree", action="store_true",
664
+ help="最終更新日時を表示(tree -D 互換)")
665
+ ap.add_argument("-P", dest="include_tree", metavar="PATTERN", action="append",
666
+ help="このパターンのみ表示(tree -P 互換)")
667
+ ap.add_argument("-I", dest="exclude_tree", metavar="PATTERN", action="append",
668
+ help="除外パターン(tree -I 互換)")
669
+
670
+ # ── dirlens独自オプション ─────────────────────────────────
671
+ ap.add_argument("path", nargs="?", default=".")
672
+ ap.add_argument("--depth", type=int, metavar="N",
673
+ help="表示する最大の深さ(-L と同じ)")
487
674
  ap.add_argument("--date", action="store_true")
488
- ap.add_argument("-t", "--type", metavar="EXT")
489
675
  ap.add_argument("-m", "--markdown", action="store_true")
490
676
  ap.add_argument("--no-color", action="store_true")
491
- # ── 新規オプション ────────────────────────────────────────
492
- ap.add_argument("--bar", action="store_true",
493
- help="親ディレクトリに対するディスク占有率バーを表示")
494
- ap.add_argument("--min-size", metavar="SIZE",
495
- help="指定サイズ以上のファイルのみ表示 (例: 1M, 500K)")
496
- ap.add_argument("--max-size", metavar="SIZE",
497
- help="指定サイズ以下のファイルのみ表示 (例: 10M)")
498
- ap.add_argument("--exclude", metavar="PATTERN", action="append",
499
- help="除外パターン(複数指定可)")
500
- ap.add_argument("--include", metavar="PATTERN", action="append",
501
- help="このパターンのみ表示(複数指定可)")
502
- ap.add_argument("--emoji", action="store_true",
503
- help="拡張子に応じた絵文字を表示")
504
- ap.add_argument("--json", action="store_true",
505
- help="JSON形式で標準出力に出力")
506
- ap.add_argument("--html", nargs="?", const="dirlens.html", metavar="FILE",
507
- help="HTMLレポートを生成 (デフォルト: dirlens.html)")
508
- ap.add_argument("-c", "--copy", action="store_true",
509
- help="出力をクリップボードにコピー")
510
- ap.add_argument("--ai", action="store_true",
511
- help="-g --date -m -c のショートカット。AIチャットへの貼り付けに最適化")
677
+ ap.add_argument("--bar", action="store_true")
678
+ ap.add_argument("--min-size", metavar="SIZE")
679
+ ap.add_argument("--max-size", metavar="SIZE")
680
+ ap.add_argument("--exclude", metavar="PATTERN", action="append")
681
+ ap.add_argument("--include", metavar="PATTERN", action="append")
682
+ ap.add_argument("--emoji", action="store_true")
683
+ ap.add_argument("--json", action="store_true")
684
+ ap.add_argument("--html", nargs="?", const="dirlens.html", metavar="FILE")
685
+ ap.add_argument("--prune", action="store_true")
686
+ ap.add_argument("--filesfirst", action="store_true")
687
+ ap.add_argument("--ai", action="store_true",
688
+ help="-G --date -m -C のショートカット")
512
689
  args = ap.parse_args()
513
690
 
514
- # --ai -g --date -m -c のショートカット
691
+ # ── エイリアスのマージ ────────────────────────────────────
692
+ if args.level is not None: args.depth = args.level
693
+ if args.date_tree: args.date = True
694
+ if args.include_tree: args.include = (args.include or []) + args.include_tree
695
+ if args.exclude_tree: args.exclude = (args.exclude or []) + args.exclude_tree
696
+ if args.no_color_tree: args.no_color = True
697
+ if args.json_tree: args.json = True
698
+
699
+ # --ai: -G --date -m -C のショートカット
515
700
  if args.ai:
516
701
  args.gitignore = True
517
702
  args.date = True
@@ -529,33 +714,30 @@ def main():
529
714
 
530
715
  cfg = Cfg(args, str(target))
531
716
  active_pats = load_gitignore(str(target)) if args.gitignore else []
717
+ _prefetch_sizes(str(target))
532
718
 
533
- # ── JSON ────────────────────────────────────────────────
719
+ # ── JSON ─────────────────────────────────────────────────
534
720
  if args.json:
535
721
  print(json.dumps(build_json_tree(str(target), 0, cfg, active_pats),
536
- ensure_ascii=False, indent=2))
537
- return
722
+ ensure_ascii=False, indent=2)); return
538
723
 
539
- # ── HTML ────────────────────────────────────────────────
724
+ # ── HTML ─────────────────────────────────────────────────
540
725
  if args.html:
541
726
  out = Path(args.html)
542
727
  out.write_text(generate_html(str(target), cfg, active_pats), encoding="utf-8")
543
- print(f"✓ {out} を生成しました ({fmt_size(out.stat().st_size)})")
544
- return
728
+ print(f"✓ {out} を生成しました ({fmt_size(out.stat().st_size)})"); return
545
729
 
546
- # ── テキスト出力(--copy はバッファ経由)──────────────────
730
+ # ── テキスト出力 ─────────────────────────────────────────
547
731
  if args.copy:
548
- _buf = io.StringIO()
549
- _old = sys.stdout
550
- sys.stdout = _buf
732
+ _buf = io.StringIO(); _old = sys.stdout; sys.stdout = _buf
733
+
734
+ if args.markdown: print("```")
551
735
 
552
- if args.markdown:
553
- print("```")
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)
554
739
 
555
- root_sz = dir_size(str(target))
556
- root_nd, root_nf = count_entries(str(target), cfg, active_pats)
557
- root_label = target.name if target.name else str(target)
558
- root_parts = [fmt_count(root_nd, root_nf), fmt_size(root_sz)]
740
+ root_parts = [fmt_count(root_nd, root_nf, root_denied), fmt_size(root_sz, root_sz_err)]
559
741
  if args.date:
560
742
  try: root_parts.append(fmt_date(target.stat().st_mtime))
561
743
  except OSError: pass
@@ -568,31 +750,33 @@ def main():
568
750
  render(str(target), "", 0, cfg, stats, active_pats)
569
751
 
570
752
  print()
571
- summary = f" 合計 {stats['dirs']} ディレクトリ, {stats['files']} ファイル"
572
- if args.gitignore: summary += " (.gitignore 適用済み)"
573
- if cfg.type_ext: summary += f" (フィルタ: {cfg.type_ext})"
574
- if cfg.excludes: summary += f" (除外: {', '.join(cfg.excludes)})"
575
- if cfg.includes: summary += f" (抽出: {', '.join(cfg.includes)})"
576
- if cfg.min_size: summary += f" (最小: {fmt_size(cfg.min_size)})"
577
- if cfg.max_size: summary += f" (最大: {fmt_size(cfg.max_size)})"
753
+ summary = f" 合計 {stats['dirs']} ディレクトリ"
754
+ if not cfg.dirs_only:
755
+ summary += f", {stats['files']} ファイル"
756
+ if args.gitignore: summary += " (.gitignore 適用済み)"
757
+ if cfg.type_ext: summary += f" (フィルタ: {cfg.type_ext})"
758
+ if cfg.excludes: summary += f" (除外: {', '.join(cfg.excludes)})"
759
+ if cfg.includes: summary += f" (抽出: {', '.join(cfg.includes)})"
760
+ if cfg.min_size: summary += f" (最小: {fmt_size(cfg.min_size)})"
761
+ if cfg.max_size: summary += f" (最大: {fmt_size(cfg.max_size)})"
762
+ if cfg.prune: summary += " (剪定済み)"
763
+ if cfg.dirs_only: summary += " (ディレクトリのみ)"
578
764
  print(c(summary, DIM))
579
765
 
580
- if stats["extensions"]:
766
+ if not cfg.dirs_only and stats["extensions"]:
581
767
  exts = sorted(stats["extensions"].items(), key=lambda x: -x[1])
582
768
  print(c(" " + " ".join(f"{e} ×{n}" for e, n in exts[:8]), DIM))
583
769
 
584
- if args.markdown:
585
- print("```")
770
+ if args.markdown: print("```")
586
771
 
587
- # ── クリップボードコピー ──────────────────────────────────
588
772
  if args.copy:
589
773
  sys.stdout = _old
590
774
  text = _buf.getvalue()
591
- print(text, end="") # ターミナルにはカラーつきで表示
592
- ok = copy_to_clipboard(strip_ansi(text)) # クリップボードにはANSIコードなし
593
- msg = "✓ クリップボードにコピーしました" if ok \
594
- else "✗ コピー失敗 (pbcopy / xclip / wl-copy が必要)"
595
- print(c(msg, BOLD, GREEN if ok else DIM), file=sys.stderr)
775
+ print(text, end="")
776
+ ok = copy_to_clipboard(strip_ansi(text))
777
+ print(c("✓ クリップボードにコピーしました" if ok
778
+ else "✗ コピー失敗 (pbcopy / xclip / wl-copy が必要)",
779
+ BOLD, GREEN if ok else DIM), file=sys.stderr)
596
780
 
597
781
 
598
782
  if __name__ == "__main__":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dirlens",
3
- "version": "1.0.8",
3
+ "version": "1.0.10",
4
4
  "description": "Directory tree viewer with file sizes / ファイルサイズ付きディレクトリツリー表示ツール",
5
5
  "bin": {
6
6
  "dirlens": "./bin/dirlens"