dirlens 1.0.7 → 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 +95 -94
  2. package/dirlens.py +335 -161
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -111,23 +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チャットへの貼り付けが一発
130
- - **隠しファイル対応**`-a` で表示切り替え(アイテム数・統計にも反映)
135
+ - **クリップボードコピー** — `-c` で出力を自動コピー(ANSIコードを自動除去)
136
+ - **AIモード**`--ai` 一発で gitignore除外・日時・Markdown・クリップボードコピーを全適用
137
+ - **隠しファイル対応** — `-a` で表示切り替え
131
138
  - **サイズ順ソート** — `-s` で大きいものから表示
132
139
 
133
140
  ---
@@ -135,94 +142,86 @@ Desktop/ (2 dirs, 2 files, 3.74 MB)
135
142
  ## 使い方
136
143
 
137
144
  ```bash
138
- # カレントディレクトリを表示
139
- dirlens
140
-
141
- # 特定のディレクトリを表示
142
- dirlens ~/Desktop
143
-
144
- # 深さ 2 階層まで表示
145
- dirlens -d 2
146
-
147
- # 隠しファイル・ディレクトリ (.xxx) も表示
148
- dirlens -a
149
-
150
- # サイズの大きい順に並べる
151
- dirlens -s
152
-
153
- # .gitignore のファイルを除外(node_modules など)
154
- dirlens -g
155
-
156
- # 最終更新日時を相対表示(例: 3日前、2時間前)
157
- dirlens --date
158
-
159
- # ディスク占有率バーを表示
160
- dirlens --bar
161
-
162
- # 拡張子に応じた絵文字アイコンを表示
163
- dirlens --emoji
164
-
165
- # 指定した拡張子のファイルのみ表示
166
- dirlens -t py
167
-
168
- # パターンで除外(複数指定可)
169
- dirlens --exclude '*.log' --exclude 'dist'
170
-
171
- # パターンで抽出(複数指定可)
172
- dirlens --include 'test_*'
173
-
174
- # サイズで絞り込み
175
- dirlens --min-size 1M # 1MB 以上のみ
176
- dirlens --max-size 100K # 100KB 以下のみ
177
-
178
- # Markdown コードブロック形式で出力
179
- dirlens -m
180
-
181
- # JSON 形式で出力(スクリプト連携)
182
- dirlens --json
183
-
184
- # HTML レポートを生成(デフォルト: dirlens.html)
185
- dirlens --html
186
- dirlens --html report.html # ファイル名を指定
187
-
188
- # 出力をクリップボードにコピー
189
- dirlens -c
190
-
191
- # カラーなし(パイプ・ファイル書き出し向け)
192
- dirlens --no-color
193
-
194
- # ── AI チャットへの貼り付け(推奨の組み合わせ)────────────────
195
- # gitignore 除外 → Markdown → クリップボードコピー(そのまま貼れる)
196
- dirlens -g -m -c
197
-
198
- # テキストファイルに書き出す
199
- dirlens --no-color > dirlens.txt
145
+ # ── AI チャットへの貼り付け(最もよく使うコマンド)─────────────
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 # テキストファイルに書き出す
200
187
  ```
201
188
 
202
189
  ---
203
190
 
204
191
  ## オプション一覧
205
192
 
206
- | オプション | 省略形 | 説明 |
207
- |---------------------|----------|------------------------------------------------------------|
208
- | `path` | — | 対象ディレクトリ(省略時はカレント) |
209
- | `--depth N` | `-d N` | 表示する最大の深さ |
210
- | `--all` | `-a` | 隠しファイル・ディレクトリも表示 |
211
- | `--sort-size` | `-s` | サイズが大きい順に並べる |
212
- | `--gitignore` | `-g` | `.gitignore` に記載されたファイルを除外(サブディレクトリも対応)|
213
- | `--date` | | 最終更新日時を相対表示(例: 3日前) |
214
- | `--type EXT` | `-t EXT` | 指定した拡張子のファイルのみ表示(例: `-t py`) |
215
- | `--bar` | — | 親ディレクトリに対するディスク占有率バーを表示 |
216
- | `--emoji` | | 拡張子に応じた絵文字アイコンを表示 |
217
- | `--exclude PATTERN` | — | 除外パターン(複数指定可、例: `--exclude '*.log'`) |
218
- | `--include PATTERN` | | このパターンのみ表示(複数指定可) |
219
- | `--min-size SIZE` | — | 指定サイズ以上のファイルのみ表示(例: `1M`, `500K`) |
220
- | `--max-size SIZE` | | 指定サイズ以下のファイルのみ表示(例: `10M`) |
221
- | `--markdown` | `-m` | Markdown コードブロック形式で出力(カラー自動無効) |
222
- | `--json` | | JSON 形式で標準出力に出力 |
223
- | `--html [FILE]` | — | HTML レポートを生成(デフォルト: `dirlens.html`) |
224
- | `--copy` | `-c` | 出力をクリップボードにコピー |
225
- | `--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` | カラー表示を無効化 |
226
225
 
