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.
- package/README.md +103 -99
- package/dirlens.py +377 -193
- 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`
|
|
120
|
-
- **最終更新日時** — `--date`
|
|
121
|
-
- **拡張子フィルタ** — `-
|
|
122
|
-
- **パターンフィルタ** — `--exclude` /
|
|
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`
|
|
127
|
-
- **JSON出力** — `--json`
|
|
132
|
+
- **Markdown出力** — `-m` でコードブロック形式に出力
|
|
133
|
+
- **JSON出力** — `--json` / `-J` で機械可読な構造データを出力
|
|
128
134
|
- **HTMLレポート** — `--html` でブラウザで閲覧できる折りたたみツリーを生成
|
|
129
|
-
- **クリップボードコピー** — `-
|
|
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
|
|
145
|
-
|
|
146
|
-
#
|
|
147
|
-
dirlens
|
|
148
|
-
|
|
149
|
-
#
|
|
150
|
-
dirlens
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
#
|
|
156
|
-
dirlens -
|
|
157
|
-
|
|
158
|
-
#
|
|
159
|
-
dirlens
|
|
160
|
-
|
|
161
|
-
#
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
#
|
|
165
|
-
dirlens
|
|
166
|
-
|
|
167
|
-
#
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
#
|
|
171
|
-
dirlens
|
|
172
|
-
|
|
173
|
-
#
|
|
174
|
-
dirlens -
|
|
175
|
-
|
|
176
|
-
#
|
|
177
|
-
dirlens
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
#
|
|
183
|
-
dirlens --
|
|
184
|
-
dirlens
|
|
185
|
-
|
|
186
|
-
#
|
|
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`** | —
|
|
214
|
-
| `--depth N` | `-
|
|
215
|
-
| `--all` | `-a`
|
|
216
|
-
|
|
|
217
|
-
| `--
|
|
218
|
-
|
|
|
219
|
-
|
|
|
220
|
-
|
|
|
221
|
-
| `--
|
|
222
|
-
| `--
|
|
223
|
-
| `--
|
|
224
|
-
| `--
|
|
225
|
-
| `--
|
|
226
|
-
| `--
|
|
227
|
-
| `--
|
|
228
|
-
|
|
|
229
|
-
| `--
|
|
230
|
-
| `--
|
|
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
|
-
-
|
|
252
|
-
-
|
|
253
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
187
|
+
matched = fnmatch.fnmatch(rel, p.lstrip("/"))
|
|
158
188
|
else:
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
174
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
|
234
|
-
|
|
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
|
|
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.
|
|
295
|
-
|
|
296
|
-
|
|
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}{
|
|
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}{
|
|
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
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
.
|
|
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.
|
|
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
|
|
469
|
-
" dirlens -
|
|
470
|
-
" dirlens
|
|
471
|
-
" dirlens
|
|
472
|
-
" dirlens --
|
|
473
|
-
" dirlens
|
|
474
|
-
" dirlens
|
|
475
|
-
" dirlens
|
|
476
|
-
" dirlens
|
|
477
|
-
" dirlens --no-color
|
|
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
|
-
|
|
483
|
-
ap.add_argument("-d",
|
|
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("-
|
|
486
|
-
ap.add_argument("-
|
|
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("--
|
|
493
|
-
|
|
494
|
-
ap.add_argument("--
|
|
495
|
-
|
|
496
|
-
ap.add_argument("--
|
|
497
|
-
|
|
498
|
-
ap.add_argument("--
|
|
499
|
-
|
|
500
|
-
ap.add_argument("--
|
|
501
|
-
|
|
502
|
-
|
|
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
|
-
#
|
|
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
|
-
# ──
|
|
730
|
+
# ── テキスト出力 ─────────────────────────────────────────
|
|
547
731
|
if args.copy:
|
|
548
|
-
_buf = io.StringIO()
|
|
549
|
-
|
|
550
|
-
|
|
732
|
+
_buf = io.StringIO(); _old = sys.stdout; sys.stdout = _buf
|
|
733
|
+
|
|
734
|
+
if args.markdown: print("```")
|
|
551
735
|
|
|
552
|
-
|
|
553
|
-
|
|
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
|
-
|
|
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']}
|
|
572
|
-
if
|
|
573
|
-
|
|
574
|
-
if
|
|
575
|
-
if cfg.
|
|
576
|
-
if cfg.
|
|
577
|
-
if cfg.
|
|
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))
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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__":
|