dirlens 1.0.2 → 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.
Files changed (2) hide show
  1. package/dirlens.py +219 -39
  2. package/package.json +1 -1
package/dirlens.py CHANGED
@@ -1,12 +1,14 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- dirlens – ファイルサイズ+アイテム数付きディレクトリツリー表示ツール
3
+ dirlens – ファイルサイズ付きディレクトリツリー表示ツール
4
4
  対応環境: macOS / Linux / Windows (Python 3.8+)
5
5
  """
6
6
 
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
  # ─── カラー設定 ──────────────────────────────────────────────
@@ -41,6 +43,7 @@ MAGENTA = "\033[35m"
41
43
  def c(text, *codes):
42
44
  return ("".join(codes) + text + RESET) if USE_COLOR else text
43
45
 
46
+
44
47
  # ─── サイズ表示 ───────────────────────────────────────────────
45
48
  def fmt_size(n):
46
49
  if n == 0:
@@ -51,6 +54,68 @@ def fmt_size(n):
51
54
  return f"{s} {unit}"
52
55
  return f"{n} {'byte' if n == 1 else 'bytes'}"
53
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
+
54
119
  # ─── ディレクトリサイズ(キャッシュ付き) ─────────────────────
55
120
  _cache = {}
56
121
 
@@ -73,24 +138,42 @@ def dir_size(path):
73
138
  _cache[path] = total
74
139
  return total
75
140
 
141
+
76
142
  # ─── アイテム数カウント ────────────────────────────────────────
77
- def count_items(path, show_all):
78
- """ディレクトリ直下のアイテム数 (num_dirs, num_files) を返す。"""
143
+ def count_entries(path, show_all, active_pats, root, type_ext, use_gitignore=False):
79
144
  try:
80
- entries = list(os.scandir(path))
145
+ raw = list(os.scandir(path))
81
146
  except OSError:
82
- return (0, 0)
83
- if not show_all:
84
- entries = [e for e in entries if not e.name.startswith(".")]
85
- nd = sum(1 for e in entries if e.is_dir(follow_symlinks=False))
86
- nf = sum(1 for e in entries if not e.is_dir(follow_symlinks=False))
87
- return (nd, nf)
88
-
89
- def fmt_meta(nd, nf, sz):
90
- """アイテム数+サイズをまとめて文字列化する。"""
91
- d_str = f"{nd} {'dir' if nd == 1 else 'dirs'}"
92
- f_str = f"{nf} {'file' if nf == 1 else 'files'}"
93
- return f"({d_str}, {f_str}, {fmt_size(sz)})"
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
+
94
177
 
95
178
  # ─── ツリー描画 ───────────────────────────────────────────────
96
179
  PIPE = "│ "
@@ -98,10 +181,32 @@ FORK = "├── "
98
181
  LAST = "└── "
99
182
  BLANK = " "
100
183
 
101
- def render(path, prefix, depth, max_depth, show_all, by_size, stats):
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
+
102
192
  if max_depth is not None and depth >= max_depth:
103
193
  return
104
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
+
105
210
  try:
106
211
  raw = list(os.scandir(path))
107
212
  except PermissionError:
@@ -109,18 +214,32 @@ def render(path, prefix, depth, max_depth, show_all, by_size, stats):
109
214
  return
110
215
 
111
216
  entries = [e for e in raw if show_all or not e.name.startswith(".")]
217
+
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
+
112
225
  dirs = [e for e in entries if e.is_dir(follow_symlinks=False)]
113
226
  files = [e for e in entries if not e.is_dir(follow_symlinks=False)]
114
227
 
115
- def entry_size(e):
116
- try:
117
- return e.stat(follow_symlinks=True).st_size
118
- except OSError:
119
- 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
120
239
 
121
240
  if by_size:
122
241
  dirs.sort(key=lambda e: dir_size(e.path), reverse=True)
123
- files.sort(key=lambda e: entry_size(e), reverse=True)
242
+ files.sort(key=lambda e: esz(e), reverse=True)
124
243
  else:
125
244
  dirs.sort(key=lambda e: e.name.casefold())
126
245
  files.sort(key=lambda e: e.name.casefold())
@@ -133,20 +252,41 @@ def render(path, prefix, depth, max_depth, show_all, by_size, stats):
133
252
  cont = BLANK if is_last else PIPE
134
253
 
135
254
  if entry.is_dir(follow_symlinks=False):
136
- sz = dir_size(entry.path)
137
- nd, nf = count_items(entry.path, show_all)
255
+ sz = dir_size(entry.path)
256
+ nd, nf = count_entries(entry.path, show_all, active_pats, root, type_ext, use_gitignore)
138
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
+
139
266
  name = c(f"{entry.name}/", BOLD, CYAN)
140
- meta = c(fmt_meta(nd, nf, sz), DIM)
267
+ meta = c(f"({', '.join(parts)})", DIM)
141
268
  print(f"{prefix}{branch}{name} {meta}")
142
- render(entry.path, prefix + cont, depth + 1, max_depth, show_all, by_size, stats)
269
+ render(entry.path, prefix + cont, depth + 1, opts, stats, active_pats)
270
+
143
271
  else:
144
- sz = entry_size(entry)
272
+ sz = esz(entry)
145
273
  sym = " →" if entry.is_symlink() else ""
146
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
+
147
286
  name = c(f"{entry.name}{sym}", MAGENTA if entry.is_symlink() else GREEN)
148
- size = c(f"({fmt_size(sz)})", DIM)
149
- print(f"{prefix}{branch}{name} {size}")
287
+ meta = c(f"({', '.join(parts)})", DIM)
288
+ print(f"{prefix}{branch}{name} {meta}")
289
+
150
290
 
151
291
  # ─── エントリポイント ─────────────────────────────────────────
152
292
  def main():
@@ -155,7 +295,7 @@ def main():
155
295
 
156
296
  ap = argparse.ArgumentParser(
157
297
  prog="dirlens",
158
- description="ファイルサイズ+アイテム数付きのディレクトリツリーを表示します",
298
+ description="ファイルサイズ付きのディレクトリツリーを表示します",
159
299
  formatter_class=argparse.RawDescriptionHelpFormatter,
160
300
  epilog=(
161
301
  "使用例:\n"
@@ -164,6 +304,10 @@ def main():
164
304
  " dirlens -d 2 深さ 2 階層まで表示\n"
165
305
  " dirlens -a 隠しファイル (.xxx) も表示\n"
166
306
  " dirlens -s サイズの大きい順に表示\n"
307
+ " dirlens -g .gitignore のファイルを除外\n"
308
+ " dirlens --date 最終更新日時を表示\n"
309
+ " dirlens -t py .py ファイルのみ表示\n"
310
+ " dirlens -m Markdown コードブロックで出力\n"
167
311
  " dirlens --no-color カラーなしで表示"
168
312
  ),
169
313
  )
@@ -171,10 +315,14 @@ def main():
171
315
  ap.add_argument("-d", "--depth", type=int, metavar="N", help="表示する最大の深さ")
172
316
  ap.add_argument("-a", "--all", action="store_true", help="隠しファイルも表示する")
173
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 コードブロックで出力")
174
322
  ap.add_argument("--no-color", action="store_true", help="カラー表示を無効化する")
175
323
  args = ap.parse_args()
176
324
 
177
- if args.no_color:
325
+ if args.no_color or args.markdown:
178
326
  USE_COLOR = False
179
327
 
180
328
  target = Path(args.path).resolve()
@@ -185,19 +333,51 @@ def main():
185
333
  print(f"エラー: '{args.path}' はディレクトリではありません", file=sys.stderr)
186
334
  sys.exit(1)
187
335
 
188
- root_label = target.name if target.name else str(target)
189
- root_sz = dir_size(str(target))
190
- nd, nf = count_items(str(target), args.all)
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
191
356
 
192
357
  root_name = c(f"{root_label}/", BOLD, BLUE)
193
- root_meta = c(fmt_meta(nd, nf, root_sz), DIM)
358
+ root_meta = c(f"({', '.join(parts)})", DIM)
194
359
  print(f"{root_name} {root_meta}")
195
360
 
196
- stats = {"files": 0, "dirs": 0}
197
- 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)
198
363
 
364
+ # ─ サマリー ──────────────────────────────────────────────
199
365
  print()
200
- 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
+
201
381
 
202
382
  if __name__ == "__main__":
203
383
  main()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dirlens",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "Directory tree viewer with file sizes / ファイルサイズ付きディレクトリツリー表示ツール",
5
5
  "bin": {
6
6
  "dirlens": "./bin/dirlens"