redwood-view 0.1.0__tar.gz
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.
- redwood_view-0.1.0/LICENSE +201 -0
- redwood_view-0.1.0/PKG-INFO +124 -0
- redwood_view-0.1.0/README.md +95 -0
- redwood_view-0.1.0/pyproject.toml +76 -0
- redwood_view-0.1.0/pyproject.toml.orig +56 -0
- redwood_view-0.1.0/src/redwood/__init__.py +0 -0
- redwood_view-0.1.0/src/redwood/__main__.py +3 -0
- redwood_view-0.1.0/src/redwood/browser.py +36 -0
- redwood_view-0.1.0/src/redwood/categories.py +60 -0
- redwood_view-0.1.0/src/redwood/cli.py +64 -0
- redwood_view-0.1.0/src/redwood/humanize.py +12 -0
- redwood_view-0.1.0/src/redwood/keys.py +38 -0
- redwood_view-0.1.0/src/redwood/scanner.py +111 -0
- redwood_view-0.1.0/src/redwood/treemap.py +98 -0
- redwood_view-0.1.0/src/redwood/ui.py +307 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
47
|
+
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
+
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
65
|
+
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
72
|
+
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
88
|
+
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or
|
|
95
|
+
Derivative Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work,
|
|
103
|
+
excluding those notices that do not pertain to any part of
|
|
104
|
+
the Derivative Works; and
|
|
105
|
+
|
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
|
108
|
+
include a readable copy of the attribution notices contained
|
|
109
|
+
within such NOTICE file, excluding those notices that do not
|
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
|
111
|
+
of the following places: within a NOTICE text file distributed
|
|
112
|
+
as part of the Derivative Works; within the Source form or
|
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
|
114
|
+
within a display generated by the Derivative Works, if and
|
|
115
|
+
wherever such third-party notices normally appear. The contents
|
|
116
|
+
of the NOTICE file are for informational purposes only and
|
|
117
|
+
do not modify the License. You may add Your own attribution
|
|
118
|
+
notices within Derivative Works that You distribute, alongside
|
|
119
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
120
|
+
that such additional attribution notices cannot be construed
|
|
121
|
+
as modifying the License.
|
|
122
|
+
|
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
|
124
|
+
may provide additional or different license terms and conditions
|
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
+
the conditions stated in this License.
|
|
129
|
+
|
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
+
this License, without any additional terms or conditions.
|
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
+
the terms of any separate license agreement you may have executed
|
|
136
|
+
with Licensor regarding such Contributions.
|
|
137
|
+
|
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
+
except as required for reasonable and customary use in describing the
|
|
141
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
142
|
+
|
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
|
152
|
+
|
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
|
158
|
+
incidental, or consequential damages of any character arising as a
|
|
159
|
+
result of this License or out of the use or inability to use the
|
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
+
other commercial damages or losses), even if such Contributor
|
|
163
|
+
has been advised of the possibility of such damages.
|
|
164
|
+
|
|
165
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
166
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
167
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
168
|
+
or other liability obligations and/or rights consistent with this
|
|
169
|
+
License. However, in accepting such obligations, You may act only
|
|
170
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
171
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
172
|
+
defend, and hold each Contributor harmless for any liability
|
|
173
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
174
|
+
of your accepting any such warranty or additional liability.
|
|
175
|
+
|
|
176
|
+
END OF TERMS AND CONDITIONS
|
|
177
|
+
|
|
178
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
179
|
+
|
|
180
|
+
To apply the Apache License to your work, attach the following
|
|
181
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
182
|
+
replaced with your own identifying information. (Don't include
|
|
183
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
184
|
+
comment syntax for the file format. We also recommend that a
|
|
185
|
+
file or class name and description of purpose be included on the
|
|
186
|
+
same "printed page" as the copyright notice for easier
|
|
187
|
+
identification within third-party archives.
|
|
188
|
+
|
|
189
|
+
Copyright [yyyy] [name of copyright owner]
|
|
190
|
+
|
|
191
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
|
+
you may not use this file except in compliance with the License.
|
|
193
|
+
You may obtain a copy of the License at
|
|
194
|
+
|
|
195
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
196
|
+
|
|
197
|
+
Unless required by applicable law or agreed to in writing, software
|
|
198
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
199
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
200
|
+
See the License for the specific language governing permissions and
|
|
201
|
+
limitations under the License.
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: redwood-view
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Console disk usage viewer: a treemap with an ncdu-style folder list
|
|
5
|
+
Keywords: disk usage,treemap,ncdu,diskonaut,sequoiaview,curses,tui
|
|
6
|
+
Author: Mykhailo Odintsov
|
|
7
|
+
License-Expression: Apache-2.0
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Environment :: Console :: Curses
|
|
11
|
+
Classifier: Intended Audience :: System Administrators
|
|
12
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
13
|
+
Classifier: Operating System :: MacOS
|
|
14
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Topic :: System :: Filesystems
|
|
23
|
+
Classifier: Topic :: Utilities
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Project-URL: Homepage, https://github.com/Templarrr/redwood_view
|
|
26
|
+
Project-URL: Source, https://github.com/Templarrr/redwood_view
|
|
27
|
+
Project-URL: Issues, https://github.com/Templarrr/redwood_view/issues
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# Redwood View
|
|
31
|
+
|
|
32
|
+
A console disk usage viewer in the spirit of SequoiaView: a treemap fills most of the
|
|
33
|
+
window, and an ncdu-style folder list on the right is used for navigation. The selected
|
|
34
|
+
entry is highlighted on the treemap, and blocks are coloured by file type so you can see
|
|
35
|
+
not only which folder is big but what is filling it.
|
|
36
|
+
|
|
37
|
+
Pure Python standard library (`curses`), so it works on any server you can `ssh` into.
|
|
38
|
+
|
|
39
|
+

|
|
40
|
+
|
|
41
|
+
Inside a folder: nested blocks of the same type are told apart by a dotted texture. Files too
|
|
42
|
+
small to get a cell of their own are merged into one block labelled "+N small items",
|
|
43
|
+
which keeps folders with tens of thousands of files quick to draw.
|
|
44
|
+
|
|
45
|
+

|
|
46
|
+
|
|
47
|
+
Without colour (`--no-color`, or a terminal that has none) each file type gets its own
|
|
48
|
+
character instead.
|
|
49
|
+
|
|
50
|
+

|
|
51
|
+
|
|
52
|
+
## Install
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
uv tool install redwood-view
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
or `pipx install redwood-view`. Either puts the `redwood` command on your `PATH`.
|
|
59
|
+
|
|
60
|
+
To get the latest unreleased code instead, install from the repo:
|
|
61
|
+
`uv tool install git+https://github.com/Templarrr/redwood_view`, or from a checkout,
|
|
62
|
+
`uv tool install .`
|
|
63
|
+
|
|
64
|
+
## Update
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
uv tool upgrade redwood-view # or: pipx upgrade redwood-view
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
If you installed from the repo, the same command fetches the latest commit, even when the
|
|
71
|
+
version number hasn't changed. A pinned commit or tag (`...redwood_view@<ref>`) stays
|
|
72
|
+
pinned, so install again without it instead:
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
uv tool install --reinstall git+https://github.com/Templarrr/redwood_view
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
From a checkout, `git pull` and then `uv tool install --reinstall .`
|
|
79
|
+
|
|
80
|
+
## Use
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
redwood / # scan everything
|
|
84
|
+
redwood ~/Videos # scan one folder
|
|
85
|
+
redwood -x / # stay on the root filesystem (skip /proc, mounted drives, ...)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Folders that can't be read are skipped and marked with `!` in the list.
|
|
89
|
+
|
|
90
|
+
| Key | Action |
|
|
91
|
+
|------------------------|---------------------------|
|
|
92
|
+
| Up / Down, `j` / `k` | move selection |
|
|
93
|
+
| PgUp / PgDn, Home / End| jump |
|
|
94
|
+
| Right / Enter | open folder |
|
|
95
|
+
| Left / Backspace | go to parent |
|
|
96
|
+
| `l` | toggle file type legend |
|
|
97
|
+
| `q` | quit |
|
|
98
|
+
|
|
99
|
+
Letter keys work by position on Russian, Ukrainian, Belarusian and Greek layouts too,
|
|
100
|
+
so `й` acts as `q` without switching to English.
|
|
101
|
+
|
|
102
|
+
Options: `--apparent-size` uses file sizes instead of disk usage, `--no-color` draws
|
|
103
|
+
file types with characters instead of colours.
|
|
104
|
+
|
|
105
|
+
## Development
|
|
106
|
+
|
|
107
|
+
```sh
|
|
108
|
+
uv sync
|
|
109
|
+
uv run pytest
|
|
110
|
+
uv run ruff check . && uv run ruff format --check .
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Releasing
|
|
114
|
+
|
|
115
|
+
Bump the version with `uv version --bump minor` (or `patch`, `major`), commit, then push a
|
|
116
|
+
matching tag:
|
|
117
|
+
|
|
118
|
+
```sh
|
|
119
|
+
git tag v0.2.0
|
|
120
|
+
git push origin v0.2.0
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The release workflow checks that the tag matches the version in `pyproject.toml`, runs
|
|
124
|
+
the tests, builds the package and publishes it to PyPI.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Redwood View
|
|
2
|
+
|
|
3
|
+
A console disk usage viewer in the spirit of SequoiaView: a treemap fills most of the
|
|
4
|
+
window, and an ncdu-style folder list on the right is used for navigation. The selected
|
|
5
|
+
entry is highlighted on the treemap, and blocks are coloured by file type so you can see
|
|
6
|
+
not only which folder is big but what is filling it.
|
|
7
|
+
|
|
8
|
+
Pure Python standard library (`curses`), so it works on any server you can `ssh` into.
|
|
9
|
+
|
|
10
|
+

|
|
11
|
+
|
|
12
|
+
Inside a folder: nested blocks of the same type are told apart by a dotted texture. Files too
|
|
13
|
+
small to get a cell of their own are merged into one block labelled "+N small items",
|
|
14
|
+
which keeps folders with tens of thousands of files quick to draw.
|
|
15
|
+
|
|
16
|
+

|
|
17
|
+
|
|
18
|
+
Without colour (`--no-color`, or a terminal that has none) each file type gets its own
|
|
19
|
+
character instead.
|
|
20
|
+
|
|
21
|
+

|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
uv tool install redwood-view
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
or `pipx install redwood-view`. Either puts the `redwood` command on your `PATH`.
|
|
30
|
+
|
|
31
|
+
To get the latest unreleased code instead, install from the repo:
|
|
32
|
+
`uv tool install git+https://github.com/Templarrr/redwood_view`, or from a checkout,
|
|
33
|
+
`uv tool install .`
|
|
34
|
+
|
|
35
|
+
## Update
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
uv tool upgrade redwood-view # or: pipx upgrade redwood-view
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
If you installed from the repo, the same command fetches the latest commit, even when the
|
|
42
|
+
version number hasn't changed. A pinned commit or tag (`...redwood_view@<ref>`) stays
|
|
43
|
+
pinned, so install again without it instead:
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
uv tool install --reinstall git+https://github.com/Templarrr/redwood_view
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
From a checkout, `git pull` and then `uv tool install --reinstall .`
|
|
50
|
+
|
|
51
|
+
## Use
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
redwood / # scan everything
|
|
55
|
+
redwood ~/Videos # scan one folder
|
|
56
|
+
redwood -x / # stay on the root filesystem (skip /proc, mounted drives, ...)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Folders that can't be read are skipped and marked with `!` in the list.
|
|
60
|
+
|
|
61
|
+
| Key | Action |
|
|
62
|
+
|------------------------|---------------------------|
|
|
63
|
+
| Up / Down, `j` / `k` | move selection |
|
|
64
|
+
| PgUp / PgDn, Home / End| jump |
|
|
65
|
+
| Right / Enter | open folder |
|
|
66
|
+
| Left / Backspace | go to parent |
|
|
67
|
+
| `l` | toggle file type legend |
|
|
68
|
+
| `q` | quit |
|
|
69
|
+
|
|
70
|
+
Letter keys work by position on Russian, Ukrainian, Belarusian and Greek layouts too,
|
|
71
|
+
so `й` acts as `q` without switching to English.
|
|
72
|
+
|
|
73
|
+
Options: `--apparent-size` uses file sizes instead of disk usage, `--no-color` draws
|
|
74
|
+
file types with characters instead of colours.
|
|
75
|
+
|
|
76
|
+
## Development
|
|
77
|
+
|
|
78
|
+
```sh
|
|
79
|
+
uv sync
|
|
80
|
+
uv run pytest
|
|
81
|
+
uv run ruff check . && uv run ruff format --check .
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Releasing
|
|
85
|
+
|
|
86
|
+
Bump the version with `uv version --bump minor` (or `patch`, `major`), commit, then push a
|
|
87
|
+
matching tag:
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
git tag v0.2.0
|
|
91
|
+
git push origin v0.2.0
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The release workflow checks that the tag matches the version in `pyproject.toml`, runs
|
|
95
|
+
the tests, builds the package and publishes it to PyPI.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "redwood-view"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Console disk usage viewer: a treemap with an ncdu-style folder list"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "Apache-2.0"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.10"
|
|
9
|
+
dependencies = []
|
|
10
|
+
keywords = [
|
|
11
|
+
"disk usage",
|
|
12
|
+
"treemap",
|
|
13
|
+
"ncdu",
|
|
14
|
+
"diskonaut",
|
|
15
|
+
"sequoiaview",
|
|
16
|
+
"curses",
|
|
17
|
+
"tui",
|
|
18
|
+
]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Development Status :: 4 - Beta",
|
|
21
|
+
"Environment :: Console :: Curses",
|
|
22
|
+
"Intended Audience :: System Administrators",
|
|
23
|
+
"Intended Audience :: End Users/Desktop",
|
|
24
|
+
"Operating System :: MacOS",
|
|
25
|
+
"Operating System :: POSIX :: Linux",
|
|
26
|
+
"Programming Language :: Python :: 3",
|
|
27
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
28
|
+
"Programming Language :: Python :: 3.10",
|
|
29
|
+
"Programming Language :: Python :: 3.11",
|
|
30
|
+
"Programming Language :: Python :: 3.12",
|
|
31
|
+
"Programming Language :: Python :: 3.13",
|
|
32
|
+
"Programming Language :: Python :: 3.14",
|
|
33
|
+
"Topic :: System :: Filesystems",
|
|
34
|
+
"Topic :: Utilities",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[[project.authors]]
|
|
38
|
+
name = "Mykhailo Odintsov"
|
|
39
|
+
|
|
40
|
+
[project.urls]
|
|
41
|
+
Homepage = "https://github.com/Templarrr/redwood_view"
|
|
42
|
+
Source = "https://github.com/Templarrr/redwood_view"
|
|
43
|
+
Issues = "https://github.com/Templarrr/redwood_view/issues"
|
|
44
|
+
|
|
45
|
+
[project.scripts]
|
|
46
|
+
redwood = "redwood.cli:main"
|
|
47
|
+
|
|
48
|
+
[dependency-groups]
|
|
49
|
+
dev = [
|
|
50
|
+
"pytest>=8",
|
|
51
|
+
"ruff>=0.6",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
[build-system]
|
|
55
|
+
requires = ["uv_build>=0.8,<0.12"]
|
|
56
|
+
build-backend = "uv_build"
|
|
57
|
+
|
|
58
|
+
[tool.uv.build-backend]
|
|
59
|
+
module-name = "redwood"
|
|
60
|
+
|
|
61
|
+
[tool.ruff]
|
|
62
|
+
line-length = 100
|
|
63
|
+
target-version = "py310"
|
|
64
|
+
|
|
65
|
+
[tool.ruff.lint]
|
|
66
|
+
select = [
|
|
67
|
+
"E",
|
|
68
|
+
"F",
|
|
69
|
+
"W",
|
|
70
|
+
"I",
|
|
71
|
+
"B",
|
|
72
|
+
"UP",
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
[tool.pytest.ini_options]
|
|
76
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "redwood-view"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Console disk usage viewer: a treemap with an ncdu-style folder list"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "Apache-2.0"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [{ name = "Mykhailo Odintsov" }]
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
dependencies = []
|
|
11
|
+
keywords = ["disk usage", "treemap", "ncdu", "diskonaut", "sequoiaview", "curses", "tui"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 4 - Beta",
|
|
14
|
+
"Environment :: Console :: Curses",
|
|
15
|
+
"Intended Audience :: System Administrators",
|
|
16
|
+
"Intended Audience :: End Users/Desktop",
|
|
17
|
+
"Operating System :: MacOS",
|
|
18
|
+
"Operating System :: POSIX :: Linux",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Programming Language :: Python :: 3.14",
|
|
26
|
+
"Topic :: System :: Filesystems",
|
|
27
|
+
"Topic :: Utilities",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/Templarrr/redwood_view"
|
|
32
|
+
Source = "https://github.com/Templarrr/redwood_view"
|
|
33
|
+
Issues = "https://github.com/Templarrr/redwood_view/issues"
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
redwood = "redwood.cli:main"
|
|
37
|
+
|
|
38
|
+
[dependency-groups]
|
|
39
|
+
dev = ["pytest>=8", "ruff>=0.6"]
|
|
40
|
+
|
|
41
|
+
[build-system]
|
|
42
|
+
requires = ["uv_build>=0.8,<0.12"]
|
|
43
|
+
build-backend = "uv_build"
|
|
44
|
+
|
|
45
|
+
[tool.uv.build-backend]
|
|
46
|
+
module-name = "redwood"
|
|
47
|
+
|
|
48
|
+
[tool.ruff]
|
|
49
|
+
line-length = 100
|
|
50
|
+
target-version = "py310"
|
|
51
|
+
|
|
52
|
+
[tool.ruff.lint]
|
|
53
|
+
select = ["E", "F", "W", "I", "B", "UP"]
|
|
54
|
+
|
|
55
|
+
[tool.pytest.ini_options]
|
|
56
|
+
testpaths = ["tests"]
|
|
File without changes
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from redwood.scanner import Node
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Browser:
|
|
5
|
+
def __init__(self, root: Node):
|
|
6
|
+
self.root = root
|
|
7
|
+
self.current = root
|
|
8
|
+
self.index = 0
|
|
9
|
+
|
|
10
|
+
@property
|
|
11
|
+
def entries(self) -> list[Node]:
|
|
12
|
+
return self.current.children
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def selected(self) -> Node | None:
|
|
16
|
+
return self.entries[self.index] if self.entries else None
|
|
17
|
+
|
|
18
|
+
def move(self, delta: int) -> None:
|
|
19
|
+
if self.entries:
|
|
20
|
+
self.index = max(0, min(len(self.entries) - 1, self.index + delta))
|
|
21
|
+
|
|
22
|
+
def move_to(self, index: int) -> None:
|
|
23
|
+
self.move(index - self.index)
|
|
24
|
+
|
|
25
|
+
def enter(self) -> None:
|
|
26
|
+
node = self.selected
|
|
27
|
+
if node is not None and node.is_dir:
|
|
28
|
+
self.current = node
|
|
29
|
+
self.index = 0
|
|
30
|
+
|
|
31
|
+
def leave(self) -> None:
|
|
32
|
+
if self.current.parent is None:
|
|
33
|
+
return
|
|
34
|
+
came_from = self.current
|
|
35
|
+
self.current = came_from.parent
|
|
36
|
+
self.index = self.current.children.index(came_from)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import stat
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class Category:
|
|
8
|
+
name: str
|
|
9
|
+
color: int # ANSI colour index, same numbering as curses.COLOR_*
|
|
10
|
+
char: str # fill used when the terminal has no colour
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_EXTENSIONS = {
|
|
14
|
+
"video": "mp4 mkv avi mov wmv flv webm m4v mpg mpeg vob 3gp m2ts mts",
|
|
15
|
+
"audio": "mp3 flac wav ogg oga m4a aac wma opus aiff ape mid midi",
|
|
16
|
+
"image": "jpg jpeg png gif bmp tif tiff webp svg heic heif raw cr2 nef arw dng psd xcf ico",
|
|
17
|
+
"archive": (
|
|
18
|
+
"zip tar gz tgz bz2 xz zst 7z rar lz4 lzma iso img dmg deb rpm jar whl apk "
|
|
19
|
+
"snap squashfs qcow2 vmdk vdi vhd vhdx"
|
|
20
|
+
),
|
|
21
|
+
"document": (
|
|
22
|
+
"pdf doc docx xls xlsx ppt pptx odt ods odp rtf txt md rst epub djvu csv tex "
|
|
23
|
+
"html htm xml json yaml yml toml log"
|
|
24
|
+
),
|
|
25
|
+
"code": (
|
|
26
|
+
"py pyi c h cc cpp hpp cxx js mjs ts tsx jsx java kt scala go rs rb php pl sh bash "
|
|
27
|
+
"zsh lua cs swift sql css scss vue ipynb el hs ml"
|
|
28
|
+
),
|
|
29
|
+
"binary": "so dll exe bin o a lib dylib pyc class ko wasm dat db sqlite pack idx",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
CATEGORIES = [
|
|
33
|
+
Category("video", 1, "#"),
|
|
34
|
+
Category("audio", 5, "~"),
|
|
35
|
+
Category("image", 3, "%"),
|
|
36
|
+
Category("archive", 4, "="),
|
|
37
|
+
Category("document", 6, "+"),
|
|
38
|
+
Category("code", 2, "*"),
|
|
39
|
+
Category("binary", 7, "@"),
|
|
40
|
+
Category("other", 0, "."),
|
|
41
|
+
]
|
|
42
|
+
OTHER = len(CATEGORIES) - 1
|
|
43
|
+
BINARY = next(i for i, c in enumerate(CATEGORIES) if c.name == "binary")
|
|
44
|
+
|
|
45
|
+
_BY_EXT = {
|
|
46
|
+
ext: next(i for i, c in enumerate(CATEGORIES) if c.name == name)
|
|
47
|
+
for name, exts in _EXTENSIONS.items()
|
|
48
|
+
for ext in exts.split()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def categorize(name: str, mode: int = 0) -> int:
|
|
53
|
+
lower = name.lower()
|
|
54
|
+
ext = os.path.splitext(lower)[1][1:]
|
|
55
|
+
if ext in _BY_EXT:
|
|
56
|
+
return _BY_EXT[ext]
|
|
57
|
+
# Versioned shared libraries (libfoo.so.6) and executables like python3.13.
|
|
58
|
+
if ".so." in lower or (stat.S_ISREG(mode) and mode & 0o111):
|
|
59
|
+
return BINARY
|
|
60
|
+
return OTHER
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
from redwood.humanize import human_size
|
|
7
|
+
from redwood.scanner import scan
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
11
|
+
parser = argparse.ArgumentParser(
|
|
12
|
+
prog="redwood", description="Show disk usage as a treemap with a navigable folder list."
|
|
13
|
+
)
|
|
14
|
+
parser.add_argument("path", nargs="?", default=".", help="folder to scan (default: .)")
|
|
15
|
+
parser.add_argument(
|
|
16
|
+
"-x", "--one-file-system", action="store_true", help="don't cross filesystem boundaries"
|
|
17
|
+
)
|
|
18
|
+
parser.add_argument(
|
|
19
|
+
"--apparent-size", action="store_true", help="use file sizes instead of disk usage"
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument("--no-color", action="store_true", help="draw with characters only")
|
|
22
|
+
return parser.parse_args(argv)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _Progress:
|
|
26
|
+
def __init__(self):
|
|
27
|
+
self.last = 0.0
|
|
28
|
+
|
|
29
|
+
def __call__(self, count: int, path: str) -> None:
|
|
30
|
+
now = time.monotonic()
|
|
31
|
+
if now - self.last < 0.1:
|
|
32
|
+
return
|
|
33
|
+
self.last = now
|
|
34
|
+
width = os.get_terminal_size(sys.stderr.fileno()).columns if sys.stderr.isatty() else 80
|
|
35
|
+
line = f"Scanning: {count} items {path}"
|
|
36
|
+
sys.stderr.write("\r" + line[: width - 1].ljust(width - 1))
|
|
37
|
+
sys.stderr.flush()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def main(argv: list[str] | None = None) -> None:
|
|
41
|
+
args = parse_args(argv)
|
|
42
|
+
if not os.path.isdir(args.path):
|
|
43
|
+
sys.exit(f"redwood: not a directory: {args.path}")
|
|
44
|
+
|
|
45
|
+
progress = _Progress() if sys.stderr.isatty() else None
|
|
46
|
+
try:
|
|
47
|
+
root = scan(args.path, args.one_file_system, args.apparent_size, progress)
|
|
48
|
+
except KeyboardInterrupt:
|
|
49
|
+
sys.exit(130)
|
|
50
|
+
except OSError as e:
|
|
51
|
+
sys.exit(f"redwood: {e}")
|
|
52
|
+
if progress:
|
|
53
|
+
sys.stderr.write("\r\033[K")
|
|
54
|
+
|
|
55
|
+
if not sys.stdout.isatty():
|
|
56
|
+
print(f"{human_size(root.size)}\t{root.path}")
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
from redwood.ui import run
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
run(root, color=not args.no_color)
|
|
63
|
+
except KeyboardInterrupt:
|
|
64
|
+
pass
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
_UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def human_size(size: int) -> str:
|
|
5
|
+
value = float(size)
|
|
6
|
+
for unit in _UNITS:
|
|
7
|
+
if value < 1024 or unit == _UNITS[-1]:
|
|
8
|
+
break
|
|
9
|
+
value /= 1024
|
|
10
|
+
if unit == "B":
|
|
11
|
+
return f"{int(value)} B"
|
|
12
|
+
return f"{value:.1f} {unit}"
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import curses
|
|
2
|
+
|
|
3
|
+
# Terminals report characters, not physical keys, so letters typed on a non-Latin
|
|
4
|
+
# layout are translated back to the US QWERTY key in the same position.
|
|
5
|
+
_QWERTY = "`qwertyuiop[]asdfghjkl;'zxcvbnm,."
|
|
6
|
+
_LAYOUTS = {
|
|
7
|
+
"russian": "ёйцукенгшщзхъфывапролджэячсмитьбю",
|
|
8
|
+
"ukrainian": "'йцукенгшщзхїфівапролджєячсмитьбю",
|
|
9
|
+
"belarusian": "ёйцукенгшўзх'фывапролджэячсмітьбю",
|
|
10
|
+
"greek": "`;ςερτυθιοπ[]ασδφγηξκλ΄'ζχψωβνμ,.",
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _build() -> dict[str, str]:
|
|
15
|
+
table: dict[str, str] = {}
|
|
16
|
+
for chars in _LAYOUTS.values():
|
|
17
|
+
for char, latin in zip(chars, _QWERTY, strict=True):
|
|
18
|
+
if char.isascii():
|
|
19
|
+
continue
|
|
20
|
+
table.setdefault(char, latin)
|
|
21
|
+
# Skips final sigma, whose upper case belongs to σ.
|
|
22
|
+
if char.upper().lower() == char:
|
|
23
|
+
table.setdefault(char.upper(), latin.upper())
|
|
24
|
+
return table
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_TO_LATIN = _build()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def normalize(key: int | str) -> int:
|
|
31
|
+
"""Turn a get_wch() result into a getch()-style code, mapping layouts to QWERTY."""
|
|
32
|
+
if isinstance(key, int):
|
|
33
|
+
return key
|
|
34
|
+
return ord(_TO_LATIN.get(key, key))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def read_key(screen: curses.window) -> int:
|
|
38
|
+
return normalize(screen.get_wch())
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import stat
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
|
|
5
|
+
from redwood.categories import CATEGORIES, OTHER, categorize
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Node:
|
|
9
|
+
__slots__ = ("name", "parent", "size", "children", "category", "cat_sizes", "error")
|
|
10
|
+
|
|
11
|
+
def __init__(self, name: str, parent: "Node | None", size: int = 0, category: int = OTHER):
|
|
12
|
+
self.name = name
|
|
13
|
+
self.parent = parent
|
|
14
|
+
self.size = size
|
|
15
|
+
self.category = category
|
|
16
|
+
self.children: list[Node] | None = None
|
|
17
|
+
self.cat_sizes: list[int] | None = None
|
|
18
|
+
self.error = False
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def is_dir(self) -> bool:
|
|
22
|
+
return self.children is not None
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def path(self) -> str:
|
|
26
|
+
if self.parent is None:
|
|
27
|
+
return self.name
|
|
28
|
+
return os.path.join(self.parent.path, self.name)
|
|
29
|
+
|
|
30
|
+
def __repr__(self) -> str:
|
|
31
|
+
return f"Node({self.path!r}, {self.size})"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _dir_node(name: str, parent: Node | None) -> Node:
|
|
35
|
+
node = Node(name, parent)
|
|
36
|
+
node.children = []
|
|
37
|
+
node.cat_sizes = [0] * len(CATEGORIES)
|
|
38
|
+
return node
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def scan(
|
|
42
|
+
root: str,
|
|
43
|
+
one_file_system: bool = False,
|
|
44
|
+
apparent_size: bool = False,
|
|
45
|
+
progress: Callable[[int, str], None] | None = None,
|
|
46
|
+
) -> Node:
|
|
47
|
+
root = os.path.abspath(root)
|
|
48
|
+
root_dev = os.stat(root).st_dev
|
|
49
|
+
root_node = _dir_node(root, None)
|
|
50
|
+
# Iterative walk so very deep trees can't hit the recursion limit.
|
|
51
|
+
stack = [(root_node, root)]
|
|
52
|
+
visited: list[Node] = []
|
|
53
|
+
seen_inodes: set[tuple[int, int]] = set()
|
|
54
|
+
count = 0
|
|
55
|
+
|
|
56
|
+
while stack:
|
|
57
|
+
node, path = stack.pop()
|
|
58
|
+
visited.append(node)
|
|
59
|
+
try:
|
|
60
|
+
with os.scandir(path) as it:
|
|
61
|
+
entries = list(it)
|
|
62
|
+
except OSError:
|
|
63
|
+
node.error = True
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
for entry in entries:
|
|
67
|
+
try:
|
|
68
|
+
st = entry.stat(follow_symlinks=False)
|
|
69
|
+
except OSError:
|
|
70
|
+
continue
|
|
71
|
+
count += 1
|
|
72
|
+
if stat.S_ISDIR(st.st_mode):
|
|
73
|
+
if one_file_system and st.st_dev != root_dev:
|
|
74
|
+
continue
|
|
75
|
+
child = _dir_node(entry.name, node)
|
|
76
|
+
stack.append((child, entry.path))
|
|
77
|
+
else:
|
|
78
|
+
# Hard links share storage, so only count the first one we meet.
|
|
79
|
+
if st.st_nlink > 1:
|
|
80
|
+
key = (st.st_dev, st.st_ino)
|
|
81
|
+
if key in seen_inodes:
|
|
82
|
+
continue
|
|
83
|
+
seen_inodes.add(key)
|
|
84
|
+
child = Node(
|
|
85
|
+
entry.name, node, _size(st, apparent_size), categorize(entry.name, st.st_mode)
|
|
86
|
+
)
|
|
87
|
+
node.children.append(child)
|
|
88
|
+
if progress:
|
|
89
|
+
progress(count, path)
|
|
90
|
+
|
|
91
|
+
# Parents were visited before their children, so reversing gives a bottom-up order.
|
|
92
|
+
for node in reversed(visited):
|
|
93
|
+
node.children.sort(key=lambda n: n.size, reverse=True)
|
|
94
|
+
for child in node.children:
|
|
95
|
+
node.size += child.size
|
|
96
|
+
if child.is_dir:
|
|
97
|
+
for i, size in enumerate(child.cat_sizes):
|
|
98
|
+
node.cat_sizes[i] += size
|
|
99
|
+
else:
|
|
100
|
+
node.cat_sizes[child.category] += child.size
|
|
101
|
+
if node.size:
|
|
102
|
+
node.category = max(range(len(CATEGORIES)), key=node.cat_sizes.__getitem__)
|
|
103
|
+
return root_node
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _size(st: os.stat_result, apparent_size: bool) -> int:
|
|
107
|
+
blocks = getattr(st, "st_blocks", None)
|
|
108
|
+
if apparent_size or blocks is None:
|
|
109
|
+
return st.st_size
|
|
110
|
+
# Allocated size, like du/ncdu; also keeps sparse files such as /proc/kcore at their real cost.
|
|
111
|
+
return blocks * 512
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from typing import NamedTuple
|
|
2
|
+
|
|
3
|
+
# Terminal cells are roughly twice as tall as they are wide.
|
|
4
|
+
CELL_ASPECT = 2.0
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Rect(NamedTuple):
|
|
8
|
+
x: int
|
|
9
|
+
y: int
|
|
10
|
+
w: int
|
|
11
|
+
h: int
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def squarify(
|
|
15
|
+
sizes: list[float], x: float, y: float, w: float, h: float
|
|
16
|
+
) -> list[tuple[float, float, float, float]]:
|
|
17
|
+
"""Squarified treemap (Bruls et al.); `sizes` must be sorted in descending order."""
|
|
18
|
+
total = sum(sizes)
|
|
19
|
+
if total <= 0 or w <= 0 or h <= 0:
|
|
20
|
+
return [(x, y, 0.0, 0.0)] * len(sizes)
|
|
21
|
+
scale = w * h / total
|
|
22
|
+
areas = [s * scale for s in sizes]
|
|
23
|
+
rects = []
|
|
24
|
+
i = 0
|
|
25
|
+
while i < len(areas):
|
|
26
|
+
if areas[i] <= 0 or w <= 0 or h <= 0:
|
|
27
|
+
rects.extend([(x, y, 0.0, 0.0)] * (len(areas) - i))
|
|
28
|
+
break
|
|
29
|
+
short = min(w, h)
|
|
30
|
+
# Sizes are sorted, so the row's largest area is its first and the smallest its last;
|
|
31
|
+
# that keeps each aspect-ratio check O(1) instead of rescanning the row.
|
|
32
|
+
row = [areas[i]]
|
|
33
|
+
row_sum = areas[i]
|
|
34
|
+
i += 1
|
|
35
|
+
while i < len(areas) and areas[i] > 0:
|
|
36
|
+
grown = row_sum + areas[i]
|
|
37
|
+
if _worst(grown, row[0], areas[i], short) > _worst(row_sum, row[0], row[-1], short):
|
|
38
|
+
break
|
|
39
|
+
row.append(areas[i])
|
|
40
|
+
row_sum = grown
|
|
41
|
+
i += 1
|
|
42
|
+
if w >= h:
|
|
43
|
+
col_w = row_sum / h
|
|
44
|
+
cy = y
|
|
45
|
+
for area in row:
|
|
46
|
+
rects.append((x, cy, col_w, area / col_w))
|
|
47
|
+
cy += area / col_w
|
|
48
|
+
x += col_w
|
|
49
|
+
w -= col_w
|
|
50
|
+
else:
|
|
51
|
+
row_h = row_sum / w
|
|
52
|
+
cx = x
|
|
53
|
+
for area in row:
|
|
54
|
+
rects.append((cx, y, area / row_h, row_h))
|
|
55
|
+
cx += area / row_h
|
|
56
|
+
y += row_h
|
|
57
|
+
h -= row_h
|
|
58
|
+
return rects
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _worst(total: float, largest: float, smallest: float, short: float) -> float:
|
|
62
|
+
side2 = short * short
|
|
63
|
+
total2 = total * total
|
|
64
|
+
return max(side2 * largest / total2, total2 / (side2 * smallest))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def layout(sizes: list[float], area: Rect) -> tuple[list[Rect], Rect]:
|
|
68
|
+
"""Lay sizes out on the character grid.
|
|
69
|
+
|
|
70
|
+
Items too small to get a cell of their own are merged into one block, so huge folders cost
|
|
71
|
+
no more than the screen can show. Returns rects for the leading, visible items (possibly
|
|
72
|
+
fewer than `sizes`) and the merged block's rect, which is empty when nothing was merged.
|
|
73
|
+
"""
|
|
74
|
+
total = sum(sizes)
|
|
75
|
+
cells = area.w * area.h
|
|
76
|
+
if total <= 0 or cells <= 0:
|
|
77
|
+
return [Rect(area.x, area.y, 0, 0)] * len(sizes), Rect(area.x, area.y, 0, 0)
|
|
78
|
+
shown = 0
|
|
79
|
+
while shown < len(sizes) and sizes[shown] * cells >= total:
|
|
80
|
+
shown += 1
|
|
81
|
+
if shown == len(sizes):
|
|
82
|
+
return _grid(sizes, area), Rect(area.x, area.y, 0, 0)
|
|
83
|
+
rest = total - sum(sizes[:shown])
|
|
84
|
+
# Squarify wants descending sizes, so the merged block goes in at its sorted position.
|
|
85
|
+
pos = next((i for i in range(shown) if sizes[i] < rest), shown)
|
|
86
|
+
rects = _grid(sizes[:pos] + [rest] + sizes[pos:shown], area)
|
|
87
|
+
return rects, rects.pop(pos)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _grid(sizes: list[float], area: Rect) -> list[Rect]:
|
|
91
|
+
rects = squarify(sizes, 0.0, 0.0, float(area.w), area.h * CELL_ASPECT)
|
|
92
|
+
result = []
|
|
93
|
+
# Rounding shared edges the same way keeps neighbours from overlapping or leaving gaps.
|
|
94
|
+
for fx, fy, fw, fh in rects:
|
|
95
|
+
x0, x1 = round(fx), round(fx + fw)
|
|
96
|
+
y0, y1 = round(fy / CELL_ASPECT), round((fy + fh) / CELL_ASPECT)
|
|
97
|
+
result.append(Rect(area.x + x0, area.y + y0, max(0, x1 - x0), max(0, y1 - y0)))
|
|
98
|
+
return result
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import curses
|
|
2
|
+
import locale
|
|
3
|
+
|
|
4
|
+
from redwood.browser import Browser
|
|
5
|
+
from redwood.categories import CATEGORIES, OTHER
|
|
6
|
+
from redwood.humanize import human_size
|
|
7
|
+
from redwood.keys import read_key
|
|
8
|
+
from redwood.scanner import Node
|
|
9
|
+
from redwood.treemap import Rect, layout
|
|
10
|
+
|
|
11
|
+
HELP = "↑↓ move →/Enter open ←/Bksp up l legend q quit"
|
|
12
|
+
HELP_ASCII = "Up/Down move Right/Enter open Left/Bksp up l legend q quit"
|
|
13
|
+
BAR_WIDTH = 8
|
|
14
|
+
|
|
15
|
+
_HIGHLIGHT_PAIR = len(CATEGORIES) + 1
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Styles:
|
|
19
|
+
def __init__(self, color: bool):
|
|
20
|
+
self.color = color and curses.has_colors()
|
|
21
|
+
if self.color:
|
|
22
|
+
curses.start_color()
|
|
23
|
+
try:
|
|
24
|
+
curses.use_default_colors()
|
|
25
|
+
default_bg = -1
|
|
26
|
+
except curses.error:
|
|
27
|
+
default_bg = curses.COLOR_BLACK
|
|
28
|
+
for i, cat in enumerate(CATEGORIES):
|
|
29
|
+
if i == OTHER:
|
|
30
|
+
curses.init_pair(i + 1, curses.COLOR_WHITE, default_bg)
|
|
31
|
+
else:
|
|
32
|
+
curses.init_pair(i + 1, curses.COLOR_BLACK, cat.color)
|
|
33
|
+
curses.init_pair(_HIGHLIGHT_PAIR, curses.COLOR_YELLOW, default_bg)
|
|
34
|
+
self.highlight = curses.color_pair(_HIGHLIGHT_PAIR) | curses.A_BOLD | curses.A_REVERSE
|
|
35
|
+
else:
|
|
36
|
+
self.highlight = curses.A_BOLD | curses.A_REVERSE
|
|
37
|
+
|
|
38
|
+
def fill(self, category: int, alt: bool) -> tuple[str, int]:
|
|
39
|
+
# Alternating texture keeps neighbouring blocks of the same type apart.
|
|
40
|
+
if self.color:
|
|
41
|
+
if category == OTHER:
|
|
42
|
+
char = ":" if alt else "."
|
|
43
|
+
else:
|
|
44
|
+
char = "." if alt else " "
|
|
45
|
+
return char, curses.color_pair(category + 1)
|
|
46
|
+
return CATEGORIES[category].char, curses.A_BOLD if alt else curses.A_NORMAL
|
|
47
|
+
|
|
48
|
+
def swatch(self, category: int) -> tuple[str, int]:
|
|
49
|
+
char, attr = self.fill(category, False)
|
|
50
|
+
return char * 2, attr
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class App:
|
|
54
|
+
def __init__(self, screen, root: Node, color: bool):
|
|
55
|
+
self.screen = screen
|
|
56
|
+
self.browser = Browser(root)
|
|
57
|
+
self.styles = Styles(color)
|
|
58
|
+
self.show_legend = False
|
|
59
|
+
self.list_top = 0
|
|
60
|
+
self.encoding = screen.encoding or locale.getpreferredencoding()
|
|
61
|
+
self.unicode = "utf" in self.encoding.lower()
|
|
62
|
+
self.unreadable = _count_errors(root)
|
|
63
|
+
self._tiles_key = None
|
|
64
|
+
self._tiles: list[tuple[Rect, int, bool]] = []
|
|
65
|
+
self._top_rects: list[Rect] = []
|
|
66
|
+
self._rest_rect = Rect(0, 0, 0, 0)
|
|
67
|
+
|
|
68
|
+
def run(self) -> None:
|
|
69
|
+
curses.curs_set(0)
|
|
70
|
+
while True:
|
|
71
|
+
self.draw()
|
|
72
|
+
key = read_key(self.screen)
|
|
73
|
+
if key in (ord("q"), ord("Q")):
|
|
74
|
+
return
|
|
75
|
+
self.handle(key)
|
|
76
|
+
|
|
77
|
+
def handle(self, key: int) -> None:
|
|
78
|
+
b = self.browser
|
|
79
|
+
page = max(1, self.screen.getmaxyx()[0] - 3)
|
|
80
|
+
actions = {
|
|
81
|
+
curses.KEY_UP: lambda: b.move(-1),
|
|
82
|
+
ord("k"): lambda: b.move(-1),
|
|
83
|
+
curses.KEY_DOWN: lambda: b.move(1),
|
|
84
|
+
ord("j"): lambda: b.move(1),
|
|
85
|
+
curses.KEY_PPAGE: lambda: b.move(-page),
|
|
86
|
+
curses.KEY_NPAGE: lambda: b.move(page),
|
|
87
|
+
curses.KEY_HOME: lambda: b.move_to(0),
|
|
88
|
+
curses.KEY_END: lambda: b.move_to(len(b.entries) - 1),
|
|
89
|
+
curses.KEY_RIGHT: b.enter,
|
|
90
|
+
curses.KEY_ENTER: b.enter,
|
|
91
|
+
10: b.enter,
|
|
92
|
+
13: b.enter,
|
|
93
|
+
curses.KEY_LEFT: b.leave,
|
|
94
|
+
curses.KEY_BACKSPACE: b.leave,
|
|
95
|
+
127: b.leave,
|
|
96
|
+
8: b.leave,
|
|
97
|
+
ord("l"): self.toggle_legend,
|
|
98
|
+
}
|
|
99
|
+
action = actions.get(key)
|
|
100
|
+
if action:
|
|
101
|
+
action()
|
|
102
|
+
|
|
103
|
+
def toggle_legend(self) -> None:
|
|
104
|
+
self.show_legend = not self.show_legend
|
|
105
|
+
|
|
106
|
+
def text(self, y: int, x: int, s: str, width: int, attr: int = 0) -> None:
|
|
107
|
+
if width <= 0:
|
|
108
|
+
return
|
|
109
|
+
s = s.encode(self.encoding, "replace").decode(self.encoding)
|
|
110
|
+
s = "".join(c if c.isprintable() else "?" for c in s)
|
|
111
|
+
try:
|
|
112
|
+
self.screen.addnstr(y, x, s, width, attr)
|
|
113
|
+
except curses.error:
|
|
114
|
+
pass # writing the bottom-right cell moves the cursor off-screen
|
|
115
|
+
|
|
116
|
+
def draw(self) -> None:
|
|
117
|
+
self.screen.erase()
|
|
118
|
+
height, width = self.screen.getmaxyx()
|
|
119
|
+
if width < 40 or height < 8:
|
|
120
|
+
self.text(0, 0, "Terminal too small", width)
|
|
121
|
+
self.screen.refresh()
|
|
122
|
+
return
|
|
123
|
+
list_w = min(max(width * 2 // 5, 36), 64)
|
|
124
|
+
map_area = Rect(0, 1, width - list_w - 1, height - 2)
|
|
125
|
+
self.draw_header(width)
|
|
126
|
+
self.draw_treemap(map_area)
|
|
127
|
+
self.draw_separator(width - list_w - 1, 1, height - 2)
|
|
128
|
+
self.draw_list(Rect(width - list_w, 1, list_w, height - 2))
|
|
129
|
+
if self.show_legend:
|
|
130
|
+
self.draw_legend(map_area)
|
|
131
|
+
self.text(
|
|
132
|
+
height - 1,
|
|
133
|
+
0,
|
|
134
|
+
(HELP if self.unicode else HELP_ASCII).ljust(width),
|
|
135
|
+
width,
|
|
136
|
+
curses.A_REVERSE,
|
|
137
|
+
)
|
|
138
|
+
self.screen.refresh()
|
|
139
|
+
|
|
140
|
+
def draw_header(self, width: int) -> None:
|
|
141
|
+
cur = self.browser.current
|
|
142
|
+
right = f" {human_size(cur.size)} {len(cur.children)} items "
|
|
143
|
+
if self.unreadable:
|
|
144
|
+
right = f" {self.unreadable} unreadable |" + right
|
|
145
|
+
left = f" redwood {cur.path}"
|
|
146
|
+
line = left[: max(0, width - len(right))].ljust(width - len(right)) + right
|
|
147
|
+
self.text(0, 0, line, width, curses.A_REVERSE)
|
|
148
|
+
|
|
149
|
+
def draw_separator(self, x: int, y: int, h: int) -> None:
|
|
150
|
+
try:
|
|
151
|
+
self.screen.vline(y, x, curses.ACS_VLINE, h)
|
|
152
|
+
except curses.error:
|
|
153
|
+
pass
|
|
154
|
+
|
|
155
|
+
def draw_treemap(self, area: Rect) -> None:
|
|
156
|
+
cur = self.browser.current
|
|
157
|
+
key = (id(cur), area)
|
|
158
|
+
if key != self._tiles_key:
|
|
159
|
+
self._tiles = []
|
|
160
|
+
self._top_rects, self._rest_rect = layout([c.size for c in cur.children], area)
|
|
161
|
+
for i, (child, rect) in enumerate(zip(cur.children, self._top_rects, strict=False)):
|
|
162
|
+
self._collect(child, _frame_inner(child, rect), i % 2 == 1)
|
|
163
|
+
self._add_rest_tile(self._rest_rect, cur, len(self._top_rects))
|
|
164
|
+
self._tiles_key = key
|
|
165
|
+
|
|
166
|
+
for rect, category, alt in self._tiles:
|
|
167
|
+
char, attr = self.styles.fill(category, alt)
|
|
168
|
+
for row in range(rect.y, rect.y + rect.h):
|
|
169
|
+
self.text(row, rect.x, char * rect.w, rect.w, attr)
|
|
170
|
+
|
|
171
|
+
rest = self._rest_rect
|
|
172
|
+
if rest.w >= 6:
|
|
173
|
+
label = f"+{len(cur.children) - len(self._top_rects)} small items"
|
|
174
|
+
self.text(rest.y, rest.x, label, rest.w, curses.A_BOLD)
|
|
175
|
+
|
|
176
|
+
for i, (child, rect) in enumerate(zip(cur.children, self._top_rects, strict=False)):
|
|
177
|
+
if not rect.w or not rect.h:
|
|
178
|
+
continue
|
|
179
|
+
selected = i == self.browser.index
|
|
180
|
+
label = f" {child.name}{'/' if child.is_dir else ''} {human_size(child.size)} "
|
|
181
|
+
if _frame_inner(child, rect) != rect:
|
|
182
|
+
self.draw_frame(rect, self.styles.highlight if selected else curses.A_NORMAL)
|
|
183
|
+
self.text(
|
|
184
|
+
rect.y, rect.x + 1, label, rect.w - 2, self.styles.highlight if selected else 0
|
|
185
|
+
)
|
|
186
|
+
elif selected:
|
|
187
|
+
attr = self.styles.highlight
|
|
188
|
+
for row in range(rect.y, rect.y + rect.h):
|
|
189
|
+
self.text(row, rect.x, " " * rect.w, rect.w, attr)
|
|
190
|
+
self.text(rect.y, rect.x, label.strip(), rect.w, attr)
|
|
191
|
+
elif rect.w >= 6:
|
|
192
|
+
self.text(
|
|
193
|
+
rect.y,
|
|
194
|
+
rect.x,
|
|
195
|
+
label.strip(),
|
|
196
|
+
rect.w,
|
|
197
|
+
self.styles.fill(child.category, i % 2 == 1)[1],
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
def _collect(self, node: Node, rect: Rect, alt: bool) -> None:
|
|
201
|
+
if not rect.w or not rect.h:
|
|
202
|
+
return
|
|
203
|
+
if not node.is_dir or not node.size or rect.w * rect.h < 2:
|
|
204
|
+
self._tiles.append((rect, node.category, alt))
|
|
205
|
+
return
|
|
206
|
+
rects, rest = layout([c.size for c in node.children], rect)
|
|
207
|
+
for i, (child, child_rect) in enumerate(zip(node.children, rects, strict=False)):
|
|
208
|
+
self._collect(child, child_rect, alt ^ (i % 2 == 1))
|
|
209
|
+
self._add_rest_tile(rest, node, len(rects), alt)
|
|
210
|
+
|
|
211
|
+
def _add_rest_tile(self, rect: Rect, parent: Node, index: int, alt: bool = False) -> None:
|
|
212
|
+
# The parent's dominant type is close enough for a block of items too small to see.
|
|
213
|
+
if rect.w and rect.h:
|
|
214
|
+
self._tiles.append((rect, parent.category, alt ^ (index % 2 == 1)))
|
|
215
|
+
|
|
216
|
+
def draw_frame(self, r: Rect, attr: int) -> None:
|
|
217
|
+
s = self.screen
|
|
218
|
+
right, bottom = r.x + r.w - 1, r.y + r.h - 1
|
|
219
|
+
try:
|
|
220
|
+
s.hline(r.y, r.x + 1, curses.ACS_HLINE | attr, r.w - 2)
|
|
221
|
+
s.hline(bottom, r.x + 1, curses.ACS_HLINE | attr, r.w - 2)
|
|
222
|
+
s.vline(r.y + 1, r.x, curses.ACS_VLINE | attr, r.h - 2)
|
|
223
|
+
s.vline(r.y + 1, right, curses.ACS_VLINE | attr, r.h - 2)
|
|
224
|
+
s.addch(r.y, r.x, curses.ACS_ULCORNER, attr)
|
|
225
|
+
s.addch(r.y, right, curses.ACS_URCORNER, attr)
|
|
226
|
+
s.addch(bottom, r.x, curses.ACS_LLCORNER, attr)
|
|
227
|
+
s.addch(bottom, right, curses.ACS_LRCORNER, attr)
|
|
228
|
+
except curses.error:
|
|
229
|
+
pass
|
|
230
|
+
|
|
231
|
+
def draw_list(self, area: Rect) -> None:
|
|
232
|
+
b = self.browser
|
|
233
|
+
entries = b.entries
|
|
234
|
+
if not entries:
|
|
235
|
+
msg = "(unreadable)" if b.current.error else "(empty)"
|
|
236
|
+
self.text(area.y, area.x + 1, msg, area.w - 1)
|
|
237
|
+
return
|
|
238
|
+
if b.index < self.list_top:
|
|
239
|
+
self.list_top = b.index
|
|
240
|
+
elif b.index >= self.list_top + area.h:
|
|
241
|
+
self.list_top = b.index - area.h + 1
|
|
242
|
+
self.list_top = max(0, min(self.list_top, len(entries) - area.h))
|
|
243
|
+
|
|
244
|
+
biggest = entries[0].size or 1
|
|
245
|
+
total = b.current.size or 1
|
|
246
|
+
for row, i in enumerate(range(self.list_top, min(len(entries), self.list_top + area.h))):
|
|
247
|
+
node = entries[i]
|
|
248
|
+
y = area.y + row
|
|
249
|
+
swatch, swatch_attr = self.styles.swatch(node.category)
|
|
250
|
+
filled = round(BAR_WIDTH * node.size / biggest)
|
|
251
|
+
bar = "#" * filled + " " * (BAR_WIDTH - filled)
|
|
252
|
+
mark = "!" if node.error else " "
|
|
253
|
+
name = node.name + ("/" if node.is_dir else "")
|
|
254
|
+
line = f"{human_size(node.size):>9} {node.size * 100 / total:5.1f}% [{bar}]{mark}{name}"
|
|
255
|
+
attr = curses.A_REVERSE if i == b.index else 0
|
|
256
|
+
self.text(y, area.x + 1, swatch, 2, swatch_attr)
|
|
257
|
+
self.text(y, area.x + 3, line.ljust(area.w), area.w - 3, attr)
|
|
258
|
+
|
|
259
|
+
def draw_legend(self, area: Rect) -> None:
|
|
260
|
+
cur = self.browser.current
|
|
261
|
+
cats = sorted(
|
|
262
|
+
(i for i in range(len(CATEGORIES)) if cur.cat_sizes[i]),
|
|
263
|
+
key=lambda i: cur.cat_sizes[i],
|
|
264
|
+
reverse=True,
|
|
265
|
+
)
|
|
266
|
+
total = cur.size or 1
|
|
267
|
+
lines = []
|
|
268
|
+
for i in cats:
|
|
269
|
+
size = cur.cat_sizes[i]
|
|
270
|
+
lines.append(
|
|
271
|
+
(i, f"{CATEGORIES[i].name:<9}{human_size(size):>10} {size * 100 / total:5.1f}%")
|
|
272
|
+
)
|
|
273
|
+
w = 2 + 3 + max((len(t) for _, t in lines), default=16)
|
|
274
|
+
h = len(lines) + 2
|
|
275
|
+
box = Rect(area.x + 1, area.y + area.h - h - 1, w + 1, h)
|
|
276
|
+
if box.y < area.y or box.w > area.w:
|
|
277
|
+
return
|
|
278
|
+
for row in range(box.y, box.y + box.h):
|
|
279
|
+
self.text(row, box.x, " " * box.w, box.w)
|
|
280
|
+
self.draw_frame(box, curses.A_BOLD)
|
|
281
|
+
self.text(box.y, box.x + 2, " legend ", box.w - 3, curses.A_BOLD)
|
|
282
|
+
if not lines:
|
|
283
|
+
self.text(box.y + 1, box.x + 2, "(nothing here)", box.w - 3)
|
|
284
|
+
for row, (i, line) in enumerate(lines):
|
|
285
|
+
swatch, attr = self.styles.swatch(i)
|
|
286
|
+
self.text(box.y + 1 + row, box.x + 2, swatch, 2, attr)
|
|
287
|
+
self.text(box.y + 1 + row, box.x + 5, line, box.w - 6)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _frame_inner(node: Node, rect: Rect) -> Rect:
|
|
291
|
+
if node.is_dir and rect.w >= 6 and rect.h >= 3:
|
|
292
|
+
return Rect(rect.x + 1, rect.y + 1, rect.w - 2, rect.h - 2)
|
|
293
|
+
return rect
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _count_errors(root: Node) -> int:
|
|
297
|
+
count, stack = 0, [root]
|
|
298
|
+
while stack:
|
|
299
|
+
node = stack.pop()
|
|
300
|
+
count += node.error
|
|
301
|
+
stack.extend(c for c in node.children if c.is_dir)
|
|
302
|
+
return count
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def run(root: Node, color: bool = True) -> None:
|
|
306
|
+
locale.setlocale(locale.LC_ALL, "")
|
|
307
|
+
curses.wrapper(lambda screen: App(screen, root, color).run())
|