dirlens 1.0.8 → 1.0.9

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