locdu 0.1.0__py3-none-any.whl

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.
locdu/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """locdu: ncdu for lines of code."""
2
+
3
+ __version__ = "0.1.0"
locdu/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
locdu/cli.py ADDED
@@ -0,0 +1,91 @@
1
+ """Command line entry point."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import os
7
+ import sys
8
+
9
+ from . import __version__
10
+ from .output import fmt, render_text, to_ncdu
11
+ from .scan import DEFAULT_EXCLUDES, METRICS, scan
12
+
13
+ EPILOG = """\
14
+ examples:
15
+ locdu browse the current directory
16
+ locdu ~/work/client/repo -x docs -x '*.md'
17
+ locdu -p -d 2 -n 10 print the top 10 per directory, two levels deep
18
+ locdu --ncdu - | ncdu -f - hand the counts to ncdu
19
+
20
+ In a git repo, the file list comes from git (tracked + untracked, minus .gitignore).
21
+ Lockfiles, *.min.js, *.min.css and *.map are skipped unless --no-default-excludes is set.
22
+ """
23
+
24
+
25
+ def _progress(done: int, total: int) -> None:
26
+ if done < total:
27
+ sys.stderr.write(f"\rscanning {fmt(done)}/{fmt(total)} files")
28
+ else:
29
+ sys.stderr.write("\r\033[K")
30
+ sys.stderr.flush()
31
+
32
+
33
+ def build_parser() -> argparse.ArgumentParser:
34
+ ap = argparse.ArgumentParser(
35
+ prog="locdu",
36
+ description="ncdu for lines of code: see where the lines in a codebase are.",
37
+ epilog=EPILOG,
38
+ formatter_class=argparse.RawDescriptionHelpFormatter,
39
+ )
40
+ ap.add_argument("dir", nargs="?", default=".", help="directory to scan (default: .)")
41
+ ap.add_argument("-x", "--exclude", action="append", default=[], metavar="GLOB",
42
+ help="skip files/dirs matching GLOB (a name anywhere, or a path relative to DIR); repeatable")
43
+ ap.add_argument("--no-default-excludes", action="store_true",
44
+ help="also count lockfiles, *.min.js, *.min.css and *.map")
45
+ ap.add_argument("--no-git", action="store_true",
46
+ help="walk the directory instead of asking git for the file list")
47
+ ap.add_argument("-m", "--metric", choices=METRICS, default="lines",
48
+ help="lines (all), code (non-blank) or files (default: lines)")
49
+ ap.add_argument("-p", "--print", dest="print_tree", action="store_true",
50
+ help="print a tree instead of the interactive view (default when output is not a terminal)")
51
+ ap.add_argument("-d", "--depth", type=int, default=1, metavar="N", help="with --print: levels to show (default: 1)")
52
+ ap.add_argument("-n", "--top", type=int, default=None, metavar="N", help="with --print: at most N entries per directory")
53
+ ap.add_argument("--ncdu", metavar="FILE", help="write ncdu's JSON format to FILE (- for stdout), for `ncdu -f`")
54
+ ap.add_argument("-V", "--version", action="version", version=f"%(prog)s {__version__}")
55
+ return ap
56
+
57
+
58
+ def main(argv: list[str] | None = None) -> int:
59
+ args = build_parser().parse_args(argv)
60
+ patterns = [p.rstrip("/") for p in args.exclude if p.rstrip("/")]
61
+ if not args.no_default_excludes:
62
+ patterns += DEFAULT_EXCLUDES
63
+ interactive = not (args.print_tree or args.ncdu) and sys.stdin.isatty() and sys.stdout.isatty()
64
+
65
+ def do_scan(show_progress: bool):
66
+ progress = _progress if show_progress and sys.stderr.isatty() else None
67
+ return scan(args.dir, patterns, use_git=not args.no_git, progress=progress)
68
+
69
+ try:
70
+ result = do_scan(show_progress=True)
71
+ if args.ncdu:
72
+ data = to_ncdu(result.root, args.metric)
73
+ if args.ncdu == "-":
74
+ json.dump(data, sys.stdout)
75
+ else:
76
+ with open(args.ncdu, "w", encoding="utf-8") as f:
77
+ json.dump(data, f)
78
+ elif interactive:
79
+ from .tui import run
80
+
81
+ run(result, lambda: do_scan(show_progress=False), args.metric)
82
+ else:
83
+ print(render_text(result.root, args.metric, max(1, args.depth), args.top))
84
+ except NotADirectoryError as e:
85
+ print(f"locdu: not a directory: {e}", file=sys.stderr)
86
+ return 2
87
+ except KeyboardInterrupt:
88
+ return 130
89
+ except BrokenPipeError:
90
+ os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
91
+ return 0
locdu/output.py ADDED
@@ -0,0 +1,61 @@
1
+ """Non-interactive output: a text tree and ncdu's JSON export format."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import time
6
+
7
+ from . import __version__
8
+ from .scan import Node
9
+
10
+
11
+ def fmt(n: int) -> str:
12
+ return f"{n:,}".replace(",", "'")
13
+
14
+
15
+ def printable(text: str) -> str:
16
+ """Show bytes of non-UTF-8 file names as \\xNN instead of failing to encode them."""
17
+ return os.fsencode(text).decode("utf-8", "backslashreplace")
18
+
19
+
20
+ def sorted_children(node: Node, metric: str) -> list[Node]:
21
+ return sorted(node.children.values(), key=lambda n: (-n.value(metric), n.name.lower()))
22
+
23
+
24
+ def render_text(root: Node, metric: str = "lines", depth: int = 1, top: int | None = None) -> str:
25
+ total = root.value(metric)
26
+ width = len(fmt(total))
27
+ out = [f"{fmt(total)} {metric} in {fmt(root.files)} files {root.name}"]
28
+
29
+ def walk(node: Node, level: int) -> None:
30
+ kids = sorted_children(node, metric)
31
+ shown = kids[:top] if top else kids
32
+ indent = " " * level
33
+ for kid in shown:
34
+ value = kid.value(metric)
35
+ pct = 100 * value / total if total else 0.0
36
+ suffix = "/" if kid.is_dir else ""
37
+ out.append(f"{fmt(value):>{width}} {pct:5.1f}% {indent}{kid.name}{suffix}")
38
+ if kid.is_dir and level + 1 < depth:
39
+ walk(kid, level + 1)
40
+ if len(kids) > len(shown):
41
+ out.append(f"{'':>{width}} {indent}... {len(kids) - len(shown)} more")
42
+
43
+ walk(root, 0)
44
+ return printable("\n".join(out))
45
+
46
+
47
+ def to_ncdu(root: Node, metric: str = "lines") -> list:
48
+ """ncdu export format; the metric goes into the size fields, so `ncdu -f` shows it as size."""
49
+
50
+ def emit(node: Node, name: str) -> list:
51
+ entries: list = [{"name": name}]
52
+ for kid in sorted(node.children.values(), key=lambda n: n.name):
53
+ if kid.is_dir:
54
+ entries.append(emit(kid, kid.name))
55
+ else:
56
+ value = kid.value(metric)
57
+ entries.append({"name": kid.name, "asize": value, "dsize": value})
58
+ return entries
59
+
60
+ meta = {"progname": "locdu", "progver": __version__, "timestamp": int(time.time())}
61
+ return [1, 0, meta, emit(root, root.name)]
locdu/scan.py ADDED
@@ -0,0 +1,233 @@
1
+ """Find files, count their lines and build the directory tree."""
2
+ from __future__ import annotations
3
+
4
+ import fnmatch
5
+ import os
6
+ import re
7
+ import stat
8
+ import subprocess
9
+ from concurrent.futures import ThreadPoolExecutor
10
+ from dataclasses import dataclass
11
+ from typing import Callable, Iterable
12
+
13
+ METRICS = ("lines", "code", "files")
14
+
15
+ # Only used without git; in a git repo, .gitignore decides.
16
+ SKIP_DIRS = {
17
+ ".git", ".hg", ".svn", "node_modules", ".next", ".nuxt", ".turbo", ".cache",
18
+ "dist", "build", "coverage", ".venv", "venv", "__pycache__", "vendor",
19
+ }
20
+
21
+ DEFAULT_EXCLUDES = [
22
+ "pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lock", "bun.lockb",
23
+ "composer.lock", "Cargo.lock", "poetry.lock", "uv.lock", "Gemfile.lock", "go.sum",
24
+ "*.min.js", "*.min.css", "*.map",
25
+ ]
26
+
27
+ SNIFF_BYTES = 8192
28
+ MAX_BYTES = 50 * 2**20 # bigger text files are data dumps, not code
29
+
30
+
31
+ class Node:
32
+ """A file or directory. Directory counts are the sums of their children."""
33
+
34
+ __slots__ = ("name", "parent", "children", "lines", "code", "files")
35
+
36
+ def __init__(self, name: str, parent: Node | None = None, is_dir: bool = False):
37
+ self.name = name
38
+ self.parent = parent
39
+ self.children: dict[str, Node] | None = {} if is_dir else None
40
+ self.lines = 0
41
+ self.code = 0
42
+ self.files = 0
43
+
44
+ @property
45
+ def is_dir(self) -> bool:
46
+ return self.children is not None
47
+
48
+ def value(self, metric: str) -> int:
49
+ return getattr(self, metric)
50
+
51
+ def relpath(self) -> str:
52
+ parts = []
53
+ node = self
54
+ while node.parent is not None:
55
+ parts.append(node.name)
56
+ node = node.parent
57
+ return "/".join(reversed(parts))
58
+
59
+ def find(self, relpath: str) -> Node | None:
60
+ node = self
61
+ for part in filter(None, relpath.split("/")):
62
+ if not node.is_dir or part not in node.children:
63
+ return None
64
+ node = node.children[part]
65
+ return node
66
+
67
+ def _add_to_ancestors(self, lines: int, code: int, files: int) -> None:
68
+ node = self.parent
69
+ while node is not None:
70
+ node.lines += lines
71
+ node.code += code
72
+ node.files += files
73
+ node = node.parent
74
+
75
+ def detach(self) -> None:
76
+ """Take this node out of the tree (view only) and update the totals above it."""
77
+ del self.parent.children[self.name]
78
+ self._add_to_ancestors(-self.lines, -self.code, -self.files)
79
+
80
+ def reattach(self) -> None:
81
+ self.parent.children[self.name] = self
82
+ self._add_to_ancestors(self.lines, self.code, self.files)
83
+
84
+ def set_counts(self, lines: int, code: int) -> None:
85
+ """Replace a file's counts, e.g. after it was edited."""
86
+ delta_lines, delta_code = lines - self.lines, code - self.code
87
+ self.lines, self.code = lines, code
88
+ self._add_to_ancestors(delta_lines, delta_code, 0)
89
+
90
+
91
+ @dataclass
92
+ class ScanResult:
93
+ root: Node
94
+ skipped: int # binary or larger than MAX_BYTES
95
+
96
+
97
+ def count_lines(path: str) -> tuple[int, int] | None:
98
+ """(lines, non-blank lines) of a text file; None if it is binary, too large or unreadable."""
99
+ try:
100
+ with open(path, "rb") as f:
101
+ data = f.read(SNIFF_BYTES)
102
+ if b"\0" in data or os.fstat(f.fileno()).st_size > MAX_BYTES:
103
+ return None
104
+ data += f.read()
105
+ except OSError:
106
+ return None
107
+ if not data:
108
+ return 0, 0
109
+ lines = data.count(b"\n") + (not data.endswith(b"\n"))
110
+ code = sum(1 for line in data.split(b"\n") if line.strip())
111
+ return lines, code
112
+
113
+
114
+ def exclude_matcher(patterns: Iterable[str]) -> Callable[[str], bool]:
115
+ """Test for relative paths: a pattern matches any single path component, or a path prefix."""
116
+ patterns = list(patterns)
117
+ if not patterns:
118
+ return lambda relpath: False
119
+ regex = re.compile("|".join(fnmatch.translate(p) for p in patterns))
120
+
121
+ def is_excluded(relpath: str) -> bool:
122
+ parts = relpath.split("/")
123
+ candidates = parts + ["/".join(parts[:i]) for i in range(2, len(parts) + 1)]
124
+ return any(regex.match(c) for c in candidates)
125
+
126
+ return is_excluded
127
+
128
+
129
+ def list_files(root: str, use_git: bool = True) -> list[str]:
130
+ """Candidate file paths relative to root."""
131
+ if use_git:
132
+ try:
133
+ out = subprocess.run(
134
+ ["git", "-C", root, "ls-files", "-z", "--cached", "--others", "--exclude-standard"],
135
+ capture_output=True,
136
+ check=True,
137
+ ).stdout
138
+ except (OSError, subprocess.CalledProcessError):
139
+ pass
140
+ else:
141
+ paths = []
142
+ for p in sorted({os.fsdecode(p).rstrip("/") for p in out.split(b"\0")} - {"", "."}):
143
+ full = os.path.join(root, p)
144
+ if os.path.isdir(full) and not os.path.islink(full):
145
+ # A submodule or nested repo: git lists only the directory, not its files.
146
+ paths.extend(f"{p}/{q}" for q in list_files(full))
147
+ else:
148
+ paths.append(p)
149
+ if paths:
150
+ return paths
151
+ # Not a repo, or git lists nothing here (e.g. a folder in .gitignore): walk it.
152
+
153
+ paths = []
154
+ for dirpath, dirnames, filenames in os.walk(root):
155
+ dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS)
156
+ rel = os.path.relpath(dirpath, root)
157
+ prefix = "" if rel == "." else rel.replace(os.sep, "/") + "/"
158
+ paths.extend(prefix + f for f in sorted(filenames))
159
+ return paths
160
+
161
+
162
+ def _count_regular(path: str) -> tuple[int, int] | None | bool:
163
+ """False for anything that is not a regular file (symlinks, deleted files, submodules)."""
164
+ try:
165
+ if not stat.S_ISREG(os.lstat(path).st_mode):
166
+ return False
167
+ except OSError:
168
+ return False
169
+ return count_lines(path)
170
+
171
+
172
+ def _insert(root: Node, relpath: str, lines: int, code: int) -> None:
173
+ *dirs, name = relpath.split("/")
174
+ node = root
175
+ for d in dirs:
176
+ child = node.children.get(d)
177
+ if child is None:
178
+ child = node.children[d] = Node(d, node, is_dir=True)
179
+ node = child
180
+ leaf = node.children[name] = Node(name, node)
181
+ leaf.lines, leaf.code, leaf.files = lines, code, 1
182
+ leaf._add_to_ancestors(lines, code, 1)
183
+
184
+
185
+ def scan(
186
+ root_dir: str,
187
+ excludes: Iterable[str] = (),
188
+ use_git: bool = True,
189
+ progress: Callable[[int, int], None] | None = None,
190
+ ) -> ScanResult:
191
+ root_dir = os.path.abspath(root_dir)
192
+ if not os.path.isdir(root_dir):
193
+ raise NotADirectoryError(root_dir)
194
+ is_excluded = exclude_matcher(excludes)
195
+ kept = [p for p in list_files(root_dir, use_git) if not is_excluded(p)]
196
+
197
+ root = Node(root_dir, is_dir=True)
198
+ skipped = 0
199
+ workers = min(32, (os.cpu_count() or 1) * 4)
200
+ with ThreadPoolExecutor(max_workers=workers) as pool:
201
+ counts = pool.map(lambda p: _count_regular(os.path.join(root_dir, p)), kept)
202
+ for i, (relpath, result) in enumerate(zip(kept, counts), 1):
203
+ if result is None:
204
+ skipped += 1
205
+ elif result is not False:
206
+ _insert(root, relpath, *result)
207
+ if progress and i % 500 == 0:
208
+ progress(i, len(kept))
209
+ if progress:
210
+ progress(len(kept), len(kept))
211
+ return ScanResult(root, skipped)
212
+
213
+
214
+ def file_type(name: str) -> str:
215
+ """Extension in lower case, or the whole name for files without one (Dockerfile, .gitignore)."""
216
+ ext = os.path.splitext(name)[1]
217
+ return ext.lower() if ext else name
218
+
219
+
220
+ def by_file_type(node: Node) -> list[tuple[str, int, int, int]]:
221
+ """(type, lines, code, files) for every file type below node."""
222
+ stats: dict[str, list[int]] = {}
223
+ stack = [node]
224
+ while stack:
225
+ n = stack.pop()
226
+ if n.is_dir:
227
+ stack.extend(n.children.values())
228
+ else:
229
+ s = stats.setdefault(file_type(n.name), [0, 0, 0])
230
+ s[0] += n.lines
231
+ s[1] += n.code
232
+ s[2] += 1
233
+ return [(k, *v) for k, v in stats.items()]
locdu/tui.py ADDED
@@ -0,0 +1,356 @@
1
+ """Interactive ncdu-style browser."""
2
+ from __future__ import annotations
3
+
4
+ import curses
5
+ import locale
6
+ import os
7
+ import shlex
8
+ import subprocess
9
+ from typing import Callable
10
+
11
+ from . import __version__
12
+ from .output import fmt, printable
13
+ from .scan import METRICS, Node, ScanResult, by_file_type, count_lines
14
+
15
+ METRIC_LABEL = {"lines": "lines", "code": "code lines", "files": "files"}
16
+
17
+ HELP = [
18
+ "up/down, j/k move",
19
+ "right, enter, l open directory",
20
+ "left, h, backspace parent directory",
21
+ "pgup/pgdn, g/G page up/down, top, bottom",
22
+ "",
23
+ "m metric: lines -> code lines -> files",
24
+ "s / n sort by count / by name (again: reverse)",
25
+ "t file types in this directory",
26
+ "e open the selected file in $EDITOR, recount after",
27
+ "d / u hide the selected item / undo (view only)",
28
+ "r rescan from disk",
29
+ "q quit",
30
+ "",
31
+ "lines = all lines, code = non-blank lines (comments count as code).",
32
+ "Binary files are skipped. Nothing on disk is ever changed.",
33
+ ]
34
+
35
+
36
+ def put(win, y: int, x: int, text: str, attr: int = 0) -> int:
37
+ """Write text clipped to the window; returns the x after it."""
38
+ h, w = win.getmaxyx()
39
+ if y < 0 or y >= h or x >= w:
40
+ return x
41
+ text = printable(text)[: w - x]
42
+ try:
43
+ win.addstr(y, x, text, attr)
44
+ except (curses.error, UnicodeEncodeError):
45
+ # curses raises after writing the bottom-right cell; the text is drawn anyway.
46
+ pass
47
+ return x + len(text)
48
+
49
+
50
+ def clip(text: str, width: int) -> str:
51
+ if width <= 0:
52
+ return ""
53
+ return text if len(text) <= width else text[: max(0, width - 3)] + "..."
54
+
55
+
56
+ class App:
57
+ def __init__(self, scr, result: ScanResult, rescan: Callable[[], ScanResult], metric: str):
58
+ self.scr = scr
59
+ self.rescan = rescan
60
+ self.metric = metric
61
+ self.sort_by = "count"
62
+ self.desc = True
63
+ self.hidden: list[Node] = []
64
+ self.msg = ""
65
+ self.load(result)
66
+ self.setup_colors()
67
+ scr.keypad(True)
68
+
69
+ def load(self, result: ScanResult, path: str = "") -> None:
70
+ self.result = result
71
+ self.root = result.root
72
+ node = self.root.find(path) or self.root
73
+ while not node.is_dir:
74
+ node = node.parent
75
+ self.cur = node
76
+ self.sel = 0
77
+ self.top = 0
78
+
79
+ def setup_colors(self) -> None:
80
+ self.c_bar = 0
81
+ self.c_dir = curses.A_BOLD
82
+ try:
83
+ curses.curs_set(0)
84
+ except curses.error:
85
+ pass
86
+ if os.environ.get("NO_COLOR") or not curses.has_colors():
87
+ return
88
+ curses.start_color()
89
+ try:
90
+ curses.use_default_colors()
91
+ bg = -1
92
+ except curses.error:
93
+ bg = curses.COLOR_BLACK
94
+ curses.init_pair(1, curses.COLOR_GREEN, bg)
95
+ curses.init_pair(2, curses.COLOR_CYAN, bg)
96
+ self.c_bar = curses.color_pair(1)
97
+ self.c_dir = curses.color_pair(2) | curses.A_BOLD
98
+
99
+ # --- data -------------------------------------------------------------
100
+
101
+ def items(self) -> list[Node]:
102
+ kids = sorted(self.cur.children.values(), key=lambda n: n.name.lower())
103
+ if self.sort_by == "count":
104
+ kids.sort(key=lambda n: n.value(self.metric), reverse=self.desc)
105
+ elif self.desc:
106
+ kids.reverse()
107
+ return kids
108
+
109
+ def display_path(self, node: Node) -> str:
110
+ rel = node.relpath()
111
+ full = os.path.join(self.root.name, rel) if rel else self.root.name
112
+ home = os.path.expanduser("~")
113
+ if full == home or full.startswith(home + os.sep):
114
+ full = "~" + full[len(home):]
115
+ return full
116
+
117
+ def sort_label(self) -> str:
118
+ arrow = "desc" if self.desc else "asc"
119
+ return f"{self.sort_by} {arrow}"
120
+
121
+ # --- drawing ----------------------------------------------------------
122
+
123
+ def list_height(self) -> int:
124
+ return max(1, self.scr.getmaxyx()[0] - 4)
125
+
126
+ def draw(self) -> None:
127
+ scr = self.scr
128
+ scr.erase()
129
+ h, w = scr.getmaxyx()
130
+ if h < 6 or w < 40:
131
+ put(scr, 0, 0, "terminal too small")
132
+ scr.refresh()
133
+ return
134
+
135
+ items = self.items()
136
+ m = self.metric
137
+ list_h = h - 4
138
+ self.sel = max(0, min(self.sel, len(items) - 1))
139
+ if self.sel < self.top:
140
+ self.top = self.sel
141
+ elif self.sel >= self.top + list_h:
142
+ self.top = self.sel - list_h + 1
143
+ self.top = max(0, min(self.top, max(0, len(items) - list_h)))
144
+
145
+ put(scr, 0, 0, f" locdu {__version__} ~ arrows to navigate, ? for help, q to quit".ljust(w), curses.A_REVERSE)
146
+ put(scr, 1, 0, f"--- {self.display_path(self.cur)} ".ljust(w, "-"))
147
+
148
+ total = self.cur.value(m)
149
+ biggest = max((n.value(m) for n in items), default=0)
150
+ count_w = max([len(fmt(n.value(m))) for n in items] + [5])
151
+ bar_w = 20 if w >= 100 else 10
152
+ for row, node in enumerate(items[self.top : self.top + list_h]):
153
+ y = 2 + row
154
+ selected = self.top + row == self.sel
155
+ base = curses.A_REVERSE if selected else 0
156
+ value = node.value(m)
157
+ pct = 100 * value / total if total else 0.0
158
+ filled = round(bar_w * value / biggest) if biggest else 0
159
+ x = put(scr, y, 0, f" {fmt(value):>{count_w}} {pct:5.1f}% [", base)
160
+ x = put(scr, y, x, "#" * filled, base | (0 if selected else self.c_bar))
161
+ x = put(scr, y, x, " " * (bar_w - filled) + "] ", base)
162
+ name = ("/" + node.name) if node.is_dir else (" " + node.name)
163
+ attr = base | (curses.A_BOLD if selected and node.is_dir else self.c_dir if node.is_dir else 0)
164
+ put(scr, y, x, clip(name, w - x).ljust(w - x), attr)
165
+ if not items:
166
+ put(scr, 2, 2, "(empty)", curses.A_DIM)
167
+
168
+ if items:
169
+ node = items[self.sel]
170
+ detail = (
171
+ f" {node.relpath()}{'/' if node.is_dir else ''}: {fmt(node.lines)} lines, "
172
+ f"{fmt(node.code)} code, {fmt(node.lines - node.code)} blank"
173
+ )
174
+ if node.is_dir:
175
+ detail += f", {fmt(node.files)} files"
176
+ put(scr, h - 2, 0, clip(detail, w), curses.A_DIM)
177
+
178
+ if self.msg:
179
+ status = f" {self.msg}"
180
+ else:
181
+ status = f" Total: {fmt(total)} {METRIC_LABEL[m]}"
182
+ if m != "files":
183
+ status += f" in {fmt(self.cur.files)} files"
184
+ status += f" | sort: {self.sort_label()}"
185
+ if self.hidden:
186
+ status += f" | hidden: {len(self.hidden)}"
187
+ if self.result.skipped:
188
+ status += f" | {fmt(self.result.skipped)} binary/large skipped"
189
+ put(scr, h - 1, 0, clip(status, w).ljust(w), curses.A_REVERSE)
190
+ scr.refresh()
191
+
192
+ def modal(self, title: str, lines: list[str]) -> None:
193
+ """Scrollable box over the current view; any key other than scrolling closes it."""
194
+ top = 0
195
+ while True:
196
+ self.draw()
197
+ h, w = self.scr.getmaxyx()
198
+ box_w = min(w - 2, max(len(title) + 6, max(map(len, lines), default=0) + 4))
199
+ box_h = min(h - 2, len(lines) + 2)
200
+ if box_w < 10 or box_h < 3:
201
+ return
202
+ win = curses.newwin(box_h, box_w, (h - box_h) // 2, (w - box_w) // 2)
203
+ win.keypad(True)
204
+ view = box_h - 2
205
+ top = max(0, min(top, len(lines) - view))
206
+ win.erase()
207
+ win.box()
208
+ put(win, 0, 2, f" {clip(title, box_w - 6)} ", curses.A_BOLD)
209
+ for i, line in enumerate(lines[top : top + view]):
210
+ put(win, 1 + i, 2, clip(line, box_w - 4))
211
+ hint = " up/down to scroll, any other key closes " if len(lines) > view else " any key closes "
212
+ put(win, box_h - 1, max(1, box_w - len(hint) - 2), clip(hint, box_w - 2))
213
+ win.refresh()
214
+ key = win.getch()
215
+ if key in (curses.KEY_UP, ord("k")):
216
+ top -= 1
217
+ elif key in (curses.KEY_DOWN, ord("j")):
218
+ top += 1
219
+ elif key == curses.KEY_PPAGE:
220
+ top -= view
221
+ elif key == curses.KEY_NPAGE:
222
+ top += view
223
+ elif key != curses.KEY_RESIZE:
224
+ return
225
+
226
+ # --- actions ----------------------------------------------------------
227
+
228
+ def go_up(self) -> None:
229
+ if self.cur.parent is None:
230
+ return
231
+ child, self.cur = self.cur, self.cur.parent
232
+ items = self.items()
233
+ self.sel = items.index(child) if child in items else 0
234
+
235
+ def open_selected(self, items: list[Node]) -> None:
236
+ if items and items[self.sel].is_dir:
237
+ self.cur = items[self.sel]
238
+ self.sel = 0
239
+ self.top = 0
240
+
241
+ def hide(self, items: list[Node]) -> None:
242
+ if not items:
243
+ return
244
+ node = items[self.sel]
245
+ node.detach()
246
+ self.hidden.append(node)
247
+ self.msg = f"hid {node.name} from the view, u to undo (files are untouched)"
248
+
249
+ def unhide(self) -> None:
250
+ if not self.hidden:
251
+ self.msg = "nothing hidden"
252
+ return
253
+ node = self.hidden.pop()
254
+ node.reattach()
255
+ self.msg = f"restored {node.relpath()}"
256
+
257
+ def edit(self, items: list[Node]) -> None:
258
+ if not items or items[self.sel].is_dir:
259
+ self.msg = "e opens files, not directories"
260
+ return
261
+ node = items[self.sel]
262
+ path = os.path.join(self.root.name, node.relpath())
263
+ editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or "vi"
264
+ curses.def_prog_mode()
265
+ curses.endwin()
266
+ try:
267
+ subprocess.call([*shlex.split(editor), path])
268
+ except OSError as e:
269
+ self.msg = f"could not start {editor}: {e.strerror}"
270
+ finally:
271
+ curses.reset_prog_mode()
272
+ try:
273
+ curses.curs_set(0)
274
+ except curses.error:
275
+ pass
276
+ self.scr.clear()
277
+ counts = count_lines(path)
278
+ if counts is not None:
279
+ node.set_counts(*counts)
280
+
281
+ def file_types(self) -> None:
282
+ idx = 1 + METRICS.index(self.metric)
283
+ rows = sorted(by_file_type(self.cur), key=lambda r: (-r[idx], r[0]))
284
+ total = self.cur.value(self.metric)
285
+ count_w = max([len(fmt(r[idx])) for r in rows] + [5])
286
+ lines = [
287
+ f"{fmt(r[idx]):>{count_w}} {100 * r[idx] / total if total else 0:5.1f}% {r[0]} ({fmt(r[3])} files)"
288
+ for r in rows
289
+ ]
290
+ title = f"File types in {self.display_path(self.cur)} by {METRIC_LABEL[self.metric]}"
291
+ self.modal(title, lines or ["(no files)"])
292
+
293
+ def do_rescan(self) -> None:
294
+ path = self.cur.relpath()
295
+ hidden = [n.relpath() for n in self.hidden]
296
+ h, w = self.scr.getmaxyx()
297
+ put(self.scr, h - 1, 0, " rescanning...".ljust(w), curses.A_REVERSE)
298
+ self.scr.refresh()
299
+ self.load(self.rescan(), path)
300
+ self.hidden = []
301
+ for rel in hidden:
302
+ node = self.root.find(rel)
303
+ if node is not None and node.parent is not None:
304
+ node.detach()
305
+ self.hidden.append(node)
306
+ self.msg = f"rescanned: {fmt(self.root.files)} files"
307
+
308
+ def loop(self) -> None:
309
+ while True:
310
+ self.draw()
311
+ key = self.scr.getch()
312
+ self.msg = ""
313
+ items = self.items()
314
+ if key == ord("q"):
315
+ return
316
+ elif key in (curses.KEY_UP, ord("k")):
317
+ self.sel -= 1
318
+ elif key in (curses.KEY_DOWN, ord("j")):
319
+ self.sel += 1
320
+ elif key == curses.KEY_PPAGE:
321
+ self.sel -= self.list_height()
322
+ elif key == curses.KEY_NPAGE:
323
+ self.sel += self.list_height()
324
+ elif key in (curses.KEY_HOME, ord("g")):
325
+ self.sel = 0
326
+ elif key in (curses.KEY_END, ord("G")):
327
+ self.sel = len(items) - 1
328
+ elif key in (curses.KEY_RIGHT, curses.KEY_ENTER, ord("l"), 10, 13):
329
+ self.open_selected(items)
330
+ elif key in (curses.KEY_LEFT, curses.KEY_BACKSPACE, ord("h"), 127, 8):
331
+ self.go_up()
332
+ elif key == ord("m"):
333
+ self.metric = METRICS[(METRICS.index(self.metric) + 1) % len(METRICS)]
334
+ self.msg = f"metric: {METRIC_LABEL[self.metric]}"
335
+ elif key in (ord("s"), ord("n")):
336
+ by = "count" if key == ord("s") else "name"
337
+ self.desc = not self.desc if self.sort_by == by else by == "count"
338
+ self.sort_by = by
339
+ elif key == ord("d"):
340
+ self.hide(items)
341
+ elif key == ord("u"):
342
+ self.unhide()
343
+ elif key == ord("e"):
344
+ self.edit(items)
345
+ elif key == ord("t"):
346
+ self.file_types()
347
+ elif key == ord("r"):
348
+ self.do_rescan()
349
+ elif key == ord("?"):
350
+ self.modal("locdu help", HELP)
351
+
352
+
353
+ def run(result: ScanResult, rescan: Callable[[], ScanResult], metric: str = "lines") -> None:
354
+ os.environ.setdefault("ESCDELAY", "25")
355
+ locale.setlocale(locale.LC_ALL, "")
356
+ curses.wrapper(lambda scr: App(scr, result, rescan, metric).loop())
@@ -0,0 +1,97 @@
1
+ Metadata-Version: 2.4
2
+ Name: locdu
3
+ Version: 0.1.0
4
+ Summary: ncdu for lines of code: browse where the lines in a codebase are
5
+ Author: Kevin Müller
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/kevmiota/locdu
8
+ Project-URL: Issues, https://github.com/kevmiota/locdu/issues
9
+ Keywords: loc,lines of code,cloc,ncdu,tui,curses
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console :: Curses
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: POSIX
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Software Development
16
+ Classifier: Topic :: Utilities
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Dynamic: license-file
21
+
22
+ # locdu
23
+
24
+ ncdu for lines of code. Scans a directory, counts the lines of every text file and lets
25
+ you drill into the tree interactively, largest first. Pure Python standard library, no
26
+ dependencies; Linux and macOS (needs curses).
27
+
28
+ ```
29
+ locdu [DIR] [-x GLOB]... [-m lines|code|files] [-p [-d N] [-n N]] [--ncdu FILE]
30
+ ```
31
+
32
+ ## Install
33
+
34
+ With [pipx](https://pipx.pypa.io) (Ubuntu: `sudo apt install pipx && pipx ensurepath`):
35
+
36
+ ```sh
37
+ pipx install locdu # from PyPI
38
+ pipx install git+https://github.com/kevmiota/locdu # latest main
39
+ ```
40
+
41
+ `uv tool install locdu` works too.
42
+
43
+ As a Debian/Ubuntu package, from the
44
+ [releases page](https://github.com/kevmiota/locdu/releases):
45
+
46
+ ```sh
47
+ sudo apt install ./locdu_0.1.0_all.deb
48
+ ```
49
+
50
+ Without pip, from a clone (needs only `python3`):
51
+
52
+ ```sh
53
+ ./install.sh # copies to ~/.local/share/locdu, launcher in ~/.local/bin
54
+ ./install.sh --uninstall
55
+ ```
56
+
57
+ ## What gets counted
58
+
59
+ - In a git repo: the files git knows about, tracked and untracked, minus `.gitignore`,
60
+ including nested repos and submodules. Elsewhere, in a folder git ignores, or with
61
+ `--no-git`: everything except `node_modules`, `dist`, `build`, `.venv`, `vendor` and
62
+ similar below DIR.
63
+ - Skipped by default: lockfiles, `*.min.js`, `*.min.css`, `*.map`
64
+ (`--no-default-excludes` counts them). Add your own with `-x`, repeatable:
65
+ a name or glob matches anywhere (`-x docs -x '*.md'`), a path matches relative
66
+ to DIR (`-x src/generated`).
67
+ - Binary files (a NUL byte in the first 8 KB), text files over 50 MB and symlinks
68
+ are skipped.
69
+ - `lines` = all lines, `code` = non-blank lines. Comments count as code; use
70
+ `scc` or `tokei` if you need comments separated.
71
+
72
+ ## Keys
73
+
74
+ | Key | |
75
+ |---|---|
76
+ | arrows, `hjkl`, enter, backspace | navigate |
77
+ | `m` | metric: lines, code lines, files |
78
+ | `s` / `n` | sort by count / name, again to reverse |
79
+ | `t` | file types in the current directory |
80
+ | `e` | open the file in `$EDITOR`, recounted afterwards |
81
+ | `d` / `u` | hide from the view / undo; nothing on disk is touched |
82
+ | `r` | rescan |
83
+ | `?` / `q` | help / quit |
84
+
85
+ ## Without the UI
86
+
87
+ ```sh
88
+ locdu -p -d 2 -n 10 # tree, two levels, top 10 per directory
89
+ locdu | less # prints automatically when stdout is not a terminal
90
+ locdu --ncdu - | ncdu -f - # hand the counts to ncdu (shown as sizes)
91
+ ```
92
+
93
+ ## Tests
94
+
95
+ ```sh
96
+ python3 -m unittest discover -s tests -v
97
+ ```
@@ -0,0 +1,12 @@
1
+ locdu/__init__.py,sha256=vTKKi4n-RXdosFeHL_ZMaJExqPCfWIdvfNY9ZKLDu9o,60
2
+ locdu/__main__.py,sha256=E6Gls0DNz8GQK2K-kOUIx8cYhgANW_CH54VKrfCfs14,52
3
+ locdu/cli.py,sha256=wd7VCuHuw0zL3sluib0TzCvA4AAImn4m5dXqco4SZso,3956
4
+ locdu/output.py,sha256=LO0QZRFEqs9yLXNYhnRzKmhYgWwjH2WWI76ikR3a_-Y,2215
5
+ locdu/scan.py,sha256=AQrgbLpUS83Huv1-YEP0Yizf2xwB5xKKZA62LybKYaw,8079
6
+ locdu/tui.py,sha256=z2xcgS64i1_FD24RP-SlEqB7lX1m7N2ejD9Geekyjkk,13452
7
+ locdu-0.1.0.dist-info/licenses/LICENSE,sha256=ApXF60TlqUsNK-tGl5DjMGxNAvqyAeWoD90c5Ko-2JE,1070
8
+ locdu-0.1.0.dist-info/METADATA,sha256=3ALi9wl1qCoNjgLx46h8i_G0kOG3tcYIvXRavcXb2ho,3156
9
+ locdu-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
10
+ locdu-0.1.0.dist-info/entry_points.txt,sha256=gWUGVjNoZtpHSouzvbi3F5SjQNsPUSDAZ9Me6nhGX0k,41
11
+ locdu-0.1.0.dist-info/top_level.txt,sha256=iVZbYVyK7rcjO_HBStEQAqfjZkpWYJa8KSd4f2l1GKA,6
12
+ locdu-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ locdu = locdu.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kevin Müller
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ locdu