227
226
  ---
228
227
 
@@ -241,8 +240,10 @@ dirlens --no-color > dirlens.txt
241
240
  ## 仕様・注意事項
242
241
 
243
242
  - ディレクトリのサイズは **全サブファイルの合計**(隠しファイルを含む、`.gitignore` 対象も含む)
244
- - **シンボリックリンク先のディレクトリ**は展開せず `→` マークで表示
243
+ - ディレクトリサイズはルート直下を **並列プリフェッチ** して高速化(透過的な最適化)
244
+ - **シンボリックリンク** は `→ リンク先パス` で表示。`-l` でリンク先ディレクトリを展開(循環検出あり)
245
245
  - 権限がないディレクトリは `[アクセス拒否]` と表示してスキップ
246
- - 非常に深いディレクトリ(1万階層以上)は `-d` で深さを制限してください
247
- - **ホームフォルダ(`~/`)やルート(`/`)で実行すると固まる場合があります** — サイズ計算は `-d` の表示制限に関わらず底まで全再帰するため、`~/Library` や iCloud Drive など大容量・ネットワークマウントのディレクトリで時間がかかります。プロジェクトフォルダなど範囲を絞って実行してください
248
- - **`-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,30 +70,53 @@ 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
116
+
117
+ import re as _re
118
+ def strip_ansi(text):
119
+ return _re.sub(r'\033\[[0-9;]*[mK]', '', text)
96
120
 
97
121
 
98
122
  # ─── 絵文字 ───────────────────────────────────────────────────
@@ -123,7 +147,7 @@ def get_emoji(name, is_dir=False):
123
147
  return _EMOJI_NAME.get(lower) or _EMOJI_EXT.get(os.path.splitext(lower)[1], "📄")
124
148
 
125
149
 
126
- # ─── .gitignore ───────────────────────────────────────────────
150
+ # ─── .gitignore(否定パターン対応) ──────────────────────────
127
151
  _gi_cache = {}
128
152
 
129
153
  def load_gitignore(directory):
@@ -142,22 +166,28 @@ def load_gitignore(directory):
142
166
  return pats
143
167
 
144
168
  def is_ignored(name, rel_path, is_dir, patterns):
169
+ """パターンを順番に評価し最後にマッチしたルールが勝つ(!否定対応)。"""
145
170
  rel = rel_path.replace("\\", "/")
171
+ result = False
146
172
  for pat in patterns:
147
- if pat.startswith("!"): continue
148
- dir_only = pat.endswith("/")
149
- p = pat.rstrip("/")
150
- 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
151
180
  if p.startswith("/"):
152
- if fnmatch.fnmatch(rel, p.lstrip("/")): return True
181
+ matched = fnmatch.fnmatch(rel, p.lstrip("/"))
153
182
  else:
154
- if fnmatch.fnmatch(name, p): return True
155
- if fnmatch.fnmatch(rel, p): return True
156
- if fnmatch.fnmatch(rel, "*/" + p): return True
157
- 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
158
189
 
159
190
  def _extend_pats(active_pats, path, cfg):
160
- """サブディレクトリの .gitignore を読み込んでパターンを拡張する(ルートは除く)。"""
161
191
  if not cfg.use_gitignore: return active_pats
162
192
  if os.path.normpath(path) == os.path.normpath(cfg.root): return active_pats
163
193
  local = load_gitignore(path)
@@ -165,8 +195,10 @@ def _extend_pats(active_pats, path, cfg):
165
195
  rel_dir = os.path.relpath(path, cfg.root).replace("\\", "/")
166
196
  adjusted = []
167
197
  for pat in local:
168
- if not pat.startswith("!") and pat.startswith("/"):
169
- 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)
170
202
  else:
171
203
  adjusted.append(pat)
172
204
  return active_pats + adjusted
@@ -191,6 +223,16 @@ def dir_size(path):
191
223
  _sz_cache[path] = total
192
224
  return total
193
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
+
194
236
 
195
237
  # ─── 設定クラス ───────────────────────────────────────────────
196
238
  class Cfg:
@@ -198,21 +240,31 @@ class Cfg:
198
240
  self.root = root
199
241
  self.max_depth = args.depth
200
242
  self.show_all = args.all
201
- 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)
202
246
  self.show_date = args.date
203
- self.use_gitignore = args.gitignore
247
+ self.use_gitignore = args.gitignore # -G
204
248
  self.show_bar = args.bar
205
249
  self.min_size = parse_size(args.min_size) if args.min_size else None
206
250
  self.max_size = parse_size(args.max_size) if args.max_size else None
207
251
  self.excludes = args.exclude or []
208
252
  self.includes = args.include or []
209
253
  self.show_emoji = args.emoji
210
- 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
211
264
 
212
265
 
213
266
  # ─── 共通フィルタリング ───────────────────────────────────────
214
267
  def _filter(path, cfg, active_pats):
215
- """エントリを取得してフィルタリングする。"""
216
268
  try: raw = list(os.scandir(path))
217
269
  except PermissionError: return [], []
218
270
 
@@ -225,8 +277,20 @@ def _filter(path, cfg, active_pats):
225
277
  e.is_dir(follow_symlinks=False),
226
278
  active_pats)]
227
279
 
228
- dirs = [e for e in entries if e.is_dir(follow_symlinks=False)]
229
- 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))]
230
294
 
231
295
  if cfg.excludes:
232
296
  dirs = [d for d in dirs if not any(fnmatch.fnmatch(d.name, p) for p in cfg.excludes)]
@@ -254,16 +318,70 @@ def count_entries(path, cfg, active_pats):
254
318
  dirs, files = _filter(path, cfg, pats)
255
319
  return len(dirs), len(files)
256
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
+
257
360
 
258
361
  # ─── ツリー描画 ───────────────────────────────────────────────
259
362
  PIPE = "│ "; FORK = "├── "; LAST = "└── "; BLANK = " "
260
363
 
261
- def render(path, prefix, depth, cfg, stats, active_pats):
364
+ def render(path, prefix, depth, cfg, stats, active_pats, _seen=None):
262
365
  if cfg.max_depth is not None and depth >= cfg.max_depth: return
263
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
+
264
375
  cur_pats = _extend_pats(active_pats, path, cfg)
265
376
  dirs, files = _filter(path, cfg, cur_pats)
266
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
+
267
385
  def esz(e):
268
386
  try: return e.stat(follow_symlinks=True).st_size
269
387
  except OSError: return 0
@@ -271,22 +389,26 @@ def render(path, prefix, depth, cfg, stats, active_pats):
271
389
  try: return e.stat(follow_symlinks=True).st_mtime
272
390
  except OSError: return 0
273
391
 
274
- if cfg.by_size:
275
- dirs.sort(key=lambda e: dir_size(e.path), reverse=True)
276
- files.sort(key=lambda e: esz(e), reverse=True)
277
- else:
278
- dirs.sort(key=lambda e: e.name.casefold())
279
- files.sort(key=lambda e: e.name.casefold())
280
-
281
- combined = dirs + files
282
- cur_dir_size = dir_size(path)
283
-
284
392
  for i, entry in enumerate(combined):
285
393
  is_last = (i == len(combined) - 1)
286
394
  branch = LAST if is_last else FORK
287
395
  cont = BLANK if is_last else PIPE
288
396
 
289
- 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:
290
412
  sz = dir_size(entry.path)
291
413
  nd, nf = count_entries(entry.path, cfg, cur_pats)
292
414
  stats["dirs"] += 1
@@ -298,14 +420,13 @@ def render(path, prefix, depth, cfg, stats, active_pats):
298
420
  except OSError: pass
299
421
  bar = (" " + fmt_bar(sz, cur_dir_size)) if cfg.show_bar and cur_dir_size else ""
300
422
 
301
- name = c(f"{emoji}{entry.name}/", BOLD, CYAN)
423
+ name = c(f"{emoji}{display}{sym_target}/", BOLD, CYAN)
302
424
  meta = c(f"({', '.join(parts)}){bar}", DIM)
303
- print(f"{prefix}{branch}{name} {meta}")
304
- 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)
305
427
 
306
428
  else:
307
429
  sz = esz(entry)
308
- sym = " →" if entry.is_symlink() else ""
309
430
  stats["files"] += 1
310
431
  ext = os.path.splitext(entry.name)[1].lower()
311
432
  stats["extensions"][ext or "(no ext)"] = \
@@ -318,40 +439,39 @@ def render(path, prefix, depth, cfg, stats, active_pats):
318
439
  if mt: parts.append(fmt_date(mt))
319
440
  bar = (" " + fmt_bar(sz, cur_dir_size)) if cfg.show_bar and cur_dir_size else ""
320
441
 
321
- 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)
322
444
  meta = c(f"({', '.join(parts)}){bar}", DIM)
323
- print(f"{prefix}{branch}{name} {meta}")
445
+ print(f"{prefix}{branch}{perm_prefix}{name} {meta}")
324
446
 
325
447
 
326
448
  # ─── JSON出力 ─────────────────────────────────────────────────
327
449
  def build_json_tree(path, depth, cfg, active_pats):
328
450
  cur_pats = _extend_pats(active_pats, path, cfg)
329
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)
330
455
  sz = dir_size(path)
331
456
 
332
457
  def esz(e):
333
458
  try: return e.stat(follow_symlinks=True).st_size
334
459
  except OSError: return 0
335
460
 
336
- if cfg.by_size:
337
- dirs.sort(key=lambda e: dir_size(e.path), reverse=True)
338
- files.sort(key=lambda e: esz(e), reverse=True)
339
- else:
340
- dirs.sort(key=lambda e: e.name.casefold())
341
- files.sort(key=lambda e: e.name.casefold())
342
-
343
461
  children = []
344
462
  if cfg.max_depth is None or depth < cfg.max_depth:
345
- for d in dirs:
346
- children.append(build_json_tree(d.path, depth + 1, cfg, cur_pats))
347
- for f in files:
348
- f_sz = esz(f)
349
- children.append({
350
- "name": f.name, "type": "file",
351
- "size": f_sz, "size_human": fmt_size(f_sz),
352
- "ext": os.path.splitext(f.name)[1].lower(),
353
- "path": os.path.relpath(f.path, cfg.root),
354
- })
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
+ })
355
475
 
356
476
  name = os.path.basename(path) or path
357
477
  return {
@@ -368,6 +488,9 @@ def generate_html(root_path, cfg, active_pats):
368
488
  def _node(path, depth, cur_pats):
369
489
  pats = _extend_pats(cur_pats, path, cfg)
370
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)
371
494
  sz = dir_size(path)
372
495
  name = os.path.basename(path) or path
373
496
 
@@ -375,28 +498,25 @@ def generate_html(root_path, cfg, active_pats):
375
498
  try: return e.stat(follow_symlinks=True).st_size
376
499
  except OSError: return 0
377
500
 
378
- if cfg.by_size:
379
- dirs.sort(key=lambda e: dir_size(e.path), reverse=True)
380
- files.sort(key=lambda e: esz(e), reverse=True)
381
- else:
382
- dirs.sort(key=lambda e: e.name.casefold())
383
- files.sort(key=lambda e: e.name.casefold())
384
-
501
+ combined = (files + dirs) if cfg.files_first else (dirs + files)
385
502
  ch = ""
386
- if cfg.max_depth is None or depth < cfg.max_depth:
387
- for d in dirs:
388
- ch += _node(d.path, depth + 1, pats)
389
- else:
390
- for d in dirs:
391
- ch += (f'<div class="item dir-leaf">📁 {d.name}/'
392
- f' <span class="sz">{fmt_size(dir_size(d.path))}</span></div>\n')
393
-
394
- for f in files:
395
- f_sz = esz(f)
396
- ch += (f'<div class="item file">'
397
- f'<span class="emoji">{get_emoji(f.name)}</span>'
398
- f'<span class="fname"> {f.name}</span>'
399
- 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')
400
520
 
401
521
  nd, nf = len(dirs), len(files)
402
522
  opened = " open" if depth == 0 else ""
@@ -406,11 +526,9 @@ def generate_html(root_path, cfg, active_pats):
406
526
 
407
527
  root_name = os.path.basename(root_path) or root_path
408
528
  tree = _node(root_path, 0, active_pats)
409
-
410
529
  return f'''<!DOCTYPE html>
411
530
  <html lang="ja"><head>
412
- <meta charset="UTF-8">
413
- <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">
414
532
  <title>dirlens — {root_name}</title>
415
533
  <style>
416
534
  *{{box-sizing:border-box;margin:0;padding:0}}
@@ -429,22 +547,32 @@ summary:hover{{background:rgba(255,255,255,.06)}}
429
547
  .ch{{border-left:1px solid rgba(255,255,255,.08);margin-left:10px}}
430
548
  .item{{padding:2px 6px;white-space:nowrap;margin-left:18px}}
431
549
  .item:hover{{background:rgba(255,255,255,.05);border-radius:4px}}
432
- .fname{{color:#a6e3a1}}
433
- .sz{{color:#585b70;font-size:12px}}
434
- .emoji{{width:1.6em;display:inline-block}}
435
- .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}}
436
552
  </style></head><body>
437
553
  <h1>🌳 dirlens — {root_name}</h1>
438
554
  <input id="q" type="text" placeholder="ファイル名で検索…" oninput="search(this.value)">
439
555
  <div id="tree">{tree}</div>
440
556
  <script>
441
557
  function search(q){{
442
- 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
+ // マッチしないファイルを非表示
443
565
  document.querySelectorAll('.file').forEach(el=>{{
444
566
  const n=el.querySelector('.fname')?.textContent.toLowerCase()||'';
445
- 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');
446
575
  }});
447
- if(q) document.querySelectorAll('details').forEach(d=>d.open=true);
448
576
  }}
449
577
  </script></body></html>'''
450
578
 
@@ -460,49 +588,97 @@ def main():
460
588
  formatter_class=argparse.RawDescriptionHelpFormatter,
461
589
  epilog=(
462
590
  "使用例:\n"
463
- " dirlens -g -m -c gitignore除外→Markdown→クリップボードコピー\n"
464
- " dirlens --bar ディスク占有率バーを表示\n"
465
- " dirlens --min-size 1M 1MB以上のファイルのみ\n"
466
- " dirlens --exclude '*.log' パターンで除外(複数指定可)\n"
467
- " dirlens --include 'test_*' パターンで抽出(複数指定可)\n"
468
- " dirlens --emoji 絵文字アイコンを表示\n"
469
- " dirlens --json JSON形式で出力\n"
470
- " dirlens --html HTMLレポートを生成 (dirlens.html)\n"
471
- " dirlens --no-color カラーなしで表示\n"
472
- " 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 ファイルに書き出す"
473
601
  ),
474
602
  )
475
- # ── 既存オプション ────────────────────────────────────────
476
- ap.add_argument("path", nargs="?", default=".")
477
- 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互換フラグ(変更なし) ────────────────────────────
478
627
  ap.add_argument("-a", "--all", action="store_true")
479
- ap.add_argument("-s", "--sort-size", action="store_true")
480
- 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 と同じ)")
481
650
  ap.add_argument("--date", action="store_true")
482
- ap.add_argument("-t", "--type", metavar="EXT")
483
651
  ap.add_argument("-m", "--markdown", action="store_true")
484
652
  ap.add_argument("--no-color", action="store_true")
485
- # ── 新規オプション ────────────────────────────────────────
486
- ap.add_argument("--bar", action="store_true",
487
- help="親ディレクトリに対するディスク占有率バーを表示")
488
- ap.add_argument("--min-size", metavar="SIZE",
489
- help="指定サイズ以上のファイルのみ表示 (例: 1M, 500K)")
490
- ap.add_argument("--max-size", metavar="SIZE",
491
- help="指定サイズ以下のファイルのみ表示 (例: 10M)")
492
- ap.add_argument("--exclude", metavar="PATTERN", action="append",
493
- help="除外パターン(複数指定可)")
494
- ap.add_argument("--include", metavar="PATTERN", action="append",
495
- help="このパターンのみ表示(複数指定可)")
496
- ap.add_argument("--emoji", action="store_true",
497
- help="拡張子に応じた絵文字を表示")
498
- ap.add_argument("--json", action="store_true",
499
- help="JSON形式で標準出力に出力")
500
- ap.add_argument("--html", nargs="?", const="dirlens.html", metavar="FILE",
501
- help="HTMLレポートを生成 (デフォルト: dirlens.html)")
502
- ap.add_argument("-c", "--copy", action="store_true",
503
- help="出力をクリップボードにコピー")
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 のショートカット")
504
665
  args = ap.parse_args()
505
666
 
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 のショートカット
676
+ if args.ai:
677
+ args.gitignore = True
678
+ args.date = True
679
+ args.markdown = True
680
+ args.copy = True
681
+
506
682
  if args.no_color or args.markdown or args.json:
507
683
  USE_COLOR = False
508
684
 
@@ -514,28 +690,24 @@ def main():
514
690
 
515
691
  cfg = Cfg(args, str(target))
516
692
  active_pats = load_gitignore(str(target)) if args.gitignore else []
693
+ _prefetch_sizes(str(target))
517
694
 
518
- # ── JSON ────────────────────────────────────────────────
695
+ # ── JSON ─────────────────────────────────────────────────
519
696
  if args.json:
520
697
  print(json.dumps(build_json_tree(str(target), 0, cfg, active_pats),
521
- ensure_ascii=False, indent=2))
522
- return
698
+ ensure_ascii=False, indent=2)); return
523
699
 
524
- # ── HTML ────────────────────────────────────────────────
700
+ # ── HTML ─────────────────────────────────────────────────
525
701
  if args.html:
526
702
  out = Path(args.html)
527
703
  out.write_text(generate_html(str(target), cfg, active_pats), encoding="utf-8")
528
- print(f"✓ {out} を生成しました ({fmt_size(out.stat().st_size)})")
529
- return
704
+ print(f"✓ {out} を生成しました ({fmt_size(out.stat().st_size)})"); return
530
705
 
531
- # ── テキスト出力(--copy はバッファ経由)──────────────────
706
+ # ── テキスト出力 ─────────────────────────────────────────
532
707
  if args.copy:
533
- _buf = io.StringIO()
534
- _old = sys.stdout
535
- sys.stdout = _buf
708
+ _buf = io.StringIO(); _old = sys.stdout; sys.stdout = _buf
536
709
 
537
- if args.markdown:
538
- print("```")
710
+ if args.markdown: print("```")
539
711
 
