dirlens 1.0.1 → 1.0.3

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 CHANGED
@@ -8,13 +8,13 @@
8
8
  ## 出力例
9
9
 
10
10
  ```
11
- Desktop/ (3.74 MB)
12
- ├── EmptyDir/ (0 bytes)
13
- ├── Project/ (712 KB)
14
- │ ├── assets/ (512 KB)
15
- │ │ └── images/ (512 KB)
11
+ Desktop/ (2 dirs, 2 files, 3.74 MB)
12
+ ├── EmptyDir/ (0 dirs, 0 files, 0 bytes)
13
+ ├── Project/ (2 dirs, 1 file, 712 KB)
14
+ │ ├── assets/ (1 dir, 0 files, 512 KB)
15
+ │ │ └── images/ (0 dirs, 1 file, 512 KB)
16
16
  │ │ └── logo.png (512 KB)
17
- │ ├── src/ (80 KB)
17
+ │ ├── src/ (0 dirs, 1 file, 80 KB)
18
18
  │ │ └── util.py (80 KB)
19
19
  │ └── main.py (120 KB)
20
20
  ├── archive.zip (3 MB)
@@ -31,7 +31,8 @@ Desktop/ (3.74 MB)
31
31
  - **カラー表示** — ディレクトリ・ファイル・シンボリックリンクを色で識別
32
32
  - **自動サイズ変換** — bytes / KB / MB / GB / TB
33
33
  - **ディレクトリサイズ** — サブディレクトリの合計サイズを自動計算
34
- - **隠しファイル対応**`-a` で表示切り替え
34
+ - **アイテム数表示**各ディレクトリの直下にある dirs / files 数を表示
35
+ - **隠しファイル対応** — `-a` で表示切り替え(アイテム数にも反映)
35
36
  - **サイズ順ソート** — `-s` で大きいものから表示
36
37
 
37
38
  ---
@@ -62,16 +63,16 @@ npm uninstall -g dirlens
62
63
 
63
64
  ### macOS / Linux(スクリプト直接インストール)
64
65
 
65
- ```bash
66
- # 実行権限を付与
67
- chmod +x dirlens
66
+ GitHubリポジトリから `dirlens.py` をダウンロードして使用します。
68
67
 
68
+ ```bash
69
69
  # /usr/local/bin にインストール(どこからでも呼べるようになる)
70
- sudo cp dirlens /usr/local/bin/
70
+ sudo install -m 755 dirlens.py /usr/local/bin/dirlens
71
71
 
72
72
  # ── または sudo なしでユーザーローカルにインストール ──
73
73
  mkdir -p ~/.local/bin
74
- cp dirlens ~/.local/bin/
74
+ cp dirlens.py ~/.local/bin/dirlens
75
+ chmod +x ~/.local/bin/dirlens
75
76
 
76
77
  # ~/.zshrc(zsh)または ~/.bashrc(bash)に以下を追記:
77
78
  export PATH="$HOME/.local/bin:$PATH"
@@ -89,7 +90,7 @@ dirlens --help
89
90
 
90
91
  ### Windows(スクリプト直接インストール)
91
92
 
92
- 1. `dirlens` **`dirlens.py`** に改名して任意のフォルダへ置く
93
+ 1. `dirlens.py` `dirlens.bat` を任意のフォルダへ置く
93
94
  (例: `C:\Users\ユーザー名\bin\`)
94
95
 
95
96
  2. 同じフォルダに **`dirlens.bat`** を置く(同梱のものを使用):
@@ -178,3 +179,4 @@ dirlens --no-color > tree.txt
178
179
  - **シンボリックリンク先のディレクトリ**は展開せず `→` マークで表示
179
180
  - 権限がないディレクトリは `[アクセス拒否]` と表示してスキップ
180
181
  - 非常に深いディレクトリ(1万階層以上)は `-d` で深さを制限してください
182
+ - **ホームフォルダ(`~/`)やルート(`/`)で実行すると固まる場合があります** — サイズ計算は `-d` の表示制限に関わらず底まで全再帰するため、`~/Library` や iCloud Drive など大容量・ネットワークマウントのディレクトリで時間がかかります。プロジェクトフォルダなど範囲を絞って実行してください
package/bin/dirlens CHANGED
File without changes
package/dirlens.py CHANGED
@@ -7,6 +7,8 @@ dirlens – ファイルサイズ付きディレクトリツリー表示ツー
7
7
  import os
8
8
  import sys
9
9
  import argparse
10
+ import fnmatch
11
+ import datetime
10
12
  from pathlib import Path
11
13
 
12
14
  # ─── カラー設定 ──────────────────────────────────────────────
@@ -14,7 +16,6 @@ def _enable_color():
14
16
  if not hasattr(sys.stdout, "isatty") or not sys.stdout.isatty():
15
17
  return False
16
18
  if os.name == "nt":
17
- # Windows: VT100モードを有効にする
18
19
  try:
19
20
  import ctypes
20
21
  kernel32 = ctypes.windll.kernel32
@@ -22,8 +23,8 @@ def _enable_color():
22
23
  except Exception:
23
24
  pass
24
25
  return bool(
25
- os.environ.get("WT_SESSION") # Windows Terminal
26
- or os.environ.get("TERM_PROGRAM") # VS Code 等
26
+ os.environ.get("WT_SESSION")
27
+ or os.environ.get("TERM_PROGRAM")
27
28
  or os.environ.get("TERM")
28
29
  or os.environ.get("ANSICON")
29
30
  )
@@ -40,12 +41,11 @@ GREEN = "\033[32m"
40
41
  MAGENTA = "\033[35m"
41
42
 
42
43
  def c(text, *codes):
43
- """ANSIカラーを適用する。カラー無効時はそのまま返す。"""
44
44
  return ("".join(codes) + text + RESET) if USE_COLOR else text
45
45
 
46
+
46
47
  # ─── サイズ表示 ───────────────────────────────────────────────
47
48
  def fmt_size(n):
48
- """バイト数を人が読みやすい文字列に変換する。"""
49
49
  if n == 0:
50
50
  return "0 bytes"
51
51
  for unit, factor in (("TB", 1 << 40), ("GB", 1 << 30), ("MB", 1 << 20), ("KB", 1 << 10)):
@@ -54,11 +54,72 @@ def fmt_size(n):
54
54
  return f"{s} {unit}"
55
55
  return f"{n} {'byte' if n == 1 else 'bytes'}"
56
56
 
57
+
58
+ # ─── アイテム数表示 ────────────────────────────────────────────
59
+ def fmt_count(nd, nf):
60
+ d = f"{nd} {'dir' if nd == 1 else 'dirs'}"
61
+ f_str = f"{nf} {'file' if nf == 1 else 'files'}"
62
+ return f"{d}, {f_str}"
63
+
64
+
65
+ # ─── 日時表示 ─────────────────────────────────────────────────
66
+ def fmt_date(mtime):
67
+ sec = int((datetime.datetime.now() -
68
+ datetime.datetime.fromtimestamp(mtime)).total_seconds())
69
+ if sec < 60: return "今"
70
+ if sec < 3600: return f"{sec // 60}分前"
71
+ if sec < 86400: return f"{sec // 3600}時間前"
72
+ days = sec // 86400
73
+ if days == 1: return "昨日"
74
+ if days < 7: return f"{days}日前"
75
+ if days < 30: return f"{days // 7}週間前"
76
+ if days < 365: return f"{days // 30}ヶ月前"
77
+ return f"{days // 365}年前"
78
+
79
+
80
+ # ─── .gitignore サポート ──────────────────────────────────────
81
+ def load_gitignore(directory):
82
+ """指定ディレクトリの .gitignore パターンを読み込む。"""
83
+ patterns = []
84
+ path = os.path.join(directory, ".gitignore")
85
+ if os.path.isfile(path):
86
+ try:
87
+ with open(path, encoding="utf-8", errors="ignore") as f:
88
+ for line in f:
89
+ line = line.strip()
90
+ if line and not line.startswith("#"):
91
+ patterns.append(line)
92
+ except OSError:
93
+ pass
94
+ return patterns
95
+
96
+ def is_ignored(name, rel_path, is_dir, patterns):
97
+ """gitignore パターンにマッチするか判定する(簡易実装)。"""
98
+ rel = rel_path.replace("\\", "/")
99
+ for pat in patterns:
100
+ if pat.startswith("!"):
101
+ continue
102
+ dir_only = pat.endswith("/")
103
+ p = pat.rstrip("/")
104
+ if dir_only and not is_dir:
105
+ continue
106
+ if p.startswith("/"):
107
+ if fnmatch.fnmatch(rel, p.lstrip("/")):
108
+ return True
109
+ else:
110
+ if fnmatch.fnmatch(name, p):
111
+ return True
112
+ if fnmatch.fnmatch(rel, p):
113
+ return True
114
+ if fnmatch.fnmatch(rel, "*/" + p):
115
+ return True
116
+ return False
117
+
118
+
57
119
  # ─── ディレクトリサイズ(キャッシュ付き) ─────────────────────
58
120
  _cache = {}
59
121
 
60
122
  def dir_size(path):
61
- """ディレクトリ以下の合計バイト数を再帰的に計算する(シンボリックリンクは追わない)。"""
62
123
  if path in _cache:
63
124
  return _cache[path]
64
125
  total = 0
@@ -77,45 +138,112 @@ def dir_size(path):
77
138
  _cache[path] = total
78
139
  return total
79
140
 
141
+
142
+ # ─── アイテム数カウント ────────────────────────────────────────
143
+ def count_entries(path, show_all, active_pats, root, type_ext, use_gitignore=False):
144
+ try:
145
+ raw = list(os.scandir(path))
146
+ except OSError:
147
+ return 0, 0
148
+ entries = [e for e in raw if show_all or not e.name.startswith(".")]
149
+
150
+ # count_entries の対象ディレクトリ自身の .gitignore も反映する
151
+ local_pats = active_pats
152
+ if use_gitignore:
153
+ local = load_gitignore(path)
154
+ if local:
155
+ rel_dir = os.path.relpath(path, root).replace("\\", "/")
156
+ adjusted = []
157
+ for pat in local:
158
+ if not pat.startswith("!") and pat.startswith("/"):
159
+ adjusted.append("/" + rel_dir + pat)
160
+ else:
161
+ adjusted.append(pat)
162
+ local_pats = active_pats + adjusted
163
+
164
+ if local_pats:
165
+ entries = [e for e in entries
166
+ if not is_ignored(e.name,
167
+ os.path.relpath(e.path, root),
168
+ e.is_dir(follow_symlinks=False),
169
+ local_pats)]
170
+ nd = sum(1 for e in entries if e.is_dir(follow_symlinks=False))
171
+ files = [e for e in entries if not e.is_dir(follow_symlinks=False)]
172
+ if type_ext:
173
+ files = [f for f in files
174
+ if os.path.splitext(f.name)[1].lower() == type_ext]
175
+ return nd, len(files)
176
+
177
+
80
178
  # ─── ツリー描画 ───────────────────────────────────────────────
81
179
  PIPE = "│ "
82
180
  FORK = "├── "
83
181
  LAST = "└── "
84
182
  BLANK = " "
85
183
 
86
- def render(path, prefix, depth, max_depth, show_all, by_size, stats):
87
- """ディレクトリ内容を再帰的にツリー表示する。"""
184
+ def render(path, prefix, depth, opts, stats, active_pats):
185
+ """
186
+ active_pats: 現在の階層で有効な gitignore パターンの累積リスト。
187
+ サブディレクトリに入るたびにローカルの .gitignore を読み込んで追記する。
188
+ リストは新規作成して渡すため、兄弟ディレクトリには影響しない。
189
+ """
190
+ max_depth, show_all, by_size, show_date, use_gitignore, root, type_ext = opts
191
+
88
192
  if max_depth is not None and depth >= max_depth:
89
193
  return
90
194
 
195
+ # このディレクトリの .gitignore を読んでパターンを積み重ねる(ルートは main で読み済み)
196
+ if use_gitignore and depth > 0:
197
+ local = load_gitignore(path)
198
+ if local:
199
+ # アンカーパターン(/xxx)をルートからの相対パスに変換する
200
+ # 例: src/.gitignore の /build → /src/build
201
+ rel_dir = os.path.relpath(path, root).replace("\\", "/")
202
+ adjusted = []
203
+ for pat in local:
204
+ if not pat.startswith("!") and pat.startswith("/"):
205
+ adjusted.append("/" + rel_dir + pat)
206
+ else:
207
+ adjusted.append(pat)
208
+ active_pats = active_pats + adjusted # 新しいリストを作成(親に影響しない)
209
+
91
210
  try:
92
211
  raw = list(os.scandir(path))
93
212
  except PermissionError:
94
213
  print(f"{prefix}{LAST}{c('[アクセス拒否]', DIM)}")
95
214
  return
96
215
 
97
- # 隠しファイルのフィルタ(オプション依存)
98
216
  entries = [e for e in raw if show_all or not e.name.startswith(".")]
99
217
 
100
- # ディレクトリ(シンボリックリンク除く)とそれ以外(ファイル+シンボリックリンク)に分類
218
+ if active_pats:
219
+ entries = [e for e in entries
220
+ if not is_ignored(e.name,
221
+ os.path.relpath(e.path, root),
222
+ e.is_dir(follow_symlinks=False),
223
+ active_pats)]
224
+
101
225
  dirs = [e for e in entries if e.is_dir(follow_symlinks=False)]
102
226
  files = [e for e in entries if not e.is_dir(follow_symlinks=False)]
103
227
 
104
- def entry_size(e):
105
- try:
106
- return e.stat(follow_symlinks=True).st_size
107
- except OSError:
108
- return 0
228
+ if type_ext:
229
+ files = [f for f in files
230
+ if os.path.splitext(f.name)[1].lower() == type_ext]
231
+
232
+ def esz(e):
233
+ try: return e.stat(follow_symlinks=True).st_size
234
+ except OSError: return 0
235
+
236
+ def emtime(e):
237
+ try: return e.stat(follow_symlinks=True).st_mtime
238
+ except OSError: return 0
109
239
 
110
- # ソート:名前順 or サイズ順
111
240
  if by_size:
112
241
  dirs.sort(key=lambda e: dir_size(e.path), reverse=True)
113
- files.sort(key=lambda e: entry_size(e), reverse=True)
242
+ files.sort(key=lambda e: esz(e), reverse=True)
114
243
  else:
115
244
  dirs.sort(key=lambda e: e.name.casefold())
116
245
  files.sort(key=lambda e: e.name.casefold())
117
246
 
118
- # ディレクトリを先に、次にファイル
119
247
  combined = dirs + files
120
248
 
121
249
  for i, entry in enumerate(combined):
@@ -124,19 +252,41 @@ def render(path, prefix, depth, max_depth, show_all, by_size, stats):
124
252
  cont = BLANK if is_last else PIPE
125
253
 
126
254
  if entry.is_dir(follow_symlinks=False):
127
- sz = dir_size(entry.path)
255
+ sz = dir_size(entry.path)
256
+ nd, nf = count_entries(entry.path, show_all, active_pats, root, type_ext, use_gitignore)
128
257
  stats["dirs"] += 1
258
+
259
+ parts = [fmt_count(nd, nf), fmt_size(sz)]
260
+ if show_date:
261
+ try:
262
+ parts.append(fmt_date(entry.stat(follow_symlinks=False).st_mtime))
263
+ except OSError:
264
+ pass
265
+
129
266
  name = c(f"{entry.name}/", BOLD, CYAN)
130
- size = c(f"({fmt_size(sz)})", DIM)
131
- print(f"{prefix}{branch}{name} {size}")
132
- render(entry.path, prefix + cont, depth + 1, max_depth, show_all, by_size, stats)
267
+ meta = c(f"({', '.join(parts)})", DIM)
268
+ print(f"{prefix}{branch}{name} {meta}")
269
+ render(entry.path, prefix + cont, depth + 1, opts, stats, active_pats)
270
+
133
271
  else:
134
- sz = entry_size(entry)
272
+ sz = esz(entry)
135
273
  sym = " →" if entry.is_symlink() else ""
136
274
  stats["files"] += 1
275
+
276
+ ext = os.path.splitext(entry.name)[1].lower()
277
+ key = ext if ext else "(no ext)"
278
+ stats["extensions"][key] = stats["extensions"].get(key, 0) + 1
279
+
280
+ parts = [fmt_size(sz)]
281
+ if show_date:
282
+ mt = emtime(entry)
283
+ if mt:
284
+ parts.append(fmt_date(mt))
285
+
137
286
  name = c(f"{entry.name}{sym}", MAGENTA if entry.is_symlink() else GREEN)
138
- size = c(f"({fmt_size(sz)})", DIM)
139
- print(f"{prefix}{branch}{name} {size}")
287
+ meta = c(f"({', '.join(parts)})", DIM)
288
+ print(f"{prefix}{branch}{name} {meta}")
289
+
140
290
 
141
291
  # ─── エントリポイント ─────────────────────────────────────────
142
292
  def main():
@@ -154,6 +304,10 @@ def main():
154
304
  " dirlens -d 2 深さ 2 階層まで表示\n"
155
305
  " dirlens -a 隠しファイル (.xxx) も表示\n"
156
306
  " dirlens -s サイズの大きい順に表示\n"
307
+ " dirlens -g .gitignore のファイルを除外\n"
308
+ " dirlens --date 最終更新日時を表示\n"
309
+ " dirlens -t py .py ファイルのみ表示\n"
310
+ " dirlens -m Markdown コードブロックで出力\n"
157
311
  " dirlens --no-color カラーなしで表示"
158
312
  ),
159
313
  )
@@ -161,10 +315,14 @@ def main():
161
315
  ap.add_argument("-d", "--depth", type=int, metavar="N", help="表示する最大の深さ")
162
316
  ap.add_argument("-a", "--all", action="store_true", help="隠しファイルも表示する")
163
317
  ap.add_argument("-s", "--sort-size", action="store_true", help="サイズが大きい順に並べる")
318
+ ap.add_argument("-g", "--gitignore", action="store_true", help=".gitignore のファイルを除外する(サブディレクトリも対応)")
319
+ ap.add_argument("--date", action="store_true", help="最終更新日時を表示する")
320
+ ap.add_argument("-t", "--type", metavar="EXT", help="指定した拡張子のみ表示 (例: py, md)")
321
+ ap.add_argument("-m", "--markdown", action="store_true", help="Markdown コードブロックで出力")
164
322
  ap.add_argument("--no-color", action="store_true", help="カラー表示を無効化する")
165
323
  args = ap.parse_args()
166
324
 
167
- if args.no_color:
325
+ if args.no_color or args.markdown:
168
326
  USE_COLOR = False
169
327
 
170
328
  target = Path(args.path).resolve()
@@ -175,19 +333,51 @@ def main():
175
333
  print(f"エラー: '{args.path}' はディレクトリではありません", file=sys.stderr)
176
334
  sys.exit(1)
177
335
 
178
- # ドライブルート(Windows C:\ 等)対応
179
- root_label = target.name if target.name else str(target)
336
+ # ルートの .gitignore を起点として読み込む
337
+ active_pats = load_gitignore(str(target)) if args.gitignore else []
338
+ type_ext = ("." + args.type.lstrip(".")).lower() if args.type else None
339
+ opts = (args.depth, args.all, args.sort_size, args.date,
340
+ args.gitignore, str(target), type_ext)
341
+
342
+ if args.markdown:
343
+ print("```")
344
+
345
+ # ルートを表示
346
+ root_sz = dir_size(str(target))
347
+ root_nd, root_nf = count_entries(str(target), args.all, active_pats, str(target), type_ext, args.gitignore)
348
+ root_label = target.name if target.name else str(target)
349
+
350
+ parts = [fmt_count(root_nd, root_nf), fmt_size(root_sz)]
351
+ if args.date:
352
+ try:
353
+ parts.append(fmt_date(target.stat().st_mtime))
354
+ except OSError:
355
+ pass
180
356
 