540
712
  root_sz = dir_size(str(target))
541
713
  root_nd, root_nf = count_entries(str(target), cfg, active_pats)
@@ -553,31 +725,33 @@ def main():
553
725
  render(str(target), "", 0, cfg, stats, active_pats)
554
726
 
555
727
  print()
556
- summary = f" 合計 {stats['dirs']} ディレクトリ, {stats['files']} ファイル"
557
- if args.gitignore: summary += " (.gitignore 適用済み)"
558
- if cfg.type_ext: summary += f" (フィルタ: {cfg.type_ext})"
559
- if cfg.excludes: summary += f" (除外: {', '.join(cfg.excludes)})"
560
- if cfg.includes: summary += f" (抽出: {', '.join(cfg.includes)})"
561
- if cfg.min_size: summary += f" (最小: {fmt_size(cfg.min_size)})"
562
- 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 += " (ディレクトリのみ)"
563
739
  print(c(summary, DIM))
564
740
 
565
- if stats["extensions"]:
741
+ if not cfg.dirs_only and stats["extensions"]:
566
742
  exts = sorted(stats["extensions"].items(), key=lambda x: -x[1])
567
743
  print(c(" " + " ".join(f"{e} ×{n}" for e, n in exts[:8]), DIM))
568
744
 
569
- if args.markdown:
570
- print("```")
745
+ if args.markdown: print("```")
571
746
 
572
- # ── クリップボードコピー ──────────────────────────────────
573
747
  if args.copy:
574
748
  sys.stdout = _old
575
749
  text = _buf.getvalue()
576
750
  print(text, end="")
577
- ok = copy_to_clipboard(text)
578
- msg = "✓ クリップボードにコピーしました" if ok \
579
- else "✗ コピー失敗 (pbcopy / xclip / wl-copy が必要)"
580
- print(c(msg, BOLD, GREEN if ok else DIM), file=sys.stderr)
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)
581
755
 
582
756
 
583
757
  if __name__ == "__main__":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dirlens",
3
- "version": "1.0.7",
3
+ "version": "1.0.9",
4
4
  "description": "Directory tree viewer with file sizes / ファイルサイズ付きディレクトリツリー表示ツール",
5
5
  "bin": {
6
6
  "dirlens": "./bin/dirlens"