181
- root_sz = dir_size(str(target))
182
357
  root_name = c(f"{root_label}/", BOLD, BLUE)
183
- root_size = c(f"({fmt_size(root_sz)})", DIM)
184
- print(f"{root_name} {root_size}")
358
+ root_meta = c(f"({', '.join(parts)})", DIM)
359
+ print(f"{root_name} {root_meta}")
185
360
 
186
- stats = {"files": 0, "dirs": 0}
187
- render(str(target), "", 0, args.depth, args.all, args.sort_size, stats)
361
+ stats = {"files": 0, "dirs": 0, "extensions": {}}
362
+ render(str(target), "", 0, opts, stats, active_pats)
188
363
 
364
+ # ─ サマリー ──────────────────────────────────────────────
189
365
  print()
190
- print(c(f" {stats['dirs']} ディレクトリ, {stats['files']} ファイル", DIM))
366
+ summary = f" {stats['dirs']} ディレクトリ, {stats['files']} ファイル"
367
+ if args.gitignore:
368
+ summary += " (.gitignore 適用済み)"
369
+ if type_ext:
370
+ summary += f" (フィルタ: {type_ext})"
371
+ print(c(summary, DIM))
372
+
373
+ if stats["extensions"]:
374
+ sorted_exts = sorted(stats["extensions"].items(), key=lambda x: -x[1])
375
+ ext_line = " " + " ".join(f"{ext} ×{n}" for ext, n in sorted_exts[:8])
376
+ print(c(ext_line, DIM))
377
+
378
+ if args.markdown:
379
+ print("```")
380
+
191
381
 
192
382
  if __name__ == "__main__":
193
383
  main()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dirlens",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Directory tree viewer with file sizes / ファイルサイズ付きディレクトリツリー表示ツール",
5
5
  "bin": {
6
6
  "dirlens": "./bin/dirlens"