diff options
-rw-r--r-- | .gitignore | 1 | ||||
-rw-r--r-- | ChangeLog | 257 | ||||
-rw-r--r-- | Makefile | 42 | ||||
-rw-r--r-- | README.md | 4 | ||||
-rw-r--r-- | build.zig | 55 | ||||
-rw-r--r-- | ncdu.1 | 515 | ||||
-rw-r--r-- | ncdu.pod | 454 | ||||
-rw-r--r-- | src/browser.zig | 217 | ||||
-rw-r--r-- | src/delete.zig | 15 | ||||
-rw-r--r-- | src/exclude.zig | 322 | ||||
-rw-r--r-- | src/main.zig | 549 | ||||
-rw-r--r-- | src/model.zig | 288 | ||||
-rw-r--r-- | src/ncurses_refs.c | 30 | ||||
-rw-r--r-- | src/scan.zig | 222 | ||||
-rw-r--r-- | src/ui.zig | 254 | ||||
-rw-r--r-- | src/util.zig | 123 |
16 files changed, 1950 insertions, 1398 deletions
@@ -3,7 +3,6 @@ *.swp *~ -ncdu.1 ncurses static-*/ zig-cache/ @@ -1,6 +1,67 @@ -# SPDX-FileCopyrightText: 2021 Yoran Heling <projects@yorhel.nl> +# SPDX-FileCopyrightText: 2021-2023 Yoran Heling <projects@yorhel.nl> # SPDX-License-Identifier: MIT +2.3 - 2023-08-04 + - Now requires Zig 0.11 + - Add --(enable|disable)-natsort options + - Add indicator to apparent size/disk usage selection in the footer + - Fix build on armv7l (hopefully) + - Minor build system additions + +2.2.2 - 2023-01-19 + - Now requires Zig 0.10 or 0.10.1 + - That's it, pretty much. + +2.2.1 - 2022-10-25 + - Still requires Zig 0.9.0 or 0.9.1 + - Fix bug with 'dark' and 'off' color themes on FreeBSD and MacOS + +2.2 - 2022-10-17 + - Still requires Zig 0.9.0 or 0.9.1 + - (breaking) Wildcards in exclude patterns don't cross directory boundary anymore + - Improve exclude pattern matching performance + - Set full background in default dark-bg color scheme + - Fix broken JSON export when a filename contains control characters below 0x10 + +2.1.2 - 2022-04-28 + - Still requires Zig 0.9.0 or 0.9.1 + - Fix possible crash on shortening file names with unicode variation + selectors or combining marks + +2.1.1 - 2022-03-25 + - Still requires Zig 0.9.0 or 0.9.1 + - Fix potential crash when refreshing + - Fix typo in --graph-style=eighth-block + - Revert default --graph-style to hash characters + +2.1 - 2022-02-07 + - Still requires Zig 0.9.0 + - Use natural sort order when sorting by file name + - Use Unicode box drawing characters for the file size bar + - Add --graph-style option to change drawing style for the file size bar + - Fix early exit if a configuration directory does not exist + - Fix display glitch for long file names + - Fix display glitch with drawing unique/shared size column + +2.0.1 - 2022-01-01 + - Still requires Zig 0.9.0 + - Fix build failure to find 'wcwidth' on some systems + - Add ZIG_FLAGS option to Makefile + +2.0 - 2021-12-21 + - Requires Zig 0.9.0 + - That's the only change. + +2.0-beta3 - 2021-11-09 + - Requires Zig 0.8 or 0.8.1 + - Add lots of new CLI flags to configure ncdu + - Add configuration file support + - Add 'dark-bg' color scheme and use that by default + - Fix not enabling -x by default + - Fix export feature + - Fix import of "special" dirs and files + - Fix double-slash display in file browser + 2.0-beta2 - 2021-07-31 - Requires Zig 0.8 - Significantly reduce memory usage for hard links @@ -10,199 +71,7 @@ 2.0-beta1 - 2021-07-22 - Full release announcement: https://dev.yorhel.nl/doc/ncdu2 - Requires Zig 0.8 + - Features and UI based on ncdu 1.16 - Lower memory use in most scenarios (except with many hard links) - Improved performance of hard link counting - Extra column for shared/unique directory sizes - -1.16 - 2021-07-02 - - Increase width of size bar depending on terminal size (Christian Göttsche) - - Set/increment $NCDU_LEVEL variable when spawning a shell - - Indicate whether apparent size or disk usage is being displayed - - Display setuid, setgid and sticky bits in file flags in extended mode - - Fix error handling while reading --exclude-from file - - Improve JSON import to allow for several future extensions to the format - - Export link count in JSON dumps - - Don't export inode in JSON dumps for non-hardlinks - -1.15.1 - 2020-06-10 - - (Linux) Fix build on older Linux systems (Christian Göttsche) - - (MacOS) Revert "Exclude firmlinks by default" behavior (until we have a better solution) - - (MacOS) Add --exclude-firmlinks option to opt-in to the above behavior - -1.15 - 2020-05-30 - - (Linux) Add --exclude-kernfs option to exclude pseudo filesystems (Christian Göttsche) - - (MacOS) Exclude firmlinks by default (Saagar Jha) - - (MacOS) Add --follow-firmlinks option to follow firmlinks (Saagar Jha) - - Fix bug in calculating the apparent size of directories containing hardlinks - - Fix integer overflow with directories containing >2GiB worth of file names - - Fix yet another possible 100% CPU bug when losing terminal - -1.14.2 - 2020-02-10 - - Fix compilation with GCC 10 (-fno-common) - - Fix minor display issue when scanning 10M+ files - - Slightly reduce memory usage for hard link detection - -1.14.1 - 2019-08-05 - - Fix occasional early exit on OS X - - Fix --exclude-caches - - Improve handling of out-of-memory situations - -1.14 - 2019-02-04 - - Add mtime display and sorting (Alex Wilson) - - Add (limited) --follow-symlinks option (Simon Doppler) - - Display larger file counts in browser UI - - Add -V, --version, and --help alias flags - - Fix crash when attempting to sort an empty directory - - Fix 100% CPU bug when ncdu loses the terminal - - Fix '--color=off' flag - - Fix some typos - -1.13 - 2018-01-29 - - Add "extended information" mode and -e flag - - Add file mode, modification time and uid/gid to info window with -e - - Add experimental color support and --color flag - - Add -rr option to disable shell spawning - - Remove directory nesting limit on file import - - Fix handling of interrupts during file import - - Fix undefined behavior that triggered crash on OS X - -1.12 - 2016-08-24 - - Add NCDU_SHELL environment variable - - Add --confirm-quit flag - - Fix compilation due to missing sys/wait.h include - -1.11 - 2015-04-05 - - Added 'b' key to spawn shell in the current directory - - Support scanning (and refreshing) of empty directories - - Added --si flag for base 10 prefixes - - Fix toggle dirs before files - -1.10 - 2013-05-09 - - Added 'c' key to display item counts - - Added 'C' key to order by item counts - - Added CACHEDIR.TAG support and --exclude-caches option - - Use locale-dependent thousand separator - - Use pkg-config to detect ncurses - - Clip file/dir sizes to 8 EiB minus one byte - - Fix buffer overflow when formatting huge file sizes - -1.9 - 2012-09-27 - - Added option to dump scanned directory information to a file (-o) - - Added option to load scanned directory information from a file (-f) - - Added multiple scan and load interfaces (-0,-1,-2) - - Fit loading and error windows to the terminal width (#13) - - Fix symlink resolving bug (#18) - - Fix path display when scanning an empty directory (#15) - - Fix hang when terminal is resized to a too small size while loading - - Use top-level automake build - - Remove useless AUTHORS, INSTALL and NEWS files - - ncdu.1 now uses POD as source format - -1.8 - 2011-11-03 - - Use hash table to speed up hard link detection - - Added read-only option (-r) - - Use KiB instead of kiB (#3399279) - -1.7 - 2010-08-13 - - List the detected hard links in file info window - - Count the size of a hard linked file once for each directory it appears in - - Fixed crash on browsing dirs with a small window size (#2991787) - - Fixed buffer overflow when some directories can't be scanned (#2981704) - - Fixed segfault when launched on a nonexistent directory (#3012787) - - Fixed segfault when root dir only contains hidden files - - Improved browsing performance - - More intuitive multi-page browsing - - Display size graph by default - - Various minor fixes - -1.6 - 2009-10-23 - - Implemented hard link detection - - Properly select the next item after deletion - - Removed reliance of dirfd() - - Fixed non-void return in void delete_process() - - Fixed several tiny memory leaks - - Return to previously opened directory on failed recalculation - - Properly display MiB units instead of MB (IEEE 1541 - bug #2831412) - - Link to ncursesw when available - - Improved support for non-ASCII characters - - VIM keybindings for browsing through the tree (#2788249, #1880622) - -1.5 - 2009-05-02 - - Fixed incorrect apparent size on directory refresh - - Browsing keys now work while file info window is displayed - - Current directory is assumed when no directory is specified - - Size graph uses the apparent size if that is displayed - - Items are ordered by displayed size rather than disk usage - - Removed switching between powers of 1000/1024 - - Don't rely on the availability of suseconds_t - - Correctly handle paths longer than PATH_MAX - - Fixed various bugs related to rpath() - - Major code rewrite - - Fixed line width when displaying 100% - -1.4 - 2008-09-10 - - Removed the startup window - - Filenames ending with a tidle (~) will now also - be hidden with the 'h'-key - - Fixed buffer overflow when supplying a path longer - than PATH_MAX (patch by Tobias Stoeckmann) - - Used S_BLKSIZE instead of a hardcoded block size of 512 - - Fixed display of disk usage and apparent sizes - - Updated ncdu -h - - Included patches for Cygwin - - Cursor now follows the selected item - - Added spaces around path (debian #472194) - - Fixed segfault on empty directory (debian #472294) - - A few code rewrites and improvements - -1.3 - 2007-08-05 - - Added 'r'-key to refresh the current directory - - Removed option to calculate apparent size: both - the disk usage and the apparent size are calculated. - - Added 'a'-key to switch between showing apparent - size and disk usage. - - Added 'i'-key to display information about the - selected item. - - Small performance improvements - - configure checks for ncurses.h (bug #1764304) - -1.2 - 2007-07-24 - - Fixed some bugs on cygwin - - Added du-like exclude patterns - - Fixed bug #1758403: large directories work fine now - - Rewrote a large part of the code - - Fixed a bug with wide characters - - Performance improvements when browsing large dirs - -1.1 - 2007-04-30 - - Deleting files and directories is now possible from - within ncdu. - - The key for sorting directories between files has - changed to 't' instead of 'd'. The 'd'-key is now - used for deleting files. - -1.0 - 2007-04-06 - - First stable release - - Small code cleanup - - Added a key to toggle between sorting dirs before - files and dirs between files - - Added graphs and percentages to the directory - browser (can be enabled or disabled with the 'g'-key) - -0.3 - 2007-03-04 - - When browsing back to the previous directory, the - directory you're getting back from will be selected. - - Added directory scanning in quiet mode to save - bandwidth on remote connections. - -0.2 - 2007-02-26 - - Fixed POSIX compliance: replaced realpath() with my - own implementation, and gettimeofday() is not - required anymore (but highly recommended) - - Added a warning for terminals smaller than 60x16 - - Mountpoints (or any other directory pointing to - another filesystem) are now considered to be - directories rather than files. - -0.1 - 2007-02-21 - - Initial version @@ -1,39 +1,37 @@ -# SPDX-FileCopyrightText: 2021 Yoran Heling <projects@yorhel.nl> +# SPDX-FileCopyrightText: 2021-2023 Yoran Heling <projects@yorhel.nl> # SPDX-License-Identifier: MIT # Optional semi-standard Makefile with some handy tools. # Ncdu itself can be built with just the zig build system. +ZIG ?= zig + PREFIX ?= /usr/local BINDIR ?= ${PREFIX}/bin MANDIR ?= ${PREFIX}/share/man/man1 +ZIG_FLAGS ?= -Doptimize=ReleaseFast NCDU_VERSION=$(shell grep 'program_version = "' src/main.zig | sed -e 's/^.*"\(.\+\)".*$$/\1/') +.PHONY: build +build: release + release: - zig build -Drelease-fast + $(ZIG) build ${ZIG_FLAGS} debug: - zig build + $(ZIG) build clean: rm -rf zig-cache zig-out -distclean: clean - rm -f ncdu.1 - -doc: ncdu.1 - -ncdu.1: ncdu.pod src/main.zig - pod2man --center "ncdu manual" --release "ncdu-${NCDU_VERSION}" ncdu.pod >ncdu.1 - install: install-bin install-doc install-bin: release mkdir -p ${BINDIR} install -m0755 zig-out/bin/ncdu ${BINDIR}/ -install-doc: doc +install-doc: mkdir -p ${MANDIR} install -m0644 ncdu.1 ${MANDIR}/ @@ -46,10 +44,10 @@ uninstall-bin: uninstall-doc: rm -f ${MANDIR}/ncdu.1 -dist: doc +dist: rm -f ncdu-${NCDU_VERSION}.tar.gz mkdir ncdu-${NCDU_VERSION} - for f in ncdu.1 `git ls-files | grep -v ^\.gitignore`; do mkdir -p ncdu-${NCDU_VERSION}/`dirname $$f`; ln -s "`pwd`/$$f" ncdu-${NCDU_VERSION}/$$f; done + for f in `git ls-files | grep -v ^\.gitignore`; do mkdir -p ncdu-${NCDU_VERSION}/`dirname $$f`; ln -s "`pwd`/$$f" ncdu-${NCDU_VERSION}/$$f; done tar -cophzf ncdu-${NCDU_VERSION}.tar.gz --sort=name ncdu-${NCDU_VERSION} rm -rf ncdu-${NCDU_VERSION} @@ -64,25 +62,25 @@ static-%.tar.gz: --without-gpm --without-sysmouse --enable-widec --with-default-terminfo-dir=/usr/share/terminfo\ --with-terminfo-dirs=/usr/share/terminfo:/lib/terminfo:/usr/local/share/terminfo\ --with-fallbacks="screen linux vt100 xterm xterm-256color" --host=$*\ - CC="zig cc --target=$*"\ - LD="zig cc --target=$*"\ - AR="zig ar" RANLIB="zig ranlib"\ + CC="${ZIG} cc --target=$*"\ + LD="${ZIG} cc --target=$*"\ + AR="${ZIG} ar" RANLIB="${ZIG} ranlib"\ CPPFLAGS=-D_GNU_SOURCE && make && make install.libs @# zig-build - cleaner approach but doesn't work, results in a dynamically linked binary. @#cd static-$* && PKG_CONFIG_LIBDIR="`pwd`/inst/pkg" zig build -Dtarget=$* @# --build-file ../build.zig --search-prefix inst/ --cache-dir zig -Drelease-fast=true @# Alternative approach, bypassing zig-build - cd static-$* && zig build-exe -target $*\ + cd static-$* && ${ZIG} build-exe -target $*\ -Iinst/include -Iinst/include/ncursesw -lc inst/lib/libncursesw.a\ - --cache-dir zig-cache -static --strip -O ReleaseFast ../src/main.zig ../src/ncurses_refs.c + --cache-dir zig-cache -static -fstrip -O ReleaseFast ../src/main.zig cd static-$* && mv main ncdu && tar -czf ../static-$*.tar.gz ncdu rm -rf static-$* static-linux-x86_64: static-x86_64-linux-musl.tar.gz mv $< ncdu-${NCDU_VERSION}-linux-x86_64.tar.gz -static-linux-i386: static-i386-linux-musl.tar.gz - mv $< ncdu-${NCDU_VERSION}-linux-i386.tar.gz +static-linux-x86: static-x86-linux-musl.tar.gz + mv $< ncdu-${NCDU_VERSION}-linux-x86.tar.gz static-linux-aarch64: static-aarch64-linux-musl.tar.gz mv $< ncdu-${NCDU_VERSION}-linux-aarch64.tar.gz @@ -92,6 +90,6 @@ static-linux-arm: static-arm-linux-musleabi.tar.gz static:\ static-linux-x86_64 \ - static-linux-i386 \ + static-linux-x86 \ static-linux-aarch64 \ static-linux-arm @@ -1,5 +1,5 @@ <!-- -SPDX-FileCopyrightText: 2021 Yoran Heling <projects@yorhel.nl> +SPDX-FileCopyrightText: 2021-2023 Yoran Heling <projects@yorhel.nl> SPDX-License-Identifier: MIT --> @@ -19,7 +19,7 @@ C version (1.x). ## Requirements -- Zig 0.8 +- Zig 0.11.0 - Some sort of POSIX-like OS - ncurses libraries and header files @@ -1,21 +1,31 @@ -// SPDX-FileCopyrightText: 2021 Yoran Heling <projects@yorhel.nl> +// SPDX-FileCopyrightText: 2021-2023 Yoran Heling <projects@yorhel.nl> // SPDX-License-Identifier: MIT const std = @import("std"); -pub fn build(b: *std.build.Builder) void { +pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); - const mode = b.standardReleaseOptions(); + const optimize = b.standardOptimizeOption(.{}); - const exe = b.addExecutable("ncdu", "src/main.zig"); - exe.setTarget(target); - exe.setBuildMode(mode); - exe.addCSourceFile("src/ncurses_refs.c", &[_][]const u8{}); - exe.linkLibC(); - exe.linkSystemLibrary("ncursesw"); - exe.install(); + const pie = b.option(bool, "pie", "Build with PIE support (by default false)") orelse false; - const run_cmd = exe.run(); + const exe = b.addExecutable(.{ + .name = "ncdu", + .root_source_file = .{ .path = "src/main.zig" }, + .target = target, + .optimize = optimize, + }); + + // https://github.com/ziglang/zig/blob/b52be973dfb7d1408218b8e75800a2da3dc69108/build.zig#L551-L554 + if (exe.target.isDarwin()) { + // useful for package maintainers + exe.headerpad_max_install_names = true; + } + linkNcurses(exe); + exe.pie = pie; + b.installArtifact(exe); + + const run_cmd = b.addRunArtifact(exe); run_cmd.step.dependOn(b.getInstallStep()); if (b.args) |args| { run_cmd.addArgs(args); @@ -24,10 +34,21 @@ pub fn build(b: *std.build.Builder) void { const run_step = b.step("run", "Run the app"); run_step.dependOn(&run_cmd.step); - const tst = b.addTest("src/main.zig"); - tst.linkLibC(); - tst.linkSystemLibrary("ncursesw"); - tst.addCSourceFile("src/ncurses_refs.c", &[_][]const u8{}); - const tst_step = b.step("test", "Run tests"); - tst_step.dependOn(&tst.step); + const unit_tests = b.addTest(.{ + .root_source_file = .{ .path = "src/main.zig" }, + .target = target, + .optimize = optimize, + }); + linkNcurses(unit_tests); + unit_tests.pie = pie; + + const run_unit_tests = b.addRunArtifact(unit_tests); + + const test_step = b.step("test", "Run unit tests"); + test_step.dependOn(&run_unit_tests.step); +} + +pub fn linkNcurses(compile_step: *std.Build.CompileStep) void { + compile_step.linkSystemLibrary("ncursesw"); + compile_step.linkLibC(); } @@ -0,0 +1,515 @@ +.\" SPDX-FileCopyrightText: Yoran Heling <projects@yorhel.nl> +.\" SPDX-License-Identifier: MIT +.Dd January 21, 2024 +.Dt NCDU 1 +.Os +.Sh NAME +.Nm ncdu +.Nd NCurses Disk Usage +.Sh SYNOPSIS +.Nm +.Op Fl f Ar file +.Op Fl o Ar file +.Op Fl e , \-extended , \-no\-extended +.Op Fl \-ignore\-config +.Op Fl x , \-one\-file\-system , \-cross\-file\-system +.Op Fl \-exclude Ar pattern +.Op Fl X , \-exclude\-from Ar file +.Op Fl \-include\-caches , \-exclude\-caches +.Op Fl L , \-follow\-symlinks , \-no\-follow\-symlinks +.Op Fl \-include\-kernfs , \-exclude\-kernfs +.Op Fl 0 , 1 , 2 +.Op Fl q , \-slow\-ui\-updates , \-fast\-ui\-updates +.Op Fl \-enable\-shell , \-disable\-shell +.Op Fl \-enable\-delete , \-disable\-delete +.Op Fl \-enable\-refresh , \-disable\-refresh +.Op Fl r +.Op Fl \-si , \-no\-si +.Op Fl \-disk\-usage , \-apparent\-size +.Op Fl \-show\-hidden , \-hide\-hidden +.Op Fl \-show\-itemcount , \-hide\-itemcount +.Op Fl \-show\-mtime , \-hide\-mtime +.Op Fl \-show\-graph , \-hide\-graph +.Op Fl \-show\-percent , \-hide\-percent +.Op Fl \-graph\-style Ar hash | half\-block | eighth\-block +.Op Fl \-shared\-column Ar off | shared | unique +.Op Fl \-sort Ar column +.Op Fl \-enable\-natsort , \-disable\-natsort +.Op Fl \-group\-directories\-first , \-no\-group\-directories\-first +.Op Fl \-confirm\-quit , \-no\-confirm\-quit +.Op Fl \-confirm\-delete , \-no\-confirm\-delete +.Op Fl \-color Ar off | dark | dark-bg +.Op Ar path +.Nm +.Op Fl h , \-help +.Nm +.Op Fl v , V , \-version +.Sh DESCRIPTION +.Nm +(NCurses Disk Usage) is an interactive curses-based version of the well-known +.Xr du 1 , +and provides a fast way to see what directories are using your disk space. +.Sh OPTIONS +.Ss Mode Selection +.Bl -tag -width Ds +.It Fl h , \-help +Print a short help message and quit. +.It Fl v , V , \-version +Print version and quit. +.It Fl f Ar file +Load the given file, which has earlier been created with the +.Fl o +flag. +If +.Ar file +is equivalent to '\-', the file is read from standard input. +.Pp +For the sake of preventing a screw-up, the current version of +.Nm +will assume that the directory information in the imported file does not +represent the filesystem on which the file is being imported. +That is, the refresh, file deletion and shell spawning options in the browser +will be disabled. +.It Ar dir +Scan the given directory. +.It Fl o Ar file +Export all necessary information to +.Ar file +instead of opening the browser interface. +If +.Ar file +is '\-', the data is written to standard output. +See the examples section below for some handy use cases. +.Pp +Be warned that the exported data may grow quite large when exporting a +directory with many files. +10.000 files will get you an export in the order of 600 to 700 KiB +uncompressed, or a little over 100 KiB when compressed with gzip. +This scales linearly, so be prepared to handle a few tens of megabytes when +dealing with millions of files. +.It Fl e , \-extended , \-no\-extended +Enable/disable extended information mode. +This will, in addition to the usual file information, also read the ownership, +permissions and last modification time for each file. +This will result in higher memory usage (by roughly ~30%) and in a larger +output file when exporting. +.Pp +When using the file export/import function, this flag should be added both when +exporting (to make sure the information is added to the export) and when +importing (to read this extra information in memory). +This flag has no effect when importing a file that has been exported without +the extended information. +.Pp +This enables viewing and sorting by the latest child mtime, or modified time, +using 'm' and 'M', respectively. +.It Fl \-ignore\-config +Do not attempt to load any configuration files. +.El +.Ss Scan Options +These options affect the scanning progress, they have no effect when importing +directory information from a file. +.Bl -tag -width Ds +.It Fl x , \-one\-file\-system +Do not cross filesystem boundaries, i.e. only count files and directories on +the same filesystem as the directory being scanned. +.It Fl \-cross\-file\-system +Do cross filesystem boundaries. +This is the default, but can be specified to overrule a previously configured +.Fl x . +.It Fl \-exclude Ar pattern +Exclude files that match +.Ar pattern . +The files are still displayed by default, but are not counted towards the disk +usage statistics. +This argument can be added multiple times to add more patterns. +.It Fl X , \-exclude\-from Ar file +Exclude files that match any pattern in +.Ar file . +Patterns should be separated by a newline. +.It Fl \-include\-caches , \-exclude\-caches +Include (default) or exclude directories containing +.Pa CACHEDIR.TAG . +Excluded cache directories are still displayed, but their contents will not be +scanned or counted towards the disk usage statistics. +.Lk https://bford.info/cachedir/ +.It Fl L , \-follow\-symlinks , \-no\-follow\-symlinks +Follow (or not) symlinks and count the size of the file they point to. +This option does not follow symlinks to directories and will cause each +symlinked file to count as a unique file. +This is different from how hard links are handled. +The exact counting behavior of this flag is subject to change in the future. +.It Fl \-include\-kernfs , \-exclude\-kernfs +(Linux only) Include (default) or exclude Linux pseudo filesystems such as +.Pa /proc +(procfs) and +.Pa /sys +(sysfs). +.Pp +The complete list of currently known pseudo filesystems is: binfmt, bpf, cgroup, +cgroup2, debug, devpts, proc, pstore, security, selinux, sys, trace. +.El +.Ss Interface Options +.Bl -tag -width Ds +.It Fl 0 +Don't give any feedback while scanning a directory or importing a file, except +when a fatal error occurs. +Ncurses will not be initialized until the scan is complete. +When exporting the data with +.Fl o , +ncurses will not be initialized at all. +This option is the default when exporting to standard output. +.It Fl 1 +Similar to +.Fl 0 , +but does give feedback on the scanning progress with a single line of output. +This option is the default when exporting to a file. +.Pp +In some cases, the ncurses browser interface which you'll see after the +scan/import is complete may look garbled when using this option. +If you're not exporting to a file, +.Fl 2 +is usually a better choice. +.It Fl 2 +Show a full-screen ncurses interface while scanning a directory or importing +a file. +This is the only interface that provides feedback on any non-fatal errors while +scanning. +.It Fl q , \-slow\-ui\-updates , \-fast\-ui\-updates +Change the UI update interval while scanning or importing. +.Nm +updates the screen 10 times a second by default (with +.Fl \-fast\-ui\-updates +), this can be decreased to once every 2 seconds with +.Fl q +or +.Fl \-slow\-ui\-updates . +This option can be used to save bandwidth over remote connections. +This option has no effect in combination with +.Fl 0 . +.It Fl \-enable\-shell , \-disable\-shell +Enable or disable shell spawning from the file browser. +This feature is enabled by default when scanning a live directory and disabled +when importing from file. +.It Fl \-enable\-delete , \-disable\-delete +Enable or disable the built-in file deletion feature. +This feature is enabled by default when scanning a live directory and disabled +when importing from file. +Explicitly disabling the deletion feature can work as a safeguard to prevent +accidental data loss. +.It Fl \-enable\-refresh , \-disable\-refresh +Enable or disable directory refreshing from the file browser. +This feature is enabled by default when scanning a live directory and disabled +when importing from file. +.It Fl r +Read-only mode. +When given once, this is an alias for +.Fl \-disable\-delete , +when given twice it will also add +.Fl \-disable\-shell , +thus ensuring that there is no way to modify the file system from within +.Nm . +.It Fl \-si , \-no\-si +List sizes using base 10 prefixes, that is, powers of 1000 (KB, MB, etc), as +defined in the International System of Units (SI), instead of the usual base 2 +prefixes (KiB, MiB, etc). +.It Fl \-disk\-usage , \-apparent\-size +Select whether to display disk usage (default) or apparent sizes. +Can also be toggled in the file browser with the 'a' key. +.It Fl \-show\-hidden , \-hide\-hidden +Show (default) or hide "hidden" and excluded files. +Can also be toggled in the file browser with the 'e' key. +.It Fl \-show\-itemcount , \-hide\-itemcount +Show or hide (default) the item counts column. +Can also be toggled in the file browser with the 'c' key. +.It Fl \-show\-mtime , \-hide\-mtime +Show or hide (default) the last modification time column. +Can also be toggled in the file browser with the 'm' key. +This option is ignored when not in extended mode, see +.Fl e . +.It Fl \-show\-graph , \-hide\-graph +Show (default) or hide the relative size bar column. +Can also be toggled in the file browser with the 'g' key. +.It Fl \-show\-percent , \-hide\-percent +Show (default) or hide the relative size percent column. +Can also be toggled in the file browser with the 'g' key. +.It Fl \-graph\-style Ar hash | half\-block | eighth\-block +Change the way that the relative size bar column is drawn. +Recognized values are +.Ar hash +to draw ASCII '#' characters (default and most portable), +.Ar half\-block +to use half-block drawing characters or +.Ar eighth\-block +to use eighth-block drawing characters. +Eighth-block characters are the most precise but may not render correctly in +all terminals. +.It Fl \-shared\-column Ar off | shared | unique +Set to +.Ar off +to disable the shared size column for directories, +.Ar shared +(default) to display shared directory sizes as a separate column or +.Ar unique +to display unique directory sizes as a separate column. +These options can also be cycled through in the file browser with the 'u' key. +.It Fl \-sort Ar column +Change the default column to sort on. +Accepted values are +.Ar disk\-usage +(the default), +.Ar name , apparent\-size , itemcount +or +.Ar mtime . +The latter only makes sense in extended mode, see +.Fl e . +.Pp +The column name can be suffixed with +.Li \-asc +or +.Li \-desc +to change the order to ascending or descending, respectively. +For example, +.Li \-\-sort=name\-desc +to sort by name in descending order. +.It Fl \-enable\-natsort , \-disable\-natsort +Enable (default) or disable natural sort when sorting by file name. +.It Fl \-group\-directories\-first , \-no\-group\-directories\-first +Sort (or not) directories before files. +.It Fl \-confirm\-quit , \-no\-confirm\-quit +Require a confirmation before quitting ncdu. +Can be helpful when you accidentally press 'q' during or after a very long scan. +.It Fl \-confirm\-delete , \-no\-confirm\-delete +Require a confirmation before deleting a file or directory. +Enabled by default, but can be disabled if you're absolutely sure you won't +accidentally press 'd'. +.It Fl \-color Ar off | dark | dark-bg +Set the color scheme. +The following schemes are recognized: +.Ar off +to disable colors, +.Ar dark +for a color scheme intended for dark backgrounds and +.Ar dark\-bg +for a variation of the +.Ar dark +color scheme that also works in terminals with a light background. +.Pp +The default is +.Ar dark\-bg +unless the +.Ev NO_COLOR +environment variable is set. +.El +.Sh CONFIGURATION +.Nm +can be configured by placing command-line options in +.Pa /etc/ncdu.conf +or +.Pa $HOME/.config/ncdu/config . +If both files exist, the system configuration will be loaded before the user +configuration, allowing users to override options set in the system +configuration. +Options given on the command line will override options set in the +configuration files. +The files will not be read at all when +.Fl \-ignore\-config +is given on the command line. +.Pp +The configuration file format is simply one command line option per line. +Lines starting with '#' are ignored. +Example configuration file: +.Bd -literal -offset indent +# Always enable extended mode +\-e + +# Disable file deletion +\-\-disable\-delete + +# Exclude .git directories +\-\-exclude .git +.Ed +.Sh KEYS +.Bl -tag -width Ds +.It ? +Open help + keys + about screen +.It up , down , j , k +Cycle through the items +.It right, enter, l +Open selected directory +.It left, <, h +Go to parent directory +.It n +Order by filename (press again for descending order) +.It s +Order by filesize (press again for descending order) +.It C +Order by number of items (press again for descending order) +.It a +Toggle between showing disk usage and showing apparent size. +.It M +Order by latest child mtime, or modified time (press again for descending +order). +Requires the +.Fl e +flag. +.It d +Delete the selected file or directory. +An error message will be shown when the contents of the directory do not match +or do not exist anymore on the filesystem. +.It t +Toggle dirs before files when sorting. +.It g +Toggle between showing percentage, graph, both, or none. +Percentage is relative to the size of the current directory, graph is relative +to the largest item in the current directory. +.It u +Toggle display of the shared / unique size column for directories that share +hard links. +This column is only visible if the current listing contains directories with +shared hard links. +.It c +Toggle display of child item counts. +.It m +Toggle display of latest child mtime, or modified time. +Requires the +.Fl e +flag. +.It e +Show/hide 'hidden' or 'excluded' files and directories. +Be aware that even if you can't see the hidden files and directories, they are +still there and they are still included in the directory sizes. +If you suspect that the totals shown at the bottom of the screen are not +correct, make sure you haven't enabled this option. +.It i +Show information about the current selected item. +.It r +Refresh/recalculate the current directory. +.It b +Spawn shell in current directory. +.Pp +.Nm +determines your preferred shell from the +.Ev NCDU_SHELL +or +.Ev SHELL +environment variable (in that order), or calls +.Pa /bin/sh +if neither are set. +This allows you to also configure another command to be run when he 'b' key is +pressed. +For example, to spawn the +.Xr vifm 1 +file manager instead of a shell, run +.Nm +as follows: +.Dl NCDU_SHELL=vifm ncdu +The +.Ev NCDU_LEVEL +environment variable is set or incremented before spawning the shell, allowing +you to detect if your shell is running from within +.Nm . +This can be useful to avoid nesting multiple instances, although +.Nm +itself does not (currently) warn about or prevent this situation. +.It q +Quit +.El +.Sh FILE FLAGS +Entries in the browser interface may be prefixed by a one\-character flag. +These flags have the following meaning: +.Bl -tag -width Ds +.It ! +An error occurred while reading this directory. +.It \. +An error occurred while reading a subdirectory, so the indicated size may not +be correct. +.It < +File or directory is excluded from the statistics by using exclude patterns. +.It > +Directory is on another filesystem. +.It ^ +Directory is excluded from the statistics due to being a Linux pseudo +filesystem. +.It @ +This is neither a file nor a folder (symlink, socket, ...). +.It H +Same file was already counted (hard link). +.It e +Empty directory. +.El +.Sh EXAMPLES +To scan and browse the directory you're currently in, all you need is a simple: +.Dl ncdu +If you want to scan a full filesystem, for example your root filesystem, then +you'll want to use +.Fl x : +.Dl ncdu \-x / +.Pp +Since scanning a large directory may take a while, you can scan a directory and +export the results for later viewing: +.Bd -literal -offset indent +ncdu \-1xo\- / | gzip >export.gz +# ...some time later: +zcat export.gz | ncdu \-f\- +.Ed +To export from a cron job, make sure to replace +.Fl 1 +with +.Fl 0 +to suppress any unnecessary output. +.Pp +You can also export a directory and browse it once scanning is done: +.Dl ncdu \-o\- | tee export.file | ./ncdu \-f\- +The same is possible with gzip compression, but is a bit kludgey: +.Dl ncdu \-o\- | gzip | tee export.gz | gunzip | ./ncdu \-f\- +.Pp +To scan a system remotely, but browse through the files locally: +.Dl ssh \-C user@system ncdu \-o\- / | ./ncdu \-f\- +The +.Fl C +option to ssh enables compression, which will be very useful over slow links. +Remote scanning and local viewing has two major advantages when +compared to running +.Nm +directly on the remote system: You can browse through the scanned directory on +the local system without any network latency, and +.Nm +does not keep the entire directory structure in memory when exporting, so this +won't consume much memory on the remote system. +.Sh BUGS +Directory hard links and firmlinks (MacOS) are not supported. +They are not detected as being hard links, and will thus get scanned and +counted multiple times. +.Pp +Some minor glitches may appear when displaying filenames that contain multibyte +or multicolumn characters. +.Pp +The unique and shared directory sizes are calculated based on the assumption +that the link count of hard links does not change during a filesystem scan or +in between refreshes. +If this does happen, for example when a hard link is deleted, then these +numbers will be very much incorrect and a full refresh by restarting ncdu is +needed to get correct numbers again. +.Pp +All sizes are internally represented as a signed 64bit integer. +If you have a directory larger than 8 EiB minus one byte, ncdu will clip its +size to 8 EiB minus one byte. +When deleting or refreshing items in a directory with a clipped size, the +resulting sizes will be incorrect. +Likewise, item counts are stored in a 32-bit integer, so will be incorrect in +the unlikely event that you happen to have more than 4 billion items in a +directory. +.Pp +Please report any other bugs you may find at the bug tracker, which can be +found on the web site at +.Lk https://dev.yorhel.nl/ncdu +.Sh SEE ALSO +.Xr du 1 , +.Xr tree 1 . +.Pp +.Nm +has a website: +.Lk https://dev.yorhel.nl/ncdu +.Sh AUTHORS +Written by +.An Yorhel Aq Mt projects@yorhel.nl diff --git a/ncdu.pod b/ncdu.pod deleted file mode 100644 index caa9812..0000000 --- a/ncdu.pod +++ /dev/null @@ -1,454 +0,0 @@ -SPDX-FileCopyrightText: 2021 Yoran Heling <projects@yorhel.nl> -SPDX-License-Identifier: MIT - -=head1 NAME - -B<ncdu> - NCurses Disk Usage - - -=head1 SYNOPSIS - -B<ncdu> [I<options>] I<dir> - - -=head1 DESCRIPTION - -ncdu (NCurses Disk Usage) is a curses-based version of the well-known 'du', and -provides a fast way to see what directories are using your disk space. - - -=head1 OPTIONS - -=head2 Mode Selection - -=over - -=item -h, --help - -Print a short help message and quit. - -=item -v, -V, --version - -Print ncdu version and quit. - -=item -f I<FILE> - -Load the given file, which has earlier been created with the C<-o> option. If -I<FILE> is equivalent to C<->, the file is read from standard input. - -For the sake of preventing a screw-up, the current version of ncdu will assume -that the directory information in the imported file does not represent the -filesystem on which the file is being imported. That is, the refresh, file -deletion and shell spawning options in the browser will be disabled. - -=item I<dir> - -Scan the given directory. - -=item -o I<FILE> - -Export all necessary information to I<FILE> instead of opening the browser -interface. If I<FILE> is C<->, the data is written to standard output. See the -examples section below for some handy use cases. - -Be warned that the exported data may grow quite large when exporting a -directory with many files. 10.000 files will get you an export in the order of -600 to 700 KiB uncompressed, or a little over 100 KiB when compressed with -gzip. This scales linearly, so be prepared to handle a few tens of megabytes -when dealing with millions of files. - -=item -e - -Enable extended information mode. This will, in addition to the usual file -information, also read the ownership, permissions and last modification time -for each file. This will result in higher memory usage (by roughly ~30%) and in -a larger output file when exporting. - -When using the file export/import function, this flag will need to be added -both when exporting (to make sure the information is added to the export), and -when importing (to read this extra information in memory). This flag has no -effect when importing a file that has been exported without the extended -information. - -This enables viewing and sorting by the latest child mtime, or modified time, -using 'm' and 'M', respectively. - -=back - -=head2 Interface options - -=over - -=item -0 - -Don't give any feedback while scanning a directory or importing a file, other -than when a fatal error occurs. Ncurses will not be initialized until the scan -is complete. When exporting the data with C<-o>, ncurses will not be -initialized at all. This option is the default when exporting to standard -output. - -=item -1 - -Similar to C<-0>, but does give feedback on the scanning progress with a single -line of output. This option is the default when exporting to a file. - -In some cases, the ncurses browser interface which you'll see after the -scan/import is complete may look garbled when using this option. If you're not -exporting to a file, C<-2> is probably a better choice. - -=item -2 - -Provide a full-screen ncurses interface while scanning a directory or importing -a file. This is the only interface that provides feedback on any non-fatal -errors while scanning. - -=item -q - -Quiet mode. While scanning or importing the directory, ncdu will update the -screen 10 times a second by default, this will be decreased to once every 2 -seconds in quiet mode. Use this feature to save bandwidth over remote -connections. This option has no effect when C<-0> is used. - -=item -r - -Read-only mode. This will disable the built-in file deletion feature. This -option has no effect when C<-o> is used, because there will not be a browser -interface in that case. It has no effect when C<-f> is used, either, because -the deletion feature is disabled in that case anyway. - -WARNING: This option will only prevent deletion through the file browser. It is -still possible to spawn a shell from ncdu and delete or modify files from -there. To disable that feature as well, pass the C<-r> option twice (see -C<-rr>). - -=item -rr - -In addition to C<-r>, this will also disable the shell spawning feature of the -file browser. - -=item --si - -List sizes using base 10 prefixes, that is, powers of 1000 (KB, MB, etc), as -defined in the International System of Units (SI), instead of the usual base 2 -prefixes, that is, powers of 1024 (KiB, MiB, etc). - -=item --confirm-quit - -Requires a confirmation before quitting ncdu. Very helpful when you -accidentally press 'q' during or after a very long scan. - -=item --color I<SCHEME> - -Select a color scheme. Currently only two schemes are recognized: I<off> to -disable colors (the default) and I<dark> for a color scheme intended for dark -backgrounds. - -=back - -=head2 Scan Options - -These options affect the scanning progress, and have no effect when importing -directory information from a file. - -=over - -=item -x - -Do not cross filesystem boundaries, i.e. only count files and directories on -the same filesystem as the directory being scanned. - -=item --exclude I<PATTERN> - -Exclude files that match I<PATTERN>. The files will still be displayed by -default, but are not counted towards the disk usage statistics. This argument -can be added multiple times to add more patterns. - -=item -X I<FILE>, --exclude-from I<FILE> - -Exclude files that match any pattern in I<FILE>. Patterns should be separated -by a newline. - -=item --exclude-caches - -Exclude directories containing CACHEDIR.TAG. The directories will still be -displayed, but not their content, and they are not counted towards the disk -usage statistics. -See http://www.brynosaurus.com/cachedir/ - -=item -L, --follow-symlinks - -Follow symlinks and count the size of the file they point to. As of ncdu 1.14, -this option will not follow symlinks to directories and will count each -symlinked file as a unique file (i.e. unlike how hard links are handled). This -is subject to change in later versions. - -=item --exclude-firmlinks - -(MacOS only) Exclude firmlinks. - -=item --exclude-kernfs - -(Linux only) Exclude Linux pseudo filesystems, e.g. /proc (procfs), /sys (sysfs). - -The complete list of currently known pseudo filesystems is: binfmt, bpf, cgroup, -cgroup2, debug, devpts, proc, pstore, security, selinux, sys, trace. - -=back - - -=head1 KEYS - -=over - -=item ? - -Show help + keys + about screen - -=item up, down j, k - -Cycle through the items - -=item right, enter, l - -Open selected directory - -=item left, <, h - -Go to parent directory - -=item n - -Order by filename (press again for descending order) - -=item s - -Order by filesize (press again for descending order) - -=item C - -Order by number of items (press again for descending order) - -=item a - -Toggle between showing disk usage and showing apparent size. - -=item M - -Order by latest child mtime, or modified time. (press again for descending order) -Requires the -e flag. - -=item d - -Delete the selected file or directory. An error message will be shown when the -contents of the directory do not match or do not exist anymore on the -filesystem. - -=item t - -Toggle dirs before files when sorting. - -=item g - -Toggle between showing percentage, graph, both, or none. Percentage is relative -to the size of the current directory, graph is relative to the largest item in -the current directory. - -=item c - -Toggle display of child item counts. - -=item m - -Toggle display of latest child mtime, or modified time. Requires the -e flag. - -=item e - -Show/hide 'hidden' or 'excluded' files and directories. Please note that even -though you can't see the hidden files and directories, they are still there and -they are still included in the directory sizes. If you suspect that the totals -shown at the bottom of the screen are not correct, make sure you haven't -enabled this option. - -=item i - -Show information about the current selected item. - -=item r - -Refresh/recalculate the current directory. - -=item b - -Spawn shell in current directory. - -Ncdu will determine your preferred shell from the C<NCDU_SHELL> or C<SHELL> -variable (in that order), or will call C</bin/sh> if neither are set. This -allows you to also configure another command to be run when he 'b' key is -pressed. For example, to spawn the L<vifm(1)> file manager instead of a shell, -run ncdu as follows: - - export NCDU_SHELL=vifm - ncdu - -Ncdu will set the C<NCDU_LEVEL> environment variable or increment it before -spawning the shell. This variable allows you to detect when your shell is -running from within ncdu, which can be useful to avoid nesting multiple -instances of ncdu. Ncdu itself does not (currently) warn when attempting to run -nested instances. - -=item q - -Quit - -=back - - -=head1 FILE FLAGS - -Entries in the browser interface may be prefixed by a one-character flag. These -flags have the following meaning: - -=over - -=item ! - -An error occurred while reading this directory. - -=item . - -An error occurred while reading a subdirectory, so the indicated size may not be -correct. - -=item < - -File or directory is excluded from the statistics by using exclude patterns. - -=item > - -Directory is on another filesystem. - -=item ^ - -Directory is excluded from the statistics due to being a Linux pseudo filesystem. - -=item @ - -This is neither a file nor a folder (symlink, socket, ...). - -=item H - -Same file was already counted (hard link). - -=item e - -Empty directory. - -=back - - -=head1 EXAMPLES - -To scan and browse the directory you're currently in, all you need is a simple: - - ncdu - -If you want to scan a full filesystem, your root filesystem, for example, then -you'll want to use C<-x>: - - ncdu -x / - -Since scanning a large directory may take a while, you can scan a directory and -export the results for later viewing: - - ncdu -1xo- / | gzip >export.gz - # ...some time later: - zcat export.gz | ncdu -f- - -To export from a cron job, make sure to replace C<-1> with C<-0> to suppress -any unnecessary output. - -You can also export a directory and browse it once scanning is done: - - ncdu -o- | tee export.file | ./ncdu -f- - -The same is possible with gzip compression, but is a bit kludgey: - - ncdu -o- | gzip | tee export.gz | gunzip | ./ncdu -f- - -To scan a system remotely, but browse through the files locally: - - ssh -C user@system ncdu -o- / | ./ncdu -f- - -The C<-C> option to ssh enables compression, which will be very useful over -slow links. Remote scanning and local viewing has two major advantages when -compared to running ncdu directly on the remote system: You can browse through -the scanned directory on the local system without any network latency, and ncdu -does not keep the entire directory structure in memory when exporting, so you -won't consume much memory on the remote system. - - -=head1 HARD LINKS - -Every disk usage analysis utility has its own way of (not) counting hard links. -There does not seem to be any universally agreed method of handling hard links, -and it is even inconsistent among different versions of ncdu. This section -explains what each version of ncdu does. - -ncdu 1.5 and below does not support any hard link detection at all: each link -is considered a separate inode and its size is counted for every link. This -means that the displayed directory sizes are incorrect when analyzing -directories which contain hard links. - -ncdu 1.6 has basic hard link detection: When a link to a previously encountered -inode is detected, the link is considered to have a file size of zero bytes. -Its size is not counted again, and the link is indicated in the browser -interface with a 'H' mark. The displayed directory sizes are only correct when -all links to an inode reside within that directory. When this is not the case, -the sizes may or may not be correct, depending on which links were considered -as "duplicate" and which as "original". The indicated size of the topmost -directory (that is, the one specified on the command line upon starting ncdu) -is always correct. - -ncdu 1.7 and later has improved hard link detection. Each file that has more -than two links has the "H" mark visible in the browser interface. Each hard -link is counted exactly once for every directory it appears in. The indicated -size of each directory is therefore, correctly, the sum of the sizes of all -unique inodes that can be found in that directory. Note, however, that this may -not always be same as the space that will be reclaimed after deleting the -directory, as some inodes may still be accessible from hard links outside it. - - -=head1 BUGS - -Directory hard links and firmlinks (MacOS) are not supported. They will not be -detected as being hard links, and may thus be scanned and counted multiple -times. - -Some minor glitches may appear when displaying filenames that contain multibyte -or multicolumn characters. - -The unique and shared directory sizes are calculated based on the assumption -that the link count of hard links does not change during a filesystem scan or -in between refreshes. If it does, for example after deleting a hard link, then -these numbers will be very much incorrect and a full refresh by restarting ncdu -is needed to get correct numbers again. - -All sizes are internally represented as a signed 64bit integer. If you have a -directory larger than 8 EiB minus one byte, ncdu will clip its size to 8 EiB -minus one byte. When deleting or refreshing items in a directory with a clipped -size, the resulting sizes will be incorrect. Likewise, item counts are stored -in a 32-bit integer, so will be incorrect in the unlikely event that you happen -to have more than 4 billion items in a directory. - -Please report any other bugs you may find at the bug tracker, which can be -found on the web site at https://dev.yorhel.nl/ncdu - - -=head1 AUTHOR - -Written by Yoran Heling <projects@yorhel.nl>. - - -=head1 SEE ALSO - -L<du(1)> diff --git a/src/browser.zig b/src/browser.zig index b85d46a..44fe9f2 100644 --- a/src/browser.zig +++ b/src/browser.zig @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2021 Yoran Heling <projects@yorhel.nl> +// SPDX-FileCopyrightText: 2021-2023 Yoran Heling <projects@yorhel.nl> // SPDX-License-Identifier: MIT const std = @import("std"); @@ -8,7 +8,7 @@ const scan = @import("scan.zig"); const delete = @import("delete.zig"); const ui = @import("ui.zig"); const c = @cImport(@cInclude("time.h")); -usingnamespace @import("util.zig"); +const util = @import("util.zig"); // Currently opened directory. pub var dir_parent: *model.Dir = undefined; @@ -44,15 +44,15 @@ const View = struct { fn save(self: *@This()) void { self.cursor_hash = if (dir_items.items.len == 0) 0 else hashEntry(dir_items.items[cursor_idx]); - opened_dir_views.put(@ptrToInt(dir_parent), self.*) catch {}; + opened_dir_views.put(@intFromPtr(dir_parent), self.*) catch {}; } // Should be called after dir_parent or dir_items has changed, will load the last saved view and find the proper cursor_idx. fn load(self: *@This(), sel: ?*const model.Entry) void { - if (opened_dir_views.get(@ptrToInt(dir_parent))) |v| self.* = v + if (opened_dir_views.get(@intFromPtr(dir_parent))) |v| self.* = v else self.* = @This(){}; cursor_idx = 0; - for (dir_items.items) |e, i| { + for (dir_items.items, 0..) |e, i| { if (if (sel != null) e == sel else self.cursor_hash == hashEntry(e)) { cursor_idx = i; break; @@ -64,7 +64,7 @@ const View = struct { var current_view = View{}; // Directories the user has browsed to before, and which item was last selected. -// The key is the @ptrToInt() of the opened *Dir; An int because the pointer +// The key is the @intFromPtr() of the opened *Dir; An int because the pointer // itself may have gone stale after deletion or refreshing. They're only for // lookups, not dereferencing. var opened_dir_views = std.AutoHashMap(usize, View).init(main.allocator); @@ -83,31 +83,30 @@ fn sortLt(_: void, ap: ?*model.Entry, bp: ?*model.Entry) bool { switch (main.config.sort_col) { .name => {}, // name sorting is the fallback .blocks => { - if (sortIntLt(a.blocks, b.blocks)) |r| return r; + if (sortIntLt(a.pack.blocks, b.pack.blocks)) |r| return r; if (sortIntLt(a.size, b.size)) |r| return r; }, .size => { if (sortIntLt(a.size, b.size)) |r| return r; - if (sortIntLt(a.blocks, b.blocks)) |r| return r; + if (sortIntLt(a.pack.blocks, b.pack.blocks)) |r| return r; }, .items => { const ai = if (a.dir()) |d| d.items else 0; const bi = if (b.dir()) |d| d.items else 0; if (sortIntLt(ai, bi)) |r| return r; - if (sortIntLt(a.blocks, b.blocks)) |r| return r; + if (sortIntLt(a.pack.blocks, b.pack.blocks)) |r| return r; if (sortIntLt(a.size, b.size)) |r| return r; }, .mtime => { - if (!a.isext or !b.isext) return a.isext; + if (!a.pack.isext or !b.pack.isext) return a.pack.isext; if (sortIntLt(a.ext().?.mtime, b.ext().?.mtime)) |r| return r; }, } - // TODO: Unicode-aware sorting might be nice (and slow) - const an = a.name(); - const bn = b.name(); - return if (main.config.sort_order == .asc) std.mem.lessThan(u8, an, bn) - else std.mem.lessThan(u8, bn, an); + const an = (if (main.config.sort_order == .asc) a else b).name(); + const bn = (if (main.config.sort_order == .asc) b else a).name(); + return if (main.config.sort_natural) util.strnatcmp(an, bn) == .lt + else std.mem.lessThan(u8, an, bn); } // Should be called when: @@ -118,7 +117,7 @@ fn sortDir(next_sel: ?*const model.Entry) void { // No need to sort the first item if that's the parent dir reference, // excluding that allows sortLt() to ignore null values. const lst = dir_items.items[(if (dir_items.items.len > 0 and dir_items.items[0] == null) @as(usize, 1) else 0)..]; - std.sort.sort(?*model.Entry, lst, @as(void, undefined), sortLt); + std.mem.sort(?*model.Entry, lst, {}, sortLt); current_view.load(next_sel); } @@ -135,11 +134,11 @@ pub fn loadDir(next_sel: ?*const model.Entry) void { if (dir_parent != model.root) dir_items.append(null) catch unreachable; var it = dir_parent.sub; - while (it) |e| { - if (e.blocks > dir_max_blocks) dir_max_blocks = e.blocks; + while (it) |e| : (it = e.next) { + if (e.pack.blocks > dir_max_blocks) dir_max_blocks = e.pack.blocks; if (e.size > dir_max_size) dir_max_size = e.size; const shown = main.config.show_hidden or blk: { - const excl = if (e.file()) |f| f.excluded else false; + const excl = if (e.file()) |f| f.pack.excluded else false; const name = e.name(); break :blk !excl and name[0] != '.' and name[name.len-1] != '~'; }; @@ -147,7 +146,6 @@ pub fn loadDir(next_sel: ?*const model.Entry) void { dir_items.append(e) catch unreachable; if (e.dir()) |d| if (d.shared_blocks > 0 or d.shared_size > 0) { dir_has_shared = true; }; } - it = e.next; } sortDir(next_sel); } @@ -165,14 +163,14 @@ const Row = struct { const item = self.item orelse return; const ch: u7 = ch: { if (item.file()) |f| { - if (f.err) break :ch '!'; - if (f.excluded) break :ch '<'; - if (f.other_fs) break :ch '>'; - if (f.kernfs) break :ch '^'; - if (f.notreg) break :ch '@'; + if (f.pack.err) break :ch '!'; + if (f.pack.excluded) break :ch '<'; + if (f.pack.other_fs) break :ch '>'; + if (f.pack.kernfs) break :ch '^'; + if (f.pack.notreg) break :ch '@'; } else if (item.dir()) |d| { - if (d.err) break :ch '!'; - if (d.suberr) break :ch '.'; + if (d.pack.err) break :ch '!'; + if (d.pack.suberr) break :ch '.'; if (d.sub == null) break :ch 'e'; } else if (item.link()) |_| break :ch 'H'; return; @@ -188,13 +186,13 @@ const Row = struct { width += 2 + width; defer self.col += width; const item = self.item orelse return; - const siz = if (main.config.show_blocks) blocksToSize(item.blocks) else item.size; - var shr = if (item.dir()) |d| (if (main.config.show_blocks) blocksToSize(d.shared_blocks) else d.shared_size) else 0; - if (main.config.show_shared == .unique) shr = saturateSub(siz, shr); + const siz = if (main.config.show_blocks) util.blocksToSize(item.pack.blocks) else item.size; + var shr = if (item.dir()) |d| (if (main.config.show_blocks) util.blocksToSize(d.shared_blocks) else d.shared_size) else 0; + if (main.config.show_shared == .unique) shr = siz -| shr; ui.move(self.row, self.col); ui.addsize(self.bg, siz); - if (shr > 0 and main.config.show_shared != .off) { + if (dir_has_shared and shr > 0 and main.config.show_shared != .off) { self.bg.fg(.flag); ui.addstr(if (main.config.show_shared == .unique) " U " else " S "); ui.addsize(self.bg, shr); @@ -202,39 +200,46 @@ const Row = struct { } fn graph(self: *Self) void { - if (main.config.show_graph == .off or self.col + 20 > ui.cols) return; - - const bar_size = std.math.max(ui.cols/7, 10); - defer self.col += switch (main.config.show_graph) { - .off => unreachable, - .graph => bar_size + 3, - .percent => 9, - .both => bar_size + 10, - }; + if ((!main.config.show_graph and !main.config.show_percent) or self.col + 20 > ui.cols) return; + + const bar_size = @max(ui.cols/7, 10); + defer self.col += 3 + + (if (main.config.show_graph) bar_size else 0) + + (if (main.config.show_percent) @as(u32, 6) else 0) + + (if (main.config.show_graph and main.config.show_percent) @as(u32, 1) else 0); const item = self.item orelse return; ui.move(self.row, self.col); self.bg.fg(.default); ui.addch('['); - if (main.config.show_graph == .both or main.config.show_graph == .percent) { + if (main.config.show_percent) { self.bg.fg(.num); - ui.addprint("{d:>5.1}", .{ 100* - if (main.config.show_blocks) @intToFloat(f32, item.blocks) / @intToFloat(f32, std.math.max(1, dir_parent.entry.blocks)) - else @intToFloat(f32, item.size) / @intToFloat(f32, std.math.max(1, dir_parent.entry.size)) + ui.addprint("{d:>5.1}", .{ 100 * + if (main.config.show_blocks) @as(f32, @floatFromInt(item.pack.blocks)) / @as(f32, @floatFromInt(@max(1, dir_parent.entry.pack.blocks))) + else @as(f32, @floatFromInt(item.size)) / @as(f32, @floatFromInt(@max(1, dir_parent.entry.size))) }); self.bg.fg(.default); ui.addch('%'); } - if (main.config.show_graph == .both) ui.addch(' '); - if (main.config.show_graph == .both or main.config.show_graph == .graph) { - const perblock = std.math.divFloor(u64, if (main.config.show_blocks) dir_max_blocks else dir_max_size, bar_size) catch unreachable; - const num = if (main.config.show_blocks) item.blocks else item.size; - var i: u32 = 0; - var siz: u64 = 0; + if (main.config.show_graph and main.config.show_percent) ui.addch(' '); + if (main.config.show_graph) { + var max = if (main.config.show_blocks) dir_max_blocks else dir_max_size; + var num = if (main.config.show_blocks) item.pack.blocks else item.size; + if (max < bar_size) { + max *= bar_size; + num *= bar_size; + } + + const perblock = std.math.divFloor(u64, max, bar_size) catch unreachable; self.bg.fg(.graph); - while (i < bar_size) : (i += 1) { - siz = saturateAdd(siz, perblock); - ui.addch(if (siz <= num) '#' else ' '); + for (0..bar_size) |_| { + const frac = @min(@as(usize, 8), (num *| 8) / perblock); + ui.addstr(switch (main.config.graph_style) { + .hash => ([_][:0]const u8{ " ", " ", " ", " ", " ", " ", " ", " ", "#" })[frac], + .half => ([_][:0]const u8{ " ", " ", " ", " ", "▌", "▌", "▌", "▌", "█" })[frac], + .eighth => ([_][:0]const u8{ " ", "▏", "▎", "▍", "▌", "▋", "▊", "▉", "█" })[frac], + }); + num -|= perblock; } } self.bg.fg(.default); @@ -255,11 +260,11 @@ const Row = struct { } else if (n < 100_000) ui.addnum(self.bg, n) else if (n < 1000_000) { - ui.addprint("{d:>5.1}", .{ @intToFloat(f32, n) / 1000 }); + ui.addprint("{d:>5.1}", .{ @as(f32, @floatFromInt(n)) / 1000 }); self.bg.fg(.default); ui.addch('k'); } else if (n < 1000_000_000) { - ui.addprint("{d:>5.1}", .{ @intToFloat(f32, n) / 1000_000 }); + ui.addprint("{d:>5.1}", .{ @as(f32, @floatFromInt(n)) / 1000_000 }); self.bg.fg(.default); ui.addch('M'); } else { @@ -284,9 +289,9 @@ const Row = struct { fn name(self: *Self) void { ui.move(self.row, self.col); if (self.item) |i| { - self.bg.fg(if (i.etype == .dir) .dir else .default); + self.bg.fg(if (i.pack.etype == .dir) .dir else .default); ui.addch(if (i.isDirectory()) '/' else ' '); - ui.addstr(ui.shorten(ui.toUtf8(i.name()), saturateSub(ui.cols, self.col + 1))); + ui.addstr(ui.shorten(ui.toUtf8(i.name()), ui.cols -| self.col -| 1)); } else { self.bg.fg(.dir); ui.addstr("/.."); @@ -344,8 +349,8 @@ const info = struct { var links_idx: usize = 0; fn lt(_: void, a: *model.Link, b: *model.Link) bool { - var pa = a.path(false); - var pb = b.path(false); + const pa = a.path(false); + const pb = b.path(false); defer main.allocator.free(pa); defer main.allocator.free(pb); return std.mem.lessThan(u8, pa, pb); @@ -378,19 +383,18 @@ const info = struct { // TODO: Zig's sort() implementation is type-generic and not very // small. I suspect we can get a good save on our binary size by using // a smaller or non-generic sort. This doesn't have to be very fast. - std.sort.sort(*model.Link, list.items, @as(void, undefined), lt); - for (list.items) |n,i| if (&n.entry == e) { links_idx = i; }; + std.mem.sort(*model.Link, list.items, {}, lt); + for (list.items, 0..) |n,i| if (&n.entry == e) { links_idx = i; }; links = list; } } fn drawLinks(box: ui.Box, row: *u32, rows: u32, cols: u32) void { - const numrows = saturateSub(rows, 4); + const numrows = rows -| 4; if (links_idx < links_top) links_top = links_idx; if (links_idx >= links_top + numrows) links_top = links_idx - numrows + 1; - var i: u32 = 0; - while (i < numrows) : (i += 1) { + for (0..numrows) |i| { if (i + links_top >= links.?.items.len) break; const e = links.?.items[i+links_top]; ui.style(if (i+links_top == links_idx) .sel else .default); @@ -398,7 +402,7 @@ const info = struct { ui.addch(if (&e.entry == entry) '*' else ' '); const path = e.path(false); defer main.allocator.free(path); - ui.addstr(ui.shorten(ui.toUtf8(path), saturateSub(cols, 5))); + ui.addstr(ui.shorten(ui.toUtf8(path), cols -| 5)); row.* += 1; } ui.style(.default); @@ -422,7 +426,7 @@ const info = struct { if (shared > 0) { ui.style(.default); drawSizeRow(box, row, " > shared: ", shared); - drawSizeRow(box, row, " > unique: ", saturateSub(size, shared)); + drawSizeRow(box, row, " > unique: ", size -| shared); } } @@ -454,7 +458,7 @@ const info = struct { } else { ui.addstr("Type: "); ui.style(.default); - ui.addstr(if (e.isDirectory()) "Directory" else if (if (e.file()) |f| f.notreg else false) "Other" else "File"); + ui.addstr(if (e.isDirectory()) "Directory" else if (if (e.file()) |f| f.pack.notreg else false) "Other" else "File"); } row.* += 1; @@ -468,8 +472,8 @@ const info = struct { } // Disk usage & Apparent size - drawSize(box, row, " Disk usage: ", blocksToSize(e.blocks), if (e.dir()) |d| blocksToSize(d.shared_blocks) else 0); - drawSize(box, row, "Apparent size: ", e.size, if (e.dir()) |d| d.shared_size else 0); + drawSize(box, row, " Disk usage: ", util.blocksToSize(e.pack.blocks), if (e.dir()) |d| util.blocksToSize(d.shared_blocks) else 0); + drawSize(box, row, "Apparent size: ", e.size, if (e.dir()) |d| d.shared_size else 0); // Number of items if (e.dir()) |d| { @@ -516,7 +520,7 @@ const info = struct { var row: u32 = 2; // Tabs - if (e.etype == .link) { + if (e.pack.etype == .link) { box.tab(cols-19, tab == .info, 1, "Info"); box.tab(cols-10, tab == .links, 2, "Links"); } @@ -537,7 +541,7 @@ const info = struct { } fn keyInput(ch: i32) bool { - if (entry.?.etype == .link) { + if (entry.?.pack.etype == .link) { switch (ch) { '1', 'h', ui.c.KEY_LEFT => { set(entry, .info); return true; }, '2', 'l', ui.c.KEY_RIGHT => { set(entry, .links); return true; }, @@ -554,7 +558,7 @@ const info = struct { set(null, .info); } } - if (keyInputSelection(ch, &cursor_idx, dir_items.items.len, saturateSub(ui.rows, 3))) { + if (keyInputSelection(ch, &cursor_idx, dir_items.items.len, ui.rows -| 3)) { set(dir_items.items[cursor_idx], .info); return true; } @@ -567,7 +571,6 @@ const info = struct { }; const help = struct { - // TODO: Document 'u' key... once I have something final for it. const keys = [_][:0]const u8{ "up, k", "Move cursor up", "down, j", "Move cursor down", @@ -580,6 +583,7 @@ const help = struct { "d", "Delete selected file or directory", "t", "Toggle dirs before files when sorting", "g", "Show percentage and/or graph", + "u", "Show/hide hard link shared sizes", "a", "Toggle between apparent size and disk usage", "c", "Toggle display of child item counts", "m", "Toggle display of latest mtime (-e flag)", @@ -619,7 +623,7 @@ const help = struct { var i = offset*2; while (i < (offset + keylines)*2) : (i += 2) { line += 1; - box.move(line, 13 - @intCast(u32, keys[i].len)); + box.move(line, 13 - @as(u32, @intCast(keys[i].len))); ui.style(.key); ui.addstr(keys[i]); ui.style(.default); @@ -651,15 +655,12 @@ const help = struct { } fn drawAbout(box: ui.Box) void { - for (logo) |s, n| { - box.move(@intCast(u32, n)+3, 12); + for (logo, 0..) |s, n| { + box.move(@as(u32, @intCast(n+3)), 12); var i: u5 = 28; - while (true) { + while (i != 0) : (i -= 1) { ui.style(if (s & (@as(u29,1)<<i) > 0) .sel else .default); ui.addch(' '); - if (i == 0) - break; - i -= 1; } } ui.style(.default); @@ -727,10 +728,10 @@ pub fn draw() void { ui.style(.hd); ui.addstr(" for help"); if (main.config.imported) { - ui.move(0, saturateSub(ui.cols, 10)); + ui.move(0, ui.cols -| 10); ui.addstr("[imported]"); - } else if (main.config.read_only) { - ui.move(0, saturateSub(ui.cols, 10)); + } else if (!main.config.can_delete.?) { + ui.move(0, ui.cols -| 10); ui.addstr("[readonly]"); } @@ -743,13 +744,13 @@ pub fn draw() void { var pathbuf = std.ArrayList(u8).init(main.allocator); dir_parent.fmtPath(true, &pathbuf); - ui.addstr(ui.shorten(ui.toUtf8(arrayListBufZ(&pathbuf)), saturateSub(ui.cols, 5))); + ui.addstr(ui.shorten(ui.toUtf8(util.arrayListBufZ(&pathbuf)), ui.cols -| 5)); pathbuf.deinit(); ui.style(.default); ui.addch(' '); - const numrows = saturateSub(ui.rows, 3); + const numrows = ui.rows -| 3; if (cursor_idx < current_view.top) current_view.top = cursor_idx; if (cursor_idx >= current_view.top + numrows) current_view.top = cursor_idx - numrows + 1; @@ -769,14 +770,17 @@ pub fn draw() void { ui.style(.hd); ui.move(ui.rows-1, 0); ui.hline(' ', ui.cols); - ui.move(ui.rows-1, 1); + ui.move(ui.rows-1, 0); + ui.addch(if (main.config.show_blocks) '*' else ' '); ui.style(if (main.config.show_blocks) .bold_hd else .hd); ui.addstr("Total disk usage: "); - ui.addsize(.hd, blocksToSize(dir_parent.entry.blocks)); + ui.addsize(.hd, util.blocksToSize(dir_parent.entry.pack.blocks)); ui.style(if (main.config.show_blocks) .hd else .bold_hd); - ui.addstr(" Apparent size: "); + ui.addstr(" "); + ui.addch(if (main.config.show_blocks) ' ' else '*'); + ui.addstr("Apparent size: "); ui.addsize(.hd, dir_parent.entry.size); - ui.addstr(" Items: "); + ui.addstr(" Items: "); ui.addnum(.hd, dir_parent.items); switch (state) { @@ -812,9 +816,9 @@ fn keyInputSelection(ch: i32, idx: *usize, len: usize, page: u32) bool { if (idx.* > 0) idx.* -= 1; }, ui.c.KEY_HOME => idx.* = 0, - ui.c.KEY_END, ui.c.KEY_LL => idx.* = saturateSub(len, 1), - ui.c.KEY_PPAGE => idx.* = saturateSub(idx.*, page), - ui.c.KEY_NPAGE => idx.* = std.math.min(saturateSub(len, 1), idx.* + page), + ui.c.KEY_END, ui.c.KEY_LL => idx.* = len -| 1, + ui.c.KEY_PPAGE => idx.* = idx.* -| page, + ui.c.KEY_NPAGE => idx.* = @min(len -| 1, idx.* + page), else => return false, } return true; @@ -840,27 +844,23 @@ pub fn keyInput(ch: i32) void { '?' => state = .help, 'i' => if (dir_items.items.len > 0) info.set(dir_items.items[cursor_idx], .info), 'r' => { - if (main.config.imported) - message = "Directory imported from file, refreshing is disabled." + if (!main.config.can_refresh.?) + message = "Directory refresh feature disabled." else { main.state = .refresh; scan.setupRefresh(dir_parent); } }, 'b' => { - if (main.config.imported) - message = "Shell feature not available for imported directories." - else if (!main.config.can_shell) - message = "Shell feature disabled in read-only mode." + if (!main.config.can_shell.?) + message = "Shell feature disabled." else main.state = .shell; }, 'd' => { if (dir_items.items.len == 0) { - } else if (main.config.imported) - message = "Deletion feature not available for imported directories." - else if (main.config.read_only) - message = "Deletion feature disabled in read-only mode." + } else if (!main.config.can_delete.?) + message = "Deletion feature disabled." else if (dir_items.items[cursor_idx]) |e| { main.state = .delete; const next = @@ -924,19 +924,18 @@ pub fn keyInput(ch: i32) void { // Display settings 'c' => main.config.show_items = !main.config.show_items, 'm' => if (main.config.extended) { main.config.show_mtime = !main.config.show_mtime; }, - 'g' => main.config.show_graph = switch (main.config.show_graph) { - .off => .graph, - .graph => .percent, - .percent => .both, - .both => .off, + 'g' => { + if (!main.config.show_graph and !main.config.show_percent) { main.config.show_graph = true; main.config.show_percent = false; } + else if ( main.config.show_graph and !main.config.show_percent) { main.config.show_graph = false; main.config.show_percent = true; } + else if (!main.config.show_graph and main.config.show_percent) { main.config.show_graph = true; main.config.show_percent = true; } + else if ( main.config.show_graph and main.config.show_percent) { main.config.show_graph = false; main.config.show_percent = false; } }, - // TODO: This key binding is not final! I'd rather add a menu selection thing for advanced settings rather than risk running out of more keys. 'u' => main.config.show_shared = switch (main.config.show_shared) { .off => .shared, .shared => .unique, .unique => .off, }, - else => _ = keyInputSelection(ch, &cursor_idx, dir_items.items.len, saturateSub(ui.rows, 3)), + else => _ = keyInputSelection(ch, &cursor_idx, dir_items.items.len, ui.rows -| 3), } } diff --git a/src/delete.zig b/src/delete.zig index b9e6851..c59aca8 100644 --- a/src/delete.zig +++ b/src/delete.zig @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2021 Yoran Heling <projects@yorhel.nl> +// SPDX-FileCopyrightText: 2021-2023 Yoran Heling <projects@yorhel.nl> // SPDX-License-Identifier: MIT const std = @import("std"); @@ -6,7 +6,7 @@ const main = @import("main.zig"); const model = @import("model.zig"); const ui = @import("ui.zig"); const browser = @import("browser.zig"); -usingnamespace @import("util.zig"); +const util = @import("util.zig"); var parent: *model.Dir = undefined; var entry: *model.Entry = undefined; @@ -45,8 +45,7 @@ fn deleteItem(dir: std.fs.Dir, path: [:0]const u8, ptr: *align(1) ?*model.Entry) return true; if (entry.dir()) |d| { - var fd = dir.openDirZ(path, .{ .access_sub_paths = true, .iterate = false }) - catch |e| return err(e); + var fd = dir.openDirZ(path, .{.no_follow = true}, false) catch |e| return err(e); var it = &d.sub; parent = d; defer parent = parent.parent.?; @@ -89,7 +88,7 @@ pub fn delete() ?*model.Entry { path.append('/') catch unreachable; path.appendSlice(entry.name()) catch unreachable; - _ = deleteItem(std.fs.cwd(), arrayListBufZ(&path), it); + _ = deleteItem(std.fs.cwd(), util.arrayListBufZ(&path), it); model.inodes.addAllStats(); return if (it.* == e) e else next_sel; } @@ -101,7 +100,7 @@ fn drawConfirm() void { ui.addstr("Are you sure you want to delete \""); ui.addstr(ui.shorten(ui.toUtf8(entry.name()), 21)); ui.addch('"'); - if (entry.etype != .dir) + if (entry.pack.etype != .dir) ui.addch('?') else { box.move(2, 18); @@ -132,7 +131,7 @@ fn drawProgress() void { const box = ui.Box.create(6, 60, "Deleting..."); box.move(2, 2); - ui.addstr(ui.shorten(ui.toUtf8(arrayListBufZ(&path)), 56)); + ui.addstr(ui.shorten(ui.toUtf8(util.arrayListBufZ(&path)), 56)); box.move(4, 41); ui.addstr("Press "); ui.style(.key); @@ -151,7 +150,7 @@ fn drawErr() void { const box = ui.Box.create(6, 60, "Error"); box.move(1, 2); ui.addstr("Error deleting "); - ui.addstr(ui.shorten(ui.toUtf8(arrayListBufZ(&path)), 41)); + ui.addstr(ui.shorten(ui.toUtf8(util.arrayListBufZ(&path)), 41)); box.move(2, 4); ui.addstr(ui.errorString(error_code)); diff --git a/src/exclude.zig b/src/exclude.zig new file mode 100644 index 0000000..ca37d11 --- /dev/null +++ b/src/exclude.zig @@ -0,0 +1,322 @@ +// SPDX-FileCopyrightText: 2021-2023 Yoran Heling <projects@yorhel.nl> +// SPDX-License-Identifier: MIT + +const std = @import("std"); +const main = @import("main.zig"); +const c = @cImport(@cInclude("fnmatch.h")); + +// Reference: +// https://manned.org/glob.7 +// https://manned.org/man.b4c7391e/rsync#head17 +// https://manned.org/man.401d6ade/arch/gitignore#head4 +// Patterns: +// Single component (none of these patterns match a '/'): +// * -> match any character sequence +// ? -> match single character +// [abc] -> match a single character in the given list +// [a-c] -> match a single character in the given range +// [!a-c] -> match a single character not in the given range +// # (these are currently still handled by calling libc fnmatch()) +// Anchored patterns: +// /pattern +// /dir/pattern +// /dir/subdir/pattern +// # In both rsync and gitignore, anchored patterns are relative to the +// # directory under consideration. In ncdu they are instead anchored to +// # the filesystem root (i.e. matched against the absolute path). +// Non-anchored patterns: +// somefile +// subdir/foo +// sub*/bar +// # In .gitignore, non-anchored patterns with a slash are implicitely anchored, +// # in rsync they can match anywhere in a path. We follow rsync here. +// Dir patterns (trailing '/' matches only dirs): +// /pattern/ +// somedir/ +// subdir/pattern/ +// +// BREAKING CHANGE: +// ncdu < 2.2 single-component matches may cross directory boundary, e.g. +// 'a*b' matches 'a/b'. This is an old bug, the fix breaks compatibility with +// old exlude patterns. + +const Pattern = struct { + isdir: bool = undefined, + isliteral: bool = undefined, + pattern: [:0]const u8, + sub: ?*const Pattern = undefined, + + fn isLiteral(str: []const u8) bool { + for (str) |chr| switch (chr) { + '[', '*', '?', '\\' => return false, + else => {}, + }; + return true; + } + + fn parse(pat_: []const u8) *const Pattern { + var pat = std.mem.trimLeft(u8, pat_, "/"); + const top = main.allocator.create(Pattern) catch unreachable; + var tail = top; + tail.sub = null; + while (std.mem.indexOfScalar(u8, pat, '/')) |idx| { + tail.pattern = main.allocator.dupeZ(u8, pat[0..idx]) catch unreachable; + tail.isdir = true; + tail.isliteral = isLiteral(tail.pattern); + pat = pat[idx+1..]; + if (std.mem.allEqual(u8, pat, '/')) return top; + + const next = main.allocator.create(Pattern) catch unreachable; + tail.sub = next; + tail = next; + tail.sub = null; + } + tail.pattern = main.allocator.dupeZ(u8, pat) catch unreachable; + tail.isdir = false; + tail.isliteral = isLiteral(tail.pattern); + return top; + } +}; + +test "parse" { + const t1 = Pattern.parse(""); + try std.testing.expectEqualStrings(t1.pattern, ""); + try std.testing.expectEqual(t1.isdir, false); + try std.testing.expectEqual(t1.isliteral, true); + try std.testing.expectEqual(t1.sub, null); + + const t2 = Pattern.parse("//a//"); + try std.testing.expectEqualStrings(t2.pattern, "a"); + try std.testing.expectEqual(t2.isdir, true); + try std.testing.expectEqual(t2.isliteral, true); + try std.testing.expectEqual(t2.sub, null); + + const t3 = Pattern.parse("foo*/bar.zig"); + try std.testing.expectEqualStrings(t3.pattern, "foo*"); + try std.testing.expectEqual(t3.isdir, true); + try std.testing.expectEqual(t3.isliteral, false); + try std.testing.expectEqualStrings(t3.sub.?.pattern, "bar.zig"); + try std.testing.expectEqual(t3.sub.?.isdir, false); + try std.testing.expectEqual(t3.sub.?.isliteral, true); + try std.testing.expectEqual(t3.sub.?.sub, null); + + const t4 = Pattern.parse("/?/sub/dir/"); + try std.testing.expectEqualStrings(t4.pattern, "?"); + try std.testing.expectEqual(t4.isdir, true); + try std.testing.expectEqual(t4.isliteral, false); + try std.testing.expectEqualStrings(t4.sub.?.pattern, "sub"); + try std.testing.expectEqual(t4.sub.?.isdir, true); + try std.testing.expectEqual(t4.sub.?.isliteral, true); + try std.testing.expectEqualStrings(t4.sub.?.sub.?.pattern, "dir"); + try std.testing.expectEqual(t4.sub.?.sub.?.isdir, true); + try std.testing.expectEqual(t4.sub.?.sub.?.isliteral, true); + try std.testing.expectEqual(t4.sub.?.sub.?.sub, null); +} + + +// List of patterns to be matched at one particular level. +// There are 2 different types of lists: those where all patterns have a +// sub-pointer (where the pattern only matches directories at this level, and +// the match result is only used to construct the PatternList of the +// subdirectory) and patterns without a sub-pointer (where the match result +// determines whether the file/dir at this level should be included or not). +fn PatternList(comptime withsub: bool) type { + return struct { + literals: std.HashMapUnmanaged(*const Pattern, Val, Ctx, 80) = .{}, + wild: std.ArrayListUnmanaged(*const Pattern) = .{}, + + // Not a fan of the map-of-arrays approach in the 'withsub' case, it + // has a lot of extra allocations. Linking the Patterns together in a + // list would be nicer, but that involves mutable Patterns, which in + // turn prevents multithreaded scanning. An alternative would be a + // sorted array + binary search, but that slows down lookups. Perhaps a + // custom hashmap with support for duplicate keys? + const Val = if (withsub) std.ArrayListUnmanaged(*const Pattern) else void; + + const Ctx = struct { + pub fn hash(_: Ctx, p: *const Pattern) u64 { + return std.hash.Wyhash.hash(0, p.pattern); + } + pub fn eql(_: Ctx, a: *const Pattern, b: *const Pattern) bool { + return std.mem.eql(u8, a.pattern, b.pattern); + } + }; + + const Self = @This(); + + fn append(self: *Self, pat: *const Pattern) void { + std.debug.assert((pat.sub != null) == withsub); + if (pat.isliteral) { + const e = self.literals.getOrPut(main.allocator, pat) catch unreachable; + if (!e.found_existing) { + e.key_ptr.* = pat; + e.value_ptr.* = if (withsub) .{} else {}; + } + if (!withsub and !pat.isdir and e.key_ptr.*.isdir) e.key_ptr.* = pat; + if (withsub) { + if (pat.sub) |s| e.value_ptr.*.append(main.allocator, s) catch unreachable; + } + + } else self.wild.append(main.allocator, pat) catch unreachable; + } + + fn match(self: *const Self, name: [:0]const u8) ?bool { + var ret: ?bool = null; + if (self.literals.getKey(&.{ .pattern = name })) |p| ret = p.isdir; + for (self.wild.items) |p| { + if (ret == false) return ret; + if (c.fnmatch(p.pattern.ptr, name.ptr, 0) == 0) ret = p.isdir; + } + return ret; + } + + fn enter(self: *const Self, out: *Patterns, name: [:0]const u8) void { + if (self.literals.get(&.{ .pattern = name })) |lst| for (lst.items) |sub| out.append(sub); + for (self.wild.items) |p| if (c.fnmatch(p.pattern.ptr, name.ptr, 0) == 0) out.append(p.sub.?); + } + + fn deinit(self: *Self) void { + if (withsub) { + var it = self.literals.valueIterator(); + while (it.next()) |e| e.deinit(main.allocator); + } + self.literals.deinit(main.allocator); + self.wild.deinit(main.allocator); + self.* = undefined; + } + }; +} + +// List of all patterns that should be matched at one level. +pub const Patterns = struct { + nonsub: PatternList(false) = .{}, + sub: PatternList(true) = .{}, + isroot: bool = false, + + fn append(self: *Patterns, pat: *const Pattern) void { + if (pat.sub == null) self.nonsub.append(pat) + else self.sub.append(pat); + } + + // Matches patterns in this level plus unanchored patterns. + // Returns null if nothing matches, otherwise whether the given item should + // only be exluced if it's a directory. + // (Should not be called on root_unanchored) + pub fn match(self: *const Patterns, name: [:0]const u8) ?bool { + const a = self.nonsub.match(name); + if (a == false) return false; + const b = root_unanchored.nonsub.match(name); + if (b == false) return false; + return a orelse b; + } + + // Construct the list of patterns for a subdirectory. + pub fn enter(self: *const Patterns, name: [:0]const u8) Patterns { + var ret = Patterns{}; + self.sub.enter(&ret, name); + root_unanchored.sub.enter(&ret, name); + return ret; + } + + pub fn deinit(self: *Patterns) void { + // getPatterns() result should be deinit()ed, except when it returns the root, + // let's simplify that and simply don't deinit root. + if (self.isroot) return; + self.nonsub.deinit(); + self.sub.deinit(); + self.* = undefined; + } +}; + +// Unanchored patterns that should be checked at every level +var root_unanchored: Patterns = .{}; + +// Patterns anchored at the root +var root: Patterns = .{ .isroot = true }; + +pub fn addPattern(pattern: []const u8) void { + if (pattern.len == 0) return; + const p = Pattern.parse(pattern); + if (pattern[0] == '/') root.append(p) + else root_unanchored.append(p); +} + +// Get the patterns for the given (absolute) path, assuming the given path +// itself hasn't been excluded. This function is slow, directory walking code +// should use Patterns.enter() instead. +pub fn getPatterns(path_: []const u8) Patterns { + var path = std.mem.trim(u8, path_, "/"); + if (path.len == 0) return root; + var pat = root; + defer pat.deinit(); + while (std.mem.indexOfScalar(u8, path, '/')) |idx| { + const name = main.allocator.dupeZ(u8, path[0..idx]) catch unreachable; + defer main.allocator.free(name); + path = path[idx+1..]; + + const sub = pat.enter(name); + pat.deinit(); + pat = sub; + } + + const name = main.allocator.dupeZ(u8, path) catch unreachable; + defer main.allocator.free(name); + return pat.enter(name); +} + + +fn testfoo(p: *const Patterns) !void { + try std.testing.expectEqual(p.match("root"), null); + try std.testing.expectEqual(p.match("bar"), false); + try std.testing.expectEqual(p.match("qoo"), false); + try std.testing.expectEqual(p.match("xyz"), false); + try std.testing.expectEqual(p.match("okay"), null); + try std.testing.expectEqual(p.match("somefile"), false); + var s = p.enter("okay"); + try std.testing.expectEqual(s.match("bar"), null); + try std.testing.expectEqual(s.match("xyz"), null); + try std.testing.expectEqual(s.match("notokay"), false); + s.deinit(); +} + +test "Matching" { + addPattern("/foo/bar"); + addPattern("/foo/qoo/"); + addPattern("/foo/qoo"); + addPattern("/foo/qoo/"); + addPattern("/f??/xyz"); + addPattern("/f??/xyz/"); + addPattern("/*o/somefile"); + addPattern("/a??/okay"); + addPattern("/roo?"); + addPattern("/root/"); + addPattern("excluded"); + addPattern("somefile/"); + addPattern("o*y/not[o]kay"); + + var a0 = getPatterns("/"); + try std.testing.expectEqual(a0.match("a"), null); + try std.testing.expectEqual(a0.match("excluded"), false); + try std.testing.expectEqual(a0.match("somefile"), true); + try std.testing.expectEqual(a0.match("root"), false); + var a1 = a0.enter("foo"); + a0.deinit(); + try testfoo(&a1); + a1.deinit(); + + var b0 = getPatterns("/somedir/somewhere"); + try std.testing.expectEqual(b0.match("a"), null); + try std.testing.expectEqual(b0.match("excluded"), false); + try std.testing.expectEqual(b0.match("root"), null); + try std.testing.expectEqual(b0.match("okay"), null); + var b1 = b0.enter("okay"); + b0.deinit(); + try std.testing.expectEqual(b1.match("excluded"), false); + try std.testing.expectEqual(b1.match("okay"), null); + try std.testing.expectEqual(b1.match("notokay"), false); + b1.deinit(); + + var c0 = getPatterns("/foo/"); + try testfoo(&c0); + c0.deinit(); +} diff --git a/src/main.zig b/src/main.zig index 3e14f7e..cd3e6d8 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,7 +1,7 @@ -// SPDX-FileCopyrightText: 2021 Yoran Heling <projects@yorhel.nl> +// SPDX-FileCopyrightText: 2021-2023 Yoran Heling <projects@yorhel.nl> // SPDX-License-Identifier: MIT -pub const program_version = "2.0-beta2"; +pub const program_version = "2.3"; const std = @import("std"); const model = @import("model.zig"); @@ -9,39 +9,49 @@ const scan = @import("scan.zig"); const ui = @import("ui.zig"); const browser = @import("browser.zig"); const delete = @import("delete.zig"); +const util = @import("util.zig"); +const exclude = @import("exclude.zig"); const c = @cImport(@cInclude("locale.h")); +test "imports" { + _ = model; + _ = scan; + _ = ui; + _ = browser; + _ = delete; + _ = util; + _ = exclude; +} + // "Custom" allocator that wraps the libc allocator and calls ui.oom() on error. // This allocator never returns an error, it either succeeds or causes ncdu to quit. // (Which means you'll find a lot of "catch unreachable" sprinkled through the code, // they look scarier than they are) -fn wrapAlloc(alloc: *std.mem.Allocator, len: usize, alignment: u29, len_align: u29, return_address: usize) error{OutOfMemory}![]u8 { +fn wrapAlloc(_: *anyopaque, len: usize, ptr_alignment: u8, return_address: usize) ?[*]u8 { while (true) { - if (std.heap.c_allocator.allocFn(alloc, len, alignment, len_align, return_address)) |r| + if (std.heap.c_allocator.vtable.alloc(undefined, len, ptr_alignment, return_address)) |r| return r - else |_| {} + else {} ui.oom(); } } -fn wrapResize(alloc: *std.mem.Allocator, buf: []u8, buf_align: u29, new_len: usize, len_align: u29, return_address: usize) std.mem.Allocator.Error!usize { - // AFAIK, all uses of resizeFn to grow an allocation will fall back to allocFn on failure. - return std.heap.c_allocator.resizeFn(alloc, buf, buf_align, new_len, len_align, return_address); -} - -var allocator_state = std.mem.Allocator{ - .allocFn = wrapAlloc, - .resizeFn = wrapResize, +pub const allocator = std.mem.Allocator{ + .ptr = undefined, + .vtable = &.{ + .alloc = wrapAlloc, + // AFAIK, all uses of resize() to grow an allocation will fall back to alloc() on failure. + .resize = std.heap.c_allocator.vtable.resize, + .free = std.heap.c_allocator.vtable.free, + }, }; -pub const allocator = &allocator_state; -//var general_purpose_allocator = std.heap.GeneralPurposeAllocator(.{}){}; -//pub const allocator = &general_purpose_allocator.allocator; + pub const config = struct { pub const SortCol = enum { name, blocks, size, items, mtime }; pub const SortOrder = enum { asc, desc }; - pub var same_fs: bool = true; + pub var same_fs: bool = false; pub var extended: bool = false; pub var follow_symlinks: bool = false; pub var exclude_caches: bool = false; @@ -49,10 +59,10 @@ pub const config = struct { pub var exclude_patterns: std.ArrayList([:0]const u8) = std.ArrayList([:0]const u8).init(allocator); pub var update_delay: u64 = 100*std.time.ns_per_ms; - pub var scan_ui: enum { none, line, full } = .full; + pub var scan_ui: ?enum { none, line, full } = null; pub var si: bool = false; pub var nc_tty: bool = false; - pub var ui_color: enum { off, dark } = .off; + pub var ui_color: enum { off, dark, darkbg } = .off; pub var thousands_sep: []const u8 = ","; pub var show_hidden: bool = true; @@ -60,14 +70,18 @@ pub const config = struct { pub var show_shared: enum { off, shared, unique } = .shared; pub var show_items: bool = false; pub var show_mtime: bool = false; - pub var show_graph: enum { off, graph, percent, both } = .graph; + pub var show_graph: bool = true; + pub var show_percent: bool = false; + pub var graph_style: enum { hash, half, eighth } = .hash; pub var sort_col: SortCol = .blocks; pub var sort_order: SortOrder = .desc; pub var sort_dirsfirst: bool = false; + pub var sort_natural: bool = true; - pub var read_only: bool = false; pub var imported: bool = false; - pub var can_shell: bool = true; + pub var can_delete: ?bool = null; + pub var can_shell: ?bool = null; + pub var can_refresh: ?bool = null; pub var confirm_quit: bool = false; pub var confirm_delete: bool = true; pub var ignore_delete_errors: bool = false; @@ -76,106 +90,256 @@ pub const config = struct { pub var state: enum { scan, browse, refresh, shell, delete } = .scan; // Simple generic argument parser, supports getopt_long() style arguments. -// T can be any type that has a 'fn next(T) ?[:0]const u8' method, e.g.: -// var args = Args(std.process.ArgIteratorPosix).init(std.process.ArgIteratorPosix.init()); -fn Args(T: anytype) type { - return struct { - it: T, - short: ?[:0]const u8 = null, // Remainder after a short option, e.g. -x<stuff> (which may be either more short options or an argument) - last: ?[]const u8 = null, - last_arg: ?[:0]const u8 = null, // In the case of --option=<arg> - shortbuf: [2]u8 = undefined, - argsep: bool = false, - - const Self = @This(); - const Option = struct { - opt: bool, - val: []const u8, - - fn is(self: @This(), cmp: []const u8) bool { - return self.opt and std.mem.eql(u8, self.val, cmp); - } - }; - - fn init(it: T) Self { - return Self{ .it = it }; +const Args = struct { + lst: []const [:0]const u8, + short: ?[:0]const u8 = null, // Remainder after a short option, e.g. -x<stuff> (which may be either more short options or an argument) + last: ?[]const u8 = null, + last_arg: ?[:0]const u8 = null, // In the case of --option=<arg> + shortbuf: [2]u8 = undefined, + argsep: bool = false, + + const Self = @This(); + const Option = struct { + opt: bool, + val: []const u8, + + fn is(self: @This(), cmp: []const u8) bool { + return self.opt and std.mem.eql(u8, self.val, cmp); } + }; - fn shortopt(self: *Self, s: [:0]const u8) Option { - self.shortbuf[0] = '-'; - self.shortbuf[1] = s[0]; - self.short = if (s.len > 1) s[1.. :0] else null; - self.last = &self.shortbuf; - return .{ .opt = true, .val = &self.shortbuf }; - } + fn init(lst: []const [:0]const u8) Self { + return Self{ .lst = lst }; + } - /// Return the next option or positional argument. - /// 'opt' indicates whether it's an option or positional argument, - /// 'val' will be either -x, --something or the argument. - pub fn next(self: *Self) ?Option { - if (self.last_arg != null) ui.die("Option '{s}' does not expect an argument.\n", .{ self.last.? }); - if (self.short) |s| return self.shortopt(s); - const val = self.it.next() orelse return null; - if (self.argsep or val.len == 0 or val[0] != '-') return Option{ .opt = false, .val = val }; - if (val.len == 1) ui.die("Invalid option '-'.\n", .{}); - if (val.len == 2 and val[1] == '-') { - self.argsep = true; - return self.next(); - } - if (val[1] == '-') { - if (std.mem.indexOfScalar(u8, val, '=')) |sep| { - if (sep == 2) ui.die("Invalid option '{s}'.\n", .{val}); - self.last_arg = val[sep+1.. :0]; - self.last = val[0..sep]; - return Option{ .opt = true, .val = self.last.? }; - } - self.last = val; - return Option{ .opt = true, .val = val }; + fn pop(self: *Self) ?[:0]const u8 { + if (self.lst.len == 0) return null; + defer self.lst = self.lst[1..]; + return self.lst[0]; + } + + fn shortopt(self: *Self, s: [:0]const u8) Option { + self.shortbuf[0] = '-'; + self.shortbuf[1] = s[0]; + self.short = if (s.len > 1) s[1.. :0] else null; + self.last = &self.shortbuf; + return .{ .opt = true, .val = &self.shortbuf }; + } + + /// Return the next option or positional argument. + /// 'opt' indicates whether it's an option or positional argument, + /// 'val' will be either -x, --something or the argument. + pub fn next(self: *Self) ?Option { + if (self.last_arg != null) ui.die("Option '{s}' does not expect an argument.\n", .{ self.last.? }); + if (self.short) |s| return self.shortopt(s); + const val = self.pop() orelse return null; + if (self.argsep or val.len == 0 or val[0] != '-') return Option{ .opt = false, .val = val }; + if (val.len == 1) ui.die("Invalid option '-'.\n", .{}); + if (val.len == 2 and val[1] == '-') { + self.argsep = true; + return self.next(); + } + if (val[1] == '-') { + if (std.mem.indexOfScalar(u8, val, '=')) |sep| { + if (sep == 2) ui.die("Invalid option '{s}'.\n", .{val}); + self.last_arg = val[sep+1.. :0]; + self.last = val[0..sep]; + return Option{ .opt = true, .val = self.last.? }; } - return self.shortopt(val[1..:0]); + self.last = val; + return Option{ .opt = true, .val = val }; } + return self.shortopt(val[1..:0]); + } - /// Returns the argument given to the last returned option. Dies with an error if no argument is provided. - pub fn arg(self: *Self) [:0]const u8 { - if (self.short) |a| { - defer self.short = null; - return a; - } - if (self.last_arg) |a| { - defer self.last_arg = null; - return a; - } - if (self.it.next()) |o| return o; - ui.die("Option '{s}' requires an argument.\n", .{ self.last.? }); + /// Returns the argument given to the last returned option. Dies with an error if no argument is provided. + pub fn arg(self: *Self) [:0]const u8 { + if (self.short) |a| { + defer self.short = null; + return a; } + if (self.last_arg) |a| { + defer self.last_arg = null; + return a; + } + if (self.pop()) |o| return o; + ui.die("Option '{s}' requires an argument.\n", .{ self.last.? }); + } +}; + +fn argConfig(args: *Args, opt: Args.Option) bool { + if (opt.is("-q") or opt.is("--slow-ui-updates")) config.update_delay = 2*std.time.ns_per_s + else if (opt.is("--fast-ui-updates")) config.update_delay = 100*std.time.ns_per_ms + else if (opt.is("-x") or opt.is("--one-file-system")) config.same_fs = true + else if (opt.is("--cross-file-system")) config.same_fs = false + else if (opt.is("-e") or opt.is("--extended")) config.extended = true + else if (opt.is("--no-extended")) config.extended = false + else if (opt.is("-r") and !(config.can_delete orelse true)) config.can_shell = false + else if (opt.is("-r")) config.can_delete = false + else if (opt.is("--enable-shell")) config.can_shell = true + else if (opt.is("--disable-shell")) config.can_shell = false + else if (opt.is("--enable-delete")) config.can_delete = true + else if (opt.is("--disable-delete")) config.can_delete = false + else if (opt.is("--enable-refresh")) config.can_refresh = true + else if (opt.is("--disable-refresh")) config.can_refresh = false + else if (opt.is("--show-hidden")) config.show_hidden = true + else if (opt.is("--hide-hidden")) config.show_hidden = false + else if (opt.is("--show-itemcount")) config.show_items = true + else if (opt.is("--hide-itemcount")) config.show_items = false + else if (opt.is("--show-mtime")) config.show_mtime = true + else if (opt.is("--hide-mtime")) config.show_mtime = false + else if (opt.is("--show-graph")) config.show_graph = true + else if (opt.is("--hide-graph")) config.show_graph = false + else if (opt.is("--show-percent")) config.show_percent = true + else if (opt.is("--hide-percent")) config.show_percent = false + else if (opt.is("--group-directories-first")) config.sort_dirsfirst = true + else if (opt.is("--no-group-directories-first")) config.sort_dirsfirst = false + else if (opt.is("--enable-natsort")) config.sort_natural = true + else if (opt.is("--disable-natsort")) config.sort_natural = false + else if (opt.is("--graph-style")) { + const val = args.arg(); + if (std.mem.eql(u8, val, "hash")) config.graph_style = .hash + else if (std.mem.eql(u8, val, "half-block")) config.graph_style = .half + else if (std.mem.eql(u8, val, "eighth-block") or std.mem.eql(u8, val, "eigth-block")) config.graph_style = .eighth + else ui.die("Unknown --graph-style option: {s}.\n", .{val}); + } else if (opt.is("--sort")) { + var val: []const u8 = args.arg(); + var ord: ?config.SortOrder = null; + if (std.mem.endsWith(u8, val, "-asc")) { + val = val[0..val.len-4]; + ord = .asc; + } else if (std.mem.endsWith(u8, val, "-desc")) { + val = val[0..val.len-5]; + ord = .desc; + } + if (std.mem.eql(u8, val, "name")) { + config.sort_col = .name; + config.sort_order = ord orelse .asc; + } else if (std.mem.eql(u8, val, "disk-usage")) { + config.sort_col = .blocks; + config.sort_order = ord orelse .desc; + } else if (std.mem.eql(u8, val, "apparent-size")) { + config.sort_col = .size; + config.sort_order = ord orelse .desc; + } else if (std.mem.eql(u8, val, "itemcount")) { + config.sort_col = .items; + config.sort_order = ord orelse .desc; + } else if (std.mem.eql(u8, val, "mtime")) { + config.sort_col = .mtime; + config.sort_order = ord orelse .asc; + } else ui.die("Unknown --sort option: {s}.\n", .{val}); + } else if (opt.is("--shared-column")) { + const val = args.arg(); + if (std.mem.eql(u8, val, "off")) config.show_shared = .off + else if (std.mem.eql(u8, val, "shared")) config.show_shared = .shared + else if (std.mem.eql(u8, val, "unique")) config.show_shared = .unique + else ui.die("Unknown --shared-column option: {s}.\n", .{val}); + } else if (opt.is("--apparent-size")) config.show_blocks = false + else if (opt.is("--disk-usage")) config.show_blocks = true + else if (opt.is("-0")) config.scan_ui = .none + else if (opt.is("-1")) config.scan_ui = .line + else if (opt.is("-2")) config.scan_ui = .full + else if (opt.is("--si")) config.si = true + else if (opt.is("--no-si")) config.si = false + else if (opt.is("-L") or opt.is("--follow-symlinks")) config.follow_symlinks = true + else if (opt.is("--no-follow-symlinks")) config.follow_symlinks = false + else if (opt.is("--exclude")) exclude.addPattern(args.arg()) + else if (opt.is("-X") or opt.is("--exclude-from")) { + const arg = args.arg(); + readExcludeFile(arg) catch |e| ui.die("Error reading excludes from {s}: {s}.\n", .{ arg, ui.errorString(e) }); + } else if (opt.is("--exclude-caches")) config.exclude_caches = true + else if (opt.is("--include-caches")) config.exclude_caches = false + else if (opt.is("--exclude-kernfs")) config.exclude_kernfs = true + else if (opt.is("--include-kernfs")) config.exclude_kernfs = false + else if (opt.is("--confirm-quit")) config.confirm_quit = true + else if (opt.is("--no-confirm-quit")) config.confirm_quit = false + else if (opt.is("--confirm-delete")) config.confirm_delete = true + else if (opt.is("--no-confirm-delete")) config.confirm_delete = false + else if (opt.is("--color")) { + const val = args.arg(); + if (std.mem.eql(u8, val, "off")) config.ui_color = .off + else if (std.mem.eql(u8, val, "dark")) config.ui_color = .dark + else if (std.mem.eql(u8, val, "dark-bg")) config.ui_color = .darkbg + else ui.die("Unknown --color option: {s}.\n", .{val}); + } else return false; + return true; +} + +fn tryReadArgsFile(path: [:0]const u8) void { + var f = std.fs.cwd().openFileZ(path, .{}) catch |e| switch (e) { + error.FileNotFound => return, + error.NotDir => return, + else => ui.die("Error opening {s}: {s}\nRun with --ignore-config to skip reading config files.\n", .{ path, ui.errorString(e) }), }; + defer f.close(); + + var arglist = std.ArrayList([:0]const u8).init(allocator); + + var rd_ = std.io.bufferedReader(f.reader()); + const rd = rd_.reader(); + + var line_buf: [4096]u8 = undefined; + var line_fbs = std.io.fixedBufferStream(&line_buf); + const line_writer = line_fbs.writer(); + + while (true) : (line_fbs.reset()) { + rd.streamUntilDelimiter(line_writer, '\n', line_buf.len) catch |err| switch (err) { + error.EndOfStream => if (line_fbs.getPos() catch unreachable == 0) break, + else => |e| ui.die("Error reading from {s}: {s}\nRun with --ignore-config to skip reading config files.\n", .{ path, ui.errorString(e) }), + }; + const line_ = line_fbs.getWritten(); + + var line = std.mem.trim(u8, line_, &std.ascii.whitespace); + if (line.len == 0 or line[0] == '#') continue; + if (std.mem.indexOfAny(u8, line, " \t=")) |i| { + arglist.append(allocator.dupeZ(u8, line[0..i]) catch unreachable) catch unreachable; + line = std.mem.trimLeft(u8, line[i+1..], &std.ascii.whitespace); + } + arglist.append(allocator.dupeZ(u8, line) catch unreachable) catch unreachable; + } + + var args = Args.init(arglist.items); + while (args.next()) |opt| { + if (!argConfig(&args, opt)) + ui.die("Unrecognized option in config file '{s}': {s}.\nRun with --ignore-config to skip reading config files.\n", .{path, opt.val}); + } + for (arglist.items) |i| allocator.free(i); + arglist.deinit(); } fn version() noreturn { - std.io.getStdOut().writer().writeAll("ncdu " ++ program_version ++ "\n") catch {}; + const stdout = std.io.getStdOut(); + stdout.writeAll("ncdu " ++ program_version ++ "\n") catch {}; std.process.exit(0); } fn help() noreturn { - std.io.getStdOut().writer().writeAll( - "ncdu <options> <directory>\n\n" - ++ " -h,--help This help message\n" - ++ " -q Quiet mode, refresh interval 2 seconds\n" - ++ " -v,-V,--version Print version\n" - ++ " -x Same filesystem\n" - ++ " -e Enable extended information\n" - ++ " -r Read only\n" - ++ " -o FILE Export scanned directory to FILE\n" - ++ " -f FILE Import scanned directory from FILE\n" - ++ " -0,-1,-2 UI to use when scanning (0=none,2=full ncurses)\n" - ++ " --si Use base 10 (SI) prefixes instead of base 2\n" - ++ " --exclude PATTERN Exclude files that match PATTERN\n" - ++ " -X, --exclude-from FILE Exclude files that match any pattern in FILE\n" - ++ " -L, --follow-symlinks Follow symbolic links (excluding directories)\n" - ++ " --exclude-caches Exclude directories containing CACHEDIR.TAG\n" - ++ " --exclude-kernfs Exclude Linux pseudo filesystems (procfs,sysfs,cgroup,...)\n" - ++ " --confirm-quit Confirm quitting ncdu\n" - ++ " --color SCHEME Set color scheme (off/dark)\n" + const stdout = std.io.getStdOut(); + stdout.writeAll( + \\ncdu <options> <directory> + \\ + \\Options: + \\ -h,--help This help message + \\ -q Quiet mode, refresh interval 2 seconds + \\ -v,-V,--version Print version + \\ -x Same filesystem + \\ -e Enable extended information + \\ -r Read only + \\ -o FILE Export scanned directory to FILE + \\ -f FILE Import scanned directory from FILE + \\ -0,-1,-2 UI to use when scanning (0=none,2=full ncurses) + \\ --si Use base 10 (SI) prefixes instead of base 2 + \\ --exclude PATTERN Exclude files that match PATTERN + \\ -X, --exclude-from FILE Exclude files that match any pattern in FILE + \\ -L, --follow-symlinks Follow symbolic links (excluding directories) + \\ --exclude-caches Exclude directories containing CACHEDIR.TAG + \\ --exclude-kernfs Exclude Linux pseudo filesystems (procfs,sysfs,cgroup,...) + \\ --confirm-quit Confirm quitting ncdu + \\ --color SCHEME Set color scheme (off/dark/dark-bg) + \\ --ignore-config Don't load config files + \\ + \\Refer to `man ncdu` for the full list of options. + \\ ) catch {}; std.process.exit(0); } @@ -194,7 +358,7 @@ fn spawnShell() void { // NCDU_LEVEL can only count to 9, keeps the implementation simple. if (env.get("NCDU_LEVEL")) |l| env.put("NCDU_LEVEL", if (l.len == 0) "1" else switch (l[0]) { - '0'...'8' => @as([]const u8, &.{l[0]+1}), + '0'...'8' => |d| &[1] u8{d+1}, '9' => "9", else => "1" }) catch unreachable @@ -202,18 +366,19 @@ fn spawnShell() void { env.put("NCDU_LEVEL", "1") catch unreachable; const shell = std.os.getenvZ("NCDU_SHELL") orelse std.os.getenvZ("SHELL") orelse "/bin/sh"; - var child = std.ChildProcess.init(&.{shell}, allocator) catch unreachable; - defer child.deinit(); + var child = std.process.Child.init(&.{shell}, allocator); child.cwd = path.items; child.env_map = &env; + const stdin = std.io.getStdIn(); + const stderr = std.io.getStdErr(); const term = child.spawnAndWait() catch |e| blk: { - _ = std.io.getStdErr().writer().print( + stderr.writer().print( "Error spawning shell: {s}\n\nPress enter to continue.\n", .{ ui.errorString(e) } ) catch {}; - _ = std.io.getStdIn().reader().skipUntilDelimiterOrEof('\n') catch unreachable; - break :blk std.ChildProcess.Term{ .Exited = 0 }; + stdin.reader().skipUntilDelimiterOrEof('\n') catch unreachable; + break :blk std.process.Child.Term{ .Exited = 0 }; }; if (term != .Exited) { const n = switch (term) { @@ -228,24 +393,33 @@ fn spawnShell() void { .Stopped => |v| v, .Unknown => |v| v, }; - _ = std.io.getStdErr().writer().print( + stderr.writer().print( "Shell returned with {s} code {}.\n\nPress enter to continue.\n", .{ n, v } ) catch {}; - _ = std.io.getStdIn().reader().skipUntilDelimiterOrEof('\n') catch unreachable; + stdin.reader().skipUntilDelimiterOrEof('\n') catch unreachable; } } -fn readExcludeFile(path: []const u8) !void { - const f = try std.fs.cwd().openFile(path, .{}); +fn readExcludeFile(path: [:0]const u8) !void { + const f = try std.fs.cwd().openFileZ(path, .{}); defer f.close(); - var rd = std.io.bufferedReader(f.reader()).reader(); - var buf = std.ArrayList(u8).init(allocator); - while (true) { - rd.readUntilDelimiterArrayList(&buf, '\n', 4096) - catch |e| if (e != error.EndOfStream) return e else if (buf.items.len == 0) break; - if (buf.items.len > 0) - config.exclude_patterns.append(buf.toOwnedSliceSentinel(0) catch unreachable) catch unreachable; + + var rd_ = std.io.bufferedReader(f.reader()); + const rd = rd_.reader(); + + var line_buf: [4096]u8 = undefined; + var line_fbs = std.io.fixedBufferStream(&line_buf); + const line_writer = line_fbs.writer(); + + while (true) : (line_fbs.reset()) { + rd.streamUntilDelimiter(line_writer, '\n', line_buf.len) catch |err| switch (err) { + error.EndOfStream => if (line_fbs.getPos() catch unreachable == 0) break, + else => |e| return e, + }; + const line = line_fbs.getWritten(); + if (line.len > 0) + exclude.addPattern(line); } } @@ -254,66 +428,76 @@ pub fn main() void { _ = c.setlocale(c.LC_ALL, ""); if (c.localeconv()) |locale| { if (locale.*.thousands_sep) |sep| { - const span = std.mem.spanZ(sep); + const span = std.mem.sliceTo(sep, 0); if (span.len > 0) config.thousands_sep = span; } } + if (std.os.getenvZ("NO_COLOR") == null) config.ui_color = .darkbg; + + const loadConf = blk: { + var args = std.process.ArgIteratorPosix.init(); + while (args.next()) |a| + if (std.mem.eql(u8, a, "--ignore-config")) + break :blk false; + break :blk true; + }; + + if (loadConf) { + tryReadArgsFile("/etc/ncdu.conf"); + + if (std.os.getenvZ("XDG_CONFIG_HOME")) |p| { + const path = std.fs.path.joinZ(allocator, &.{p, "ncdu", "config"}) catch unreachable; + defer allocator.free(path); + tryReadArgsFile(path); + } else if (std.os.getenvZ("HOME")) |p| { + const path = std.fs.path.joinZ(allocator, &.{p, ".config", "ncdu", "config"}) catch unreachable; + defer allocator.free(path); + tryReadArgsFile(path); + } + } - var args = Args(std.process.ArgIteratorPosix).init(std.process.ArgIteratorPosix.init()); var scan_dir: ?[]const u8 = null; var import_file: ?[:0]const u8 = null; var export_file: ?[:0]const u8 = null; - var has_scan_ui = false; - _ = args.next(); // program name - while (args.next()) |opt| { - if (!opt.opt) { - // XXX: ncdu 1.x doesn't error, it just silently ignores all but the last argument. - if (scan_dir != null) ui.die("Multiple directories given, see ncdu -h for help.\n", .{}); - scan_dir = opt.val; - continue; + var quit_after_scan = false; + { + const arglist = std.process.argsAlloc(allocator) catch unreachable; + defer std.process.argsFree(allocator, arglist); + var args = Args.init(arglist); + _ = args.next(); // program name + while (args.next()) |opt| { + if (!opt.opt) { + // XXX: ncdu 1.x doesn't error, it just silently ignores all but the last argument. + if (scan_dir != null) ui.die("Multiple directories given, see ncdu -h for help.\n", .{}); + scan_dir = allocator.dupeZ(u8, opt.val) catch unreachable; + continue; + } + if (opt.is("-h") or opt.is("-?") or opt.is("--help")) help() + else if (opt.is("-v") or opt.is("-V") or opt.is("--version")) version() + else if (opt.is("-o") and export_file != null) ui.die("The -o flag can only be given once.\n", .{}) + else if (opt.is("-o")) export_file = allocator.dupeZ(u8, args.arg()) catch unreachable + else if (opt.is("-f") and import_file != null) ui.die("The -f flag can only be given once.\n", .{}) + else if (opt.is("-f")) import_file = allocator.dupeZ(u8, args.arg()) catch unreachable + else if (opt.is("--ignore-config")) {} + else if (opt.is("--quit-after-scan")) quit_after_scan = true // undocumented feature to help with benchmarking scan/import + else if (argConfig(&args, opt)) {} + else ui.die("Unrecognized option '{s}'.\n", .{opt.val}); } - if (opt.is("-h") or opt.is("-?") or opt.is("--help")) help() - else if(opt.is("-v") or opt.is("-V") or opt.is("--version")) version() - else if(opt.is("-q")) config.update_delay = 2*std.time.ns_per_s - else if(opt.is("-x")) config.same_fs = true - else if(opt.is("-e")) config.extended = true - else if(opt.is("-r") and config.read_only) config.can_shell = false - else if(opt.is("-r")) config.read_only = true - else if(opt.is("-0")) { has_scan_ui = true; config.scan_ui = .none; } - else if(opt.is("-1")) { has_scan_ui = true; config.scan_ui = .line; } - else if(opt.is("-2")) { has_scan_ui = true; config.scan_ui = .full; } - else if(opt.is("-o") and export_file != null) ui.die("The -o flag can only be given once.\n", .{}) - else if(opt.is("-o")) export_file = args.arg() - else if(opt.is("-f") and import_file != null) ui.die("The -f flag can only be given once.\n", .{}) - else if(opt.is("-f")) import_file = args.arg() - else if(opt.is("--si")) config.si = true - else if(opt.is("-L") or opt.is("--follow-symlinks")) config.follow_symlinks = true - else if(opt.is("--exclude")) config.exclude_patterns.append(args.arg()) catch unreachable - else if(opt.is("-X") or opt.is("--exclude-from")) { - const arg = args.arg(); - readExcludeFile(arg) catch |e| ui.die("Error reading excludes from {s}: {s}.\n", .{ arg, ui.errorString(e) }); - } else if(opt.is("--exclude-caches")) config.exclude_caches = true - else if(opt.is("--exclude-kernfs")) config.exclude_kernfs = true - else if(opt.is("--confirm-quit")) config.confirm_quit = true - else if(opt.is("--color")) { - const val = args.arg(); - if (std.mem.eql(u8, val, "off")) config.ui_color = .off - else if (std.mem.eql(u8, val, "dark")) config.ui_color = .dark - else ui.die("Unknown --color option: {s}.\n", .{val}); - } else ui.die("Unrecognized option '{s}'.\n", .{opt.val}); } - if (std.builtin.os.tag != .linux and config.exclude_kernfs) - ui.die("The --exclude-kernfs tag is currently only supported on Linux.\n", .{}); + if (@import("builtin").os.tag != .linux and config.exclude_kernfs) + ui.die("The --exclude-kernfs flag is currently only supported on Linux.\n", .{}); - const out_tty = std.io.getStdOut().isTty(); - const in_tty = std.io.getStdIn().isTty(); - if (!has_scan_ui) { + const stdin = std.io.getStdIn(); + const stdout = std.io.getStdOut(); + const out_tty = stdout.isTty(); + const in_tty = stdin.isTty(); + if (config.scan_ui == null) { if (export_file) |f| { if (!out_tty or std.mem.eql(u8, f, "-")) config.scan_ui = .none else config.scan_ui = .line; - } + } else config.scan_ui = .full; } if (!in_tty and import_file == null and export_file == null) ui.die("Standard input is not a TTY. Did you mean to import a file using '-f -'?\n", .{}); @@ -322,8 +506,8 @@ pub fn main() void { event_delay_timer = std.time.Timer.start() catch unreachable; defer ui.deinit(); - var out_file = if (export_file) |f| ( - if (std.mem.eql(u8, f, "-")) std.io.getStdOut() + const out_file = if (export_file) |f| ( + if (std.mem.eql(u8, f, "-")) stdout else std.fs.cwd().createFileZ(f, .{}) catch |e| ui.die("Error opening export file: {s}.\n", .{ui.errorString(e)}) ) else null; @@ -333,7 +517,11 @@ pub fn main() void { config.imported = true; } else scan.scanRoot(scan_dir orelse ".", out_file) catch |e| ui.die("Error opening directory: {s}.\n", .{ui.errorString(e)}); - if (out_file != null) return; + if (quit_after_scan or out_file != null) return; + + config.can_shell = config.can_shell orelse !config.imported; + config.can_delete = config.can_delete orelse !config.imported; + config.can_refresh = config.can_refresh orelse !config.imported; config.scan_ui = .full; // in case we're refreshing from the UI, always in full mode. ui.init(); @@ -385,7 +573,7 @@ pub fn handleEvent(block: bool, force_draw: bool) void { var firstblock = block; while (true) { - var ch = ui.getch(firstblock); + const ch = ui.getch(firstblock); if (ch == 0) return; if (ch == -1) return handleEvent(firstblock, true); switch (state) { @@ -398,21 +586,10 @@ pub fn handleEvent(block: bool, force_draw: bool) void { } } - test "argument parser" { - const L = struct { - lst: []const [:0]const u8, - idx: usize = 0, - fn next(s: *@This()) ?[:0]const u8 { - if (s.idx == s.lst.len) return null; - defer s.idx += 1; - return s.lst[s.idx]; - } - }; const lst = [_][:0]const u8{ "a", "-abcd=e", "--opt1=arg1", "--opt2", "arg2", "-x", "foo", "", "--", "--arg", "", "-", }; - const l = L{ .lst = &lst }; const T = struct { - a: Args(L), + a: Args, fn opt(self: *@This(), isopt: bool, val: []const u8) !void { const o = self.a.next().?; try std.testing.expectEqual(isopt, o.opt); @@ -423,7 +600,7 @@ test "argument parser" { try std.testing.expectEqualStrings(val, self.a.arg()); } }; - var t = T{ .a = Args(L).init(l) }; + var t = T{ .a = Args.init(&lst) }; try t.opt(false, "a"); try t.opt(true, "-a"); try t.opt(true, "-b"); diff --git a/src/model.zig b/src/model.zig index 42f2a78..feb589a 100644 --- a/src/model.zig +++ b/src/model.zig @@ -1,10 +1,10 @@ -// SPDX-FileCopyrightText: 2021 Yoran Heling <projects@yorhel.nl> +// SPDX-FileCopyrightText: 2021-2023 Yoran Heling <projects@yorhel.nl> // SPDX-License-Identifier: MIT const std = @import("std"); const main = @import("main.zig"); const ui = @import("ui.zig"); -usingnamespace @import("util.zig"); +const util = @import("util.zig"); // While an arena allocator is optimimal for almost all scenarios in which ncdu // is used, it doesn't allow for re-using deleted nodes after doing a delete or @@ -12,11 +12,12 @@ usingnamespace @import("util.zig"); // will leak memory, but I'd say that's worth the efficiency gains. // TODO: Can still implement a simple bucketed free list on top of this arena // allocator to reuse nodes, if necessary. -var allocator = std.heap.ArenaAllocator.init(std.heap.page_allocator); +var allocator_state = std.heap.ArenaAllocator.init(std.heap.page_allocator); +const allocator = allocator_state.allocator(); -pub const EType = packed enum(u2) { dir, link, file }; +pub const EType = enum(u2) { dir, link, file }; -// Type for the Entry.blocks field. Smaller than a u64 to make room for flags. +// Type for the Entry.Packed.blocks field. Smaller than a u64 to make room for flags. pub const Blocks = u60; // Memory layout: @@ -30,93 +31,94 @@ pub const Blocks = u60; // These are all packed structs and hence do not have any alignment, which is // great for saving memory but perhaps not very great for code size or // performance. -// (TODO: What are the aliassing rules for Zig? There is a 'noalias' keyword, -// but does that mean all unmarked pointers are allowed to alias?) -pub const Entry = packed struct { - etype: EType, - isext: bool, - // Whether or not this entry's size has been counted in its parents. - // Counting of Link entries is deferred until the scan/delete operation has - // completed, so for those entries this flag indicates an intention to be - // counted. - counted: bool, - blocks: Blocks, // 512-byte blocks - size: u64, - next: ?*Entry, +pub const Entry = extern struct { + pack: Packed align(1), + size: u64 align(1) = 0, + next: ?*Entry align(1) = null, + + pub const Packed = packed struct(u64) { + etype: EType, + isext: bool, + // Whether or not this entry's size has been counted in its parents. + // Counting of Link entries is deferred until the scan/delete operation has + // completed, so for those entries this flag indicates an intention to be + // counted. + counted: bool = false, + blocks: Blocks = 0, // 512-byte blocks + }; const Self = @This(); pub fn dir(self: *Self) ?*Dir { - return if (self.etype == .dir) @ptrCast(*Dir, self) else null; + return if (self.pack.etype == .dir) @ptrCast(self) else null; } pub fn link(self: *Self) ?*Link { - return if (self.etype == .link) @ptrCast(*Link, self) else null; + return if (self.pack.etype == .link) @ptrCast(self) else null; } pub fn file(self: *Self) ?*File { - return if (self.etype == .file) @ptrCast(*File, self) else null; + return if (self.pack.etype == .file) @ptrCast(self) else null; } // Whether this entry should be displayed as a "directory". // Some dirs are actually represented in this data model as a File for efficiency. pub fn isDirectory(self: *Self) bool { - return if (self.file()) |f| f.other_fs or f.kernfs else self.etype == .dir; + return if (self.file()) |f| f.pack.other_fs or f.pack.kernfs else self.pack.etype == .dir; } - fn nameOffset(etype: EType) usize { - return switch (etype) { - .dir => @byteOffsetOf(Dir, "name"), - .link => @byteOffsetOf(Link, "name"), - .file => @byteOffsetOf(File, "name"), + pub fn name(self: *const Self) [:0]const u8 { + const self_name = switch (self.pack.etype) { + .dir => &@as(*const Dir, @ptrCast(self)).name, + .link => &@as(*const Link, @ptrCast(self)).name, + .file => &@as(*const File, @ptrCast(self)).name, }; + const name_ptr: [*:0]const u8 = @ptrCast(self_name); + return std.mem.sliceTo(name_ptr, 0); } - pub fn name(self: *const Self) [:0]const u8 { - const ptr = @ptrCast([*:0]const u8, self) + nameOffset(self.etype); - return ptr[0..std.mem.lenZ(ptr) :0]; + pub fn ext(self: *Self) ?*Ext { + if (!self.pack.isext) return null; + return @ptrCast(@as([*]Ext, @ptrCast(self)) - 1); } - pub fn ext(self: *Self) ?*Ext { - if (!self.isext) return null; - return @ptrCast(*Ext, @ptrCast([*]Ext, self) - 1); + fn alloc(comptime T: type, etype: EType, isext: bool, ename: []const u8) *Entry { + const size = (if (isext) @as(usize, @sizeOf(Ext)) else 0) + @sizeOf(T) + ename.len + 1; + var ptr = blk: while (true) { + if (allocator.allocWithOptions(u8, size, 1, null)) |p| break :blk p + else |_| {} + ui.oom(); + }; + if (isext) { + @as(*Ext, @ptrCast(ptr)).* = .{}; + ptr = ptr[@sizeOf(Ext)..]; + } + const e: *T = @ptrCast(ptr); + e.* = .{ .entry = .{ .pack = .{ .etype = etype, .isext = isext } } }; + const n = @as([*]u8, @ptrCast(&e.name))[0..ename.len+1]; + @memcpy(n[0..ename.len], ename); + n[ename.len] = 0; + return &e.entry; } pub fn create(etype: EType, isext: bool, ename: []const u8) *Entry { - const extsize = if (isext) @as(usize, @sizeOf(Ext)) else 0; - const size = nameOffset(etype) + ename.len + 1 + extsize; - var ptr = blk: { - while (true) { - if (allocator.allocator.allocWithOptions(u8, size, std.math.max(@alignOf(Ext), @alignOf(Entry)), null)) |p| - break :blk p - else |_| {} - ui.oom(); - } + return switch (etype) { + .dir => alloc(Dir, etype, isext, ename), + .file => alloc(File, etype, isext, ename), + .link => alloc(Link, etype, isext, ename), }; - std.mem.set(u8, ptr, 0); // kind of ugly, but does the trick - var e = @ptrCast(*Entry, ptr.ptr + extsize); - e.etype = etype; - e.isext = isext; - var name_ptr = @ptrCast([*]u8, e) + nameOffset(etype); - std.mem.copy(u8, name_ptr[0..ename.len], ename); - return e; } - // Set the 'err' flag on Dirs and Files, propagating 'suberr' to parents. - pub fn setErr(self: *Self, parent: *Dir) void { - if (self.dir()) |d| d.err = true - else if (self.file()) |f| f.err = true - else unreachable; - var it: ?*Dir = if (&parent.entry == self) parent.parent else parent; - while (it) |p| : (it = p.parent) { - if (p.suberr) break; - p.suberr = true; - } + fn hasErr(self: *Self) bool { + return + if (self.file()) |f| f.pack.err + else if (self.dir()) |d| d.pack.err or d.pack.suberr + else false; } pub fn addStats(self: *Entry, parent: *Dir, nlink: u31) void { - if (self.counted) return; - self.counted = true; + if (self.pack.counted) return; + self.pack.counted = true; // Add link to the inode map, but don't count its size (yet). if (self.link()) |l| { @@ -124,7 +126,7 @@ pub const Entry = packed struct { var d = inodes.map.getOrPut(l) catch unreachable; if (!d.found_existing) { d.value_ptr.* = .{ .counted = false, .nlink = nlink }; - inodes.total_blocks = saturateAdd(inodes.total_blocks, self.blocks); + inodes.total_blocks +|= self.pack.blocks; l.next = l; } else { inodes.setStats(.{ .key_ptr = d.key_ptr, .value_ptr = d.value_ptr }, false); @@ -142,10 +144,10 @@ pub const Entry = packed struct { if (self.ext()) |e| if (p.entry.ext()) |pe| if (e.mtime > pe.mtime) { pe.mtime = e.mtime; }; - p.items = saturateAdd(p.items, 1); - if (self.etype != .link) { - p.entry.size = saturateAdd(p.entry.size, self.size); - p.entry.blocks = saturateAdd(p.entry.blocks, self.blocks); + p.items +|= 1; + if (self.pack.etype != .link) { + p.entry.size +|= self.size; + p.entry.pack.blocks +|= self.pack.blocks; } } } @@ -164,8 +166,8 @@ pub const Entry = packed struct { // anymore, meaning that delStats() followed by addStats() with the same // data may cause information to be lost. pub fn delStats(self: *Entry, parent: *Dir) void { - if (!self.counted) return; - defer self.counted = false; // defer, to make sure inodes.setStats() still sees it as counted. + if (!self.pack.counted) return; + defer self.pack.counted = false; // defer, to make sure inodes.setStats() still sees it as counted. if (self.link()) |l| { var d = inodes.map.getEntry(l).?; @@ -174,7 +176,7 @@ pub const Entry = packed struct { if (l.next == l) { _ = inodes.map.remove(l); _ = inodes.uncounted.remove(l); - inodes.total_blocks = saturateSub(inodes.total_blocks, self.blocks); + inodes.total_blocks -|= self.pack.blocks; } else { if (d.key_ptr.* == l) d.key_ptr.* = l.next; @@ -193,10 +195,10 @@ pub const Entry = packed struct { var it: ?*Dir = parent; while(it) |p| : (it = p.parent) { - p.items = saturateSub(p.items, 1); - if (self.etype != .link) { - p.entry.size = saturateSub(p.entry.size, self.size); - p.entry.blocks = saturateSub(p.entry.blocks, self.blocks); + p.items -|= 1; + if (self.pack.etype != .link) { + p.entry.size -|= self.size; + p.entry.pack.blocks -|= self.pack.blocks; } } } @@ -211,32 +213,35 @@ pub const Entry = packed struct { } }; -const DevId = u30; // Can be reduced to make room for more flags in Dir. +const DevId = u30; // Can be reduced to make room for more flags in Dir.Packed. -pub const Dir = packed struct { +pub const Dir = extern struct { entry: Entry, - sub: ?*Entry, - parent: ?*Dir, + sub: ?*Entry align(1) = null, + parent: ?*Dir align(1) = null, // entry.{blocks,size}: Total size of all unique files + dirs. Non-shared hardlinks are counted only once. // (i.e. the space you'll need if you created a filesystem with only this dir) // shared_*: Unique hardlinks that still have references outside of this directory. // (i.e. the space you won't reclaim by deleting this dir) // (space reclaimed by deleting a dir =~ entry. - shared_) - shared_blocks: u64, - shared_size: u64, - items: u32, - - // Indexes into the global 'devices.list' array - dev: DevId, + shared_blocks: u64 align(1) = 0, + shared_size: u64 align(1) = 0, + items: u32 align(1) = 0, - err: bool, - suberr: bool, + pack: Packed align(1) = .{}, - // Only used to find the @byteOffsetOff, the name is written at this point as a 0-terminated string. + // Only used to find the @offsetOff, the name is written at this point as a 0-terminated string. // (Old C habits die hard) - name: u8, + name: [0]u8 = undefined, + + pub const Packed = packed struct { + // Indexes into the global 'devices.list' array + dev: DevId = 0, + err: bool = false, + suberr: bool = false, + }; pub fn fmtPath(self: *const @This(), withRoot: bool, out: *std.ArrayList(u8)) void { if (!withRoot and self.parent == null) return; @@ -249,25 +254,38 @@ pub const Dir = packed struct { var i: usize = components.items.len-1; while (true) { - if (i != components.items.len-1) out.append('/') catch unreachable; + if (i != components.items.len-1 and !(out.items.len != 0 and out.items[out.items.len-1] == '/')) out.append('/') catch unreachable; out.appendSlice(components.items[i]) catch unreachable; if (i == 0) break; i -= 1; } } + + // Only updates the suberr of this Dir, assumes child dirs have already + // been updated and does not propagate to parents. + pub fn updateSubErr(self: *@This()) void { + self.pack.suberr = false; + var sub = self.sub; + while (sub) |e| : (sub = e.next) { + if (e.hasErr()) { + self.pack.suberr = true; + break; + } + } + } }; // File that's been hardlinked (i.e. nlink > 1) -pub const Link = packed struct { +pub const Link = extern struct { entry: Entry, - parent: *Dir, - next: *Link, // Singly circular linked list of all *Link nodes with the same dev,ino. + parent: *Dir align(1) = undefined, + next: *Link align(1) = undefined, // Singly circular linked list of all *Link nodes with the same dev,ino. // dev is inherited from the parent Dir - ino: u64, - name: u8, + ino: u64 align(1) = undefined, + name: [0]u8 = undefined, // Return value should be freed with main.allocator. - pub fn path(self: @This(), withRoot: bool) [:0]const u8 { + pub fn path(self: *const @This(), withRoot: bool) [:0]const u8 { var out = std.ArrayList(u8).init(main.allocator); self.parent.fmtPath(withRoot, &out); out.append('/') catch unreachable; @@ -277,40 +295,28 @@ pub const Link = packed struct { }; // Anything that's not an (indexed) directory or hardlink. Excluded directories are also "Files". -pub const File = packed struct { +pub const File = extern struct { entry: Entry, - - err: bool, - excluded: bool, - other_fs: bool, - kernfs: bool, - notreg: bool, - _pad: u3, - - name: u8, - - pub fn resetFlags(f: *@This()) void { - f.err = false; - f.excluded = false; - f.other_fs = false; - f.kernfs = false; - f.notreg = false; - } + pack: Packed = .{}, + name: [0]u8 = undefined, + + pub const Packed = packed struct(u8) { + err: bool = false, + excluded: bool = false, + other_fs: bool = false, + kernfs: bool = false, + notreg: bool = false, + _pad: u3 = 0, // Make this struct "ABI sized" to allow inclusion in an extern struct + }; }; -pub const Ext = packed struct { - mtime: u64 = 0, - uid: u32 = 0, - gid: u32 = 0, - mode: u16 = 0, +pub const Ext = extern struct { + mtime: u64 align(1) = 0, + uid: u32 align(1) = 0, + gid: u32 align(1) = 0, + mode: u16 align(1) = 0, }; -comptime { - std.debug.assert(@bitOffsetOf(Dir, "name") % 8 == 0); - std.debug.assert(@bitOffsetOf(Link, "name") % 8 == 0); - std.debug.assert(@bitOffsetOf(File, "name") % 8 == 0); -} - // List of st_dev entries. Those are typically 64bits, but that's quite a waste // of space when a typical scan won't cover many unique devices. @@ -321,9 +327,9 @@ pub const devices = struct { var lookup = std.AutoHashMap(u64, DevId).init(main.allocator); pub fn getId(dev: u64) DevId { - var d = lookup.getOrPut(dev) catch unreachable; + const d = lookup.getOrPut(dev) catch unreachable; if (!d.found_existing) { - d.value_ptr.* = @intCast(DevId, list.items.len); + d.value_ptr.* = @as(DevId, @intCast(list.items.len)); list.append(dev) catch unreachable; } return d.value_ptr.*; @@ -366,13 +372,13 @@ pub const inodes = struct { const HashContext = struct { pub fn hash(_: @This(), l: *Link) u64 { var h = std.hash.Wyhash.init(0); - h.update(std.mem.asBytes(&@as(u32, l.parent.dev))); + h.update(std.mem.asBytes(&@as(u32, l.parent.pack.dev))); h.update(std.mem.asBytes(&l.ino)); return h.final(); } pub fn eql(_: @This(), a: *Link, b: *Link) bool { - return a.ino == b.ino and a.parent.dev == b.parent.dev; + return a.ino == b.ino and a.parent.pack.dev == b.parent.pack.dev; } }; @@ -398,11 +404,11 @@ pub const inodes = struct { defer dirs.deinit(); var it = entry.key_ptr.*; while (true) { - if (it.entry.counted) { + if (it.entry.pack.counted) { nlink += 1; var parent: ?*Dir = it.parent; while (parent) |p| : (parent = p.parent) { - var de = dirs.getOrPut(p) catch unreachable; + const de = dirs.getOrPut(p) catch unreachable; if (de.found_existing) de.value_ptr.* += 1 else de.value_ptr.* = 1; } @@ -418,20 +424,20 @@ pub const inodes = struct { var dir_iter = dirs.iterator(); if (add) { while (dir_iter.next()) |de| { - de.key_ptr.*.entry.blocks = saturateAdd(de.key_ptr.*.entry.blocks, entry.key_ptr.*.entry.blocks); - de.key_ptr.*.entry.size = saturateAdd(de.key_ptr.*.entry.size, entry.key_ptr.*.entry.size); + de.key_ptr.*.entry.pack.blocks +|= entry.key_ptr.*.entry.pack.blocks; + de.key_ptr.*.entry.size +|= entry.key_ptr.*.entry.size; if (de.value_ptr.* < nlink) { - de.key_ptr.*.shared_blocks = saturateAdd(de.key_ptr.*.shared_blocks, entry.key_ptr.*.entry.blocks); - de.key_ptr.*.shared_size = saturateAdd(de.key_ptr.*.shared_size, entry.key_ptr.*.entry.size); + de.key_ptr.*.shared_blocks +|= entry.key_ptr.*.entry.pack.blocks; + de.key_ptr.*.shared_size +|= entry.key_ptr.*.entry.size; } } } else { while (dir_iter.next()) |de| { - de.key_ptr.*.entry.blocks = saturateSub(de.key_ptr.*.entry.blocks, entry.key_ptr.*.entry.blocks); - de.key_ptr.*.entry.size = saturateSub(de.key_ptr.*.entry.size, entry.key_ptr.*.entry.size); + de.key_ptr.*.entry.pack.blocks -|= entry.key_ptr.*.entry.pack.blocks; + de.key_ptr.*.entry.size -|= entry.key_ptr.*.entry.size; if (de.value_ptr.* < nlink) { - de.key_ptr.*.shared_blocks = saturateSub(de.key_ptr.*.shared_blocks, entry.key_ptr.*.entry.blocks); - de.key_ptr.*.shared_size = saturateSub(de.key_ptr.*.shared_size, entry.key_ptr.*.entry.size); + de.key_ptr.*.shared_blocks -|= entry.key_ptr.*.entry.pack.blocks; + de.key_ptr.*.shared_size -|= entry.key_ptr.*.entry.size; } } } @@ -456,8 +462,8 @@ pub var root: *Dir = undefined; test "entry" { - var e = Entry.create(.file, false, "hello") catch unreachable; - std.debug.assert(e.etype == .file); - std.debug.assert(!e.isext); - std.testing.expectEqualStrings(e.name(), "hello"); + var e = Entry.create(.file, false, "hello"); + try std.testing.expectEqual(e.pack.etype, .file); + try std.testing.expect(!e.pack.isext); + try std.testing.expectEqualStrings(e.name(), "hello"); } diff --git a/src/ncurses_refs.c b/src/ncurses_refs.c deleted file mode 100644 index b458efb..0000000 --- a/src/ncurses_refs.c +++ /dev/null @@ -1,30 +0,0 @@ -/* SPDX-FileCopyrightText: 2021 Yoran Heling <projects@yorhel.nl> - * SPDX-License-Identifier: MIT - */ - -#include <curses.h> - -/* Zig @cImport() has problems with the ACS_* macros. Two, in fact: - * - * 1. Naively using the ACS_* macros results in: - * - * error: cannot store runtime value in compile time variable - * return acs_map[NCURSES_CAST(u8, c)]; - * ^ - * That error doesn't make much sense to me, but it might be - * related to https://github.com/ziglang/zig/issues/5344? - * - * 2. The 'acs_map' extern variable isn't being linked correctly? - * Haven't investigated this one deeply enough yet, but attempting - * to dereference acs_map from within Zig leads to a segfault; - * its pointer value doesn't make any sense. - */ -chtype ncdu_acs_ulcorner() { return ACS_ULCORNER; } -chtype ncdu_acs_llcorner() { return ACS_LLCORNER; } -chtype ncdu_acs_urcorner() { return ACS_URCORNER; } -chtype ncdu_acs_lrcorner() { return ACS_LRCORNER; } -chtype ncdu_acs_hline() { return ACS_VLINE ; } -chtype ncdu_acs_vline() { return ACS_HLINE ; } - -/* https://github.com/ziglang/zig/issues/8947 */ -void ncdu_init_pair(short a,b,c) { init_pair(a,b,c); } diff --git a/src/scan.zig b/src/scan.zig index 38b9a2e..4608a4f 100644 --- a/src/scan.zig +++ b/src/scan.zig @@ -1,13 +1,13 @@ -// SPDX-FileCopyrightText: 2021 Yoran Heling <projects@yorhel.nl> +// SPDX-FileCopyrightText: 2021-2023 Yoran Heling <projects@yorhel.nl> // SPDX-License-Identifier: MIT const std = @import("std"); const main = @import("main.zig"); const model = @import("model.zig"); const ui = @import("ui.zig"); -usingnamespace @import("util.zig"); +const util = @import("util.zig"); +const exclude = @import("exclude.zig"); const c_statfs = @cImport(@cInclude("sys/vfs.h")); -const c_fnmatch = @cImport(@cInclude("fnmatch.h")); // Concise stat struct for fields we're interested in, with the types used by the model. @@ -23,26 +23,26 @@ const Stat = struct { symlink: bool = false, ext: model.Ext = .{}, - fn clamp(comptime T: type, comptime field: anytype, x: anytype) std.meta.fieldInfo(T, field).field_type { - return castClamp(std.meta.fieldInfo(T, field).field_type, x); + fn clamp(comptime T: type, comptime field: anytype, x: anytype) std.meta.fieldInfo(T, field).type { + return util.castClamp(std.meta.fieldInfo(T, field).type, x); } - fn truncate(comptime T: type, comptime field: anytype, x: anytype) std.meta.fieldInfo(T, field).field_type { - return castTruncate(std.meta.fieldInfo(T, field).field_type, x); + fn truncate(comptime T: type, comptime field: anytype, x: anytype) std.meta.fieldInfo(T, field).type { + return util.castTruncate(std.meta.fieldInfo(T, field).type, x); } fn read(parent: std.fs.Dir, name: [:0]const u8, follow: bool) !Stat { - const stat = try std.os.fstatatZ(parent.fd, name, if (follow) 0 else std.os.AT_SYMLINK_NOFOLLOW); + const stat = try std.os.fstatatZ(parent.fd, name, if (follow) 0 else std.os.AT.SYMLINK_NOFOLLOW); return Stat{ .blocks = clamp(Stat, .blocks, stat.blocks), .size = clamp(Stat, .size, stat.size), .dev = truncate(Stat, .dev, stat.dev), .ino = truncate(Stat, .ino, stat.ino), .nlink = clamp(Stat, .nlink, stat.nlink), - .hlinkc = stat.nlink > 1 and !std.os.system.S_ISDIR(stat.mode), - .dir = std.os.system.S_ISDIR(stat.mode), - .reg = std.os.system.S_ISREG(stat.mode), - .symlink = std.os.system.S_ISLNK(stat.mode), + .hlinkc = stat.nlink > 1 and !std.os.system.S.ISDIR(stat.mode), + .dir = std.os.system.S.ISDIR(stat.mode), + .reg = std.os.system.S.ISREG(stat.mode), + .symlink = std.os.system.S.ISLNK(stat.mode), .ext = .{ .mtime = clamp(model.Ext, .mtime, stat.mtime().tv_sec), .uid = truncate(model.Ext, .uid, stat.uid), @@ -60,7 +60,7 @@ fn isKernfs(dir: std.fs.Dir, dev: u64) bool { if (kernfs_cache.get(dev)) |e| return e; var buf: c_statfs.struct_statfs = undefined; if (c_statfs.fstatfs(dir.fd, &buf) != 0) return false; // silently ignoring errors isn't too nice. - const iskern = switch (buf.f_type) { + const iskern = switch (util.castTruncate(u32, buf.f_type)) { // These numbers are documented in the Linux 'statfs(2)' man page, so I assume they're stable. 0x42494e4d, // BINFMTFS_MAGIC 0xcafe4a11, // BPF_FS_MAGIC @@ -96,7 +96,7 @@ fn writeJsonString(wr: anytype, s: []const u8) !void { 0xC => try wr.writeAll("\\f"), '\\' => try wr.writeAll("\\\\"), '"' => try wr.writeAll("\\\""), - 0...7, 0xB, 0xE...0x1F, 127 => try wr.print("\\u00{x:02}", .{ch}), + 0...7, 0xB, 0xE...0x1F, 127 => try wr.print("\\u00{x:0>2}", .{ch}), else => try wr.writeByte(ch) } } @@ -141,7 +141,7 @@ const ScanDir = struct { var count: Map.Size = 0; var it = dir.sub; while (it) |e| : (it = e.next) count += 1; - self.entries.ensureCapacity(count) catch unreachable; + self.entries.ensureUnusedCapacity(count) catch unreachable; it = dir.sub; while (it) |e| : (it = e.next) @@ -156,14 +156,14 @@ const ScanDir = struct { // in-place conversion to a File entry. That's more efficient, // but also more code. I don't expect this to happen often. var e = entry.key_ptr.*.?; - if (e.etype == .file) { - if (e.size > 0 or e.blocks > 0) { + if (e.pack.etype == .file) { + if (e.size > 0 or e.pack.blocks > 0) { e.delStats(self.dir); e.size = 0; - e.blocks = 0; + e.pack.blocks = 0; e.addStats(self.dir, 0); } - e.file().?.resetFlags(); + e.file().?.pack = .{}; _ = self.entries.removeAdapted(@as(?*model.Entry,null), HashContext{ .cmp = name }); break :blk e; } else e.delStatsRec(self.dir); @@ -176,10 +176,10 @@ const ScanDir = struct { }; var f = e.file().?; switch (t) { - .err => e.setErr(self.dir), - .other_fs => f.other_fs = true, - .kernfs => f.kernfs = true, - .excluded => f.excluded = true, + .err => f.pack.err = true, + .other_fs => f.pack.other_fs = true, + .kernfs => f.pack.kernfs = true, + .excluded => f.pack.excluded = true, } } @@ -192,9 +192,9 @@ const ScanDir = struct { // XXX: In-place conversion may also be possible here. var e = entry.key_ptr.*.?; // changes of dev/ino affect hard link counting in a way we can't simply merge. - const samedev = if (e.dir()) |d| d.dev == model.devices.getId(stat.dev) else true; + const samedev = if (e.dir()) |d| d.pack.dev == model.devices.getId(stat.dev) else true; const sameino = if (e.link()) |l| l.ino == stat.ino else true; - if (e.etype == etype and samedev and sameino) { + if (e.pack.etype == etype and samedev and sameino) { _ = self.entries.removeAdapted(@as(?*model.Entry,null), HashContext{ .cmp = name }); break :blk e; } else e.delStatsRec(self.dir); @@ -209,19 +209,16 @@ const ScanDir = struct { // entire subtree, which, in turn, would break all shared hardlink // sizes. The current approach may result in incorrect sizes after // refresh, but I expect the difference to be fairly minor. - if (!(e.etype == .dir and e.counted) and (e.blocks != stat.blocks or e.size != stat.size)) { + if (!(e.pack.etype == .dir and e.pack.counted) and (e.pack.blocks != stat.blocks or e.size != stat.size)) { e.delStats(self.dir); - e.blocks = stat.blocks; + e.pack.blocks = stat.blocks; e.size = stat.size; } if (e.dir()) |d| { d.parent = self.dir; - d.dev = model.devices.getId(stat.dev); - } - if (e.file()) |f| { - f.resetFlags(); - f.notreg = !stat.dir and !stat.reg; + d.pack.dev = model.devices.getId(stat.dev); } + if (e.file()) |f| f.pack = .{ .notreg = !stat.dir and !stat.reg }; if (e.link()) |l| l.ino = stat.ino; if (e.ext()) |ext| { if (ext.mtime > stat.ext.mtime) @@ -234,16 +231,17 @@ const ScanDir = struct { } fn final(self: *Self) void { - if (self.entries.count() == 0) // optimization for the common case - return; - var it = &self.dir.sub; - while (it.*) |e| { - if (self.entries.contains(e)) { - e.delStatsRec(self.dir); - it.* = e.next; - } else - it = &e.next; + if (self.entries.count() > 0) { + var it = &self.dir.sub; + while (it.*) |e| { + if (self.entries.contains(e)) { + e.delStatsRec(self.dir); + it.* = e.next; + } else + it = &e.next; + } } + self.dir.updateSubErr(); } fn deinit(self: *Self) void { @@ -263,7 +261,8 @@ const ScanDir = struct { // const Context = struct { // When scanning to RAM - parents: ?std.ArrayList(ScanDir) = std.ArrayList(ScanDir).init(main.allocator), + parents: ?std.ArrayList(ScanDir) = null, + refreshing: ?*model.Dir = null, // When scanning to a file wr: ?*Writer = null, @@ -296,20 +295,30 @@ const Context = struct { wr.print("{d}", .{std.time.timestamp()}) catch |e| writeErr(e); wr.writeByte('}') catch |e| writeErr(e); - var self = main.allocator.create(Self) catch unreachable; + const self = main.allocator.create(Self) catch unreachable; self.* = .{ .wr = buf }; return self; } fn initMem(dir: ?*model.Dir) *Self { var self = main.allocator.create(Self) catch unreachable; - self.* = .{ .parents = std.ArrayList(ScanDir).init(main.allocator) }; + self.* = .{ + .parents = std.ArrayList(ScanDir).init(main.allocator), + .refreshing = dir, + }; if (dir) |d| self.parents.?.append(ScanDir.init(d)) catch unreachable; return self; } fn final(self: *Self) void { - if (self.parents) |_| model.inodes.addAllStats(); + if (self.parents) |_| { + counting_hardlinks = true; + defer counting_hardlinks = false; + main.handleEvent(false, true); + model.inodes.addAllStats(); + var p = self.refreshing; + while (p) |d| : (p = d.parent) d.updateSubErr(); + } if (self.wr) |wr| { wr.writer().writeByte(']') catch |e| writeErr(e); wr.flush() catch |e| writeErr(e); @@ -345,13 +354,13 @@ const Context = struct { } fn pathZ(self: *Self) [:0]const u8 { - return arrayListBufZ(&self.path); + return util.arrayListBufZ(&self.path); } // Set a flag to indicate that there was an error listing file entries in the current directory. // (Such errors are silently ignored when exporting to a file, as the directory metadata has already been written) fn setDirlistError(self: *Self) void { - if (self.parents) |*p| p.items[p.items.len-1].dir.entry.setErr(p.items[p.items.len-1].dir); + if (self.parents) |*p| p.items[p.items.len-1].dir.pack.err = true; } const Special = enum { err, other_fs, kernfs, excluded }; @@ -374,8 +383,6 @@ const Context = struct { // Insert the current path as a special entry (i.e. a file/dir that is not counted) // Ignores self.stat except for the 'dir' option. fn addSpecial(self: *Self, t: Special) void { - std.debug.assert(self.items_seen > 0); // root item can't be a special - if (t == .err) { if (self.last_error) |p| main.allocator.free(p); self.last_error = main.allocator.dupeZ(u8, self.path.items) catch unreachable; @@ -396,7 +403,7 @@ const Context = struct { try w.writeAll("{\"name\":"); try writeJsonString(w, self.name); if (self.stat.size > 0) try w.print(",\"asize\":{d}", .{ self.stat.size }); - if (self.stat.blocks > 0) try w.print(",\"dsize\":{d}", .{ blocksToSize(self.stat.blocks) }); + if (self.stat.blocks > 0) try w.print(",\"dsize\":{d}", .{ util.blocksToSize(self.stat.blocks) }); if (self.stat.dir and self.stat.dev != dir_dev) try w.print(",\"dev\":{d}", .{ self.stat.dev }); if (self.stat.hlinkc) try w.print(",\"ino\":{d},\"hlnkc\":true,\"nlink\":{d}", .{ self.stat.ino, self.stat.nlink }); if (!self.stat.dir and !self.stat.reg) try w.writeAll(",\"notreg\":true"); @@ -412,11 +419,11 @@ const Context = struct { var e = if (p.items.len == 0) blk: { // Root entry var e = model.Entry.create(.dir, main.config.extended, self.name); - e.blocks = self.stat.blocks; + e.pack.blocks = self.stat.blocks; e.size = self.stat.size; if (e.ext()) |ext| ext.* = self.stat.ext; model.root = e.dir().?; - model.root.dev = model.devices.getId(self.stat.dev); + model.root.pack.dev = model.devices.getId(self.stat.dev); break :blk e; } else p.items[p.items.len-1].addStat(self.name, &self.stat); @@ -447,8 +454,8 @@ const Context = struct { var active_context: *Context = undefined; // Read and index entries of the given dir. -fn scanDir(ctx: *Context, dir: std.fs.Dir, dir_dev: u64) void { - var it = main.allocator.create(std.fs.Dir.Iterator) catch unreachable; +fn scanDir(ctx: *Context, pat: *const exclude.Patterns, dir: std.fs.IterableDir, dir_dev: u64) void { + var it = main.allocator.create(std.fs.IterableDir.Iterator) catch unreachable; defer main.allocator.destroy(it); it.* = dir.iterate(); while(true) { @@ -462,35 +469,23 @@ fn scanDir(ctx: *Context, dir: std.fs.Dir, dir_dev: u64) void { defer ctx.popPath(); main.handleEvent(false, false); - // XXX: This algorithm is extremely slow, can be optimized with some clever pattern parsing. - const excluded = blk: { - for (main.config.exclude_patterns.items) |pat| { - var path = ctx.pathZ(); - while (path.len > 0) { - if (c_fnmatch.fnmatch(pat, path, 0) == 0) break :blk true; - if (std.mem.indexOfScalar(u8, path, '/')) |idx| path = path[idx+1..:0] - else break; - } - } - break :blk false; - }; - if (excluded) { + const excluded = pat.match(ctx.name); + if (excluded == false) { // matched either a file or directory, so we can exclude this before stat()ing. ctx.addSpecial(.excluded); continue; } - ctx.stat = Stat.read(dir, ctx.name, false) catch { + ctx.stat = Stat.read(dir.dir, ctx.name, false) catch { ctx.addSpecial(.err); continue; }; - if (main.config.same_fs and ctx.stat.dev != dir_dev) { ctx.addSpecial(.other_fs); continue; } if (main.config.follow_symlinks and ctx.stat.symlink) { - if (Stat.read(dir, ctx.name, true)) |nstat| { + if (Stat.read(dir.dir, ctx.name, true)) |nstat| { if (!nstat.dir) { ctx.stat = nstat; // Symlink targets may reside on different filesystems, @@ -500,21 +495,27 @@ fn scanDir(ctx: *Context, dir: std.fs.Dir, dir_dev: u64) void { } } else |_| {} } + if (excluded) |e| if (e and ctx.stat.dir) { + ctx.addSpecial(.excluded); + continue; + }; var edir = - if (ctx.stat.dir) dir.openDirZ(ctx.name, .{ .access_sub_paths = true, .iterate = true, .no_follow = true }) catch { + if (!ctx.stat.dir) null + else if (dir.dir.openDirZ(ctx.name, .{ .no_follow = true }, true)) |d| std.fs.IterableDir{.dir = d} + else |_| { ctx.addSpecial(.err); continue; - } else null; + }; defer if (edir != null) edir.?.close(); - if (std.builtin.os.tag == .linux and main.config.exclude_kernfs and ctx.stat.dir and isKernfs(edir.?, ctx.stat.dev)) { + if (@import("builtin").os.tag == .linux and main.config.exclude_kernfs and ctx.stat.dir and isKernfs(edir.?.dir, ctx.stat.dev)) { ctx.addSpecial(.kernfs); continue; } if (main.config.exclude_caches and ctx.stat.dir) { - if (edir.?.openFileZ("CACHEDIR.TAG", .{})) |f| { + if (edir.?.dir.openFileZ("CACHEDIR.TAG", .{})) |f| { const sig = "Signature: 8a477f597d28d172789f06886806bc55"; var buf: [sig.len]u8 = undefined; if (f.reader().readAll(&buf)) |len| { @@ -527,7 +528,11 @@ fn scanDir(ctx: *Context, dir: std.fs.Dir, dir_dev: u64) void { } ctx.addStat(dir_dev); - if (ctx.stat.dir) scanDir(ctx, edir.?, ctx.stat.dev); + if (ctx.stat.dir) { + var subpat = pat.enter(ctx.name); + defer subpat.deinit(); + scanDir(ctx, &subpat, edir.?, ctx.stat.dev); + } } } @@ -551,21 +556,24 @@ pub fn setupRefresh(parent: *model.Dir) void { parent.fmtPath(true, &full_path); active_context.pushPath(full_path.items); active_context.stat.dir = true; - active_context.stat.dev = model.devices.list.items[parent.dev]; + active_context.stat.dev = model.devices.list.items[parent.pack.dev]; } // To be called after setupRefresh() (or from scanRoot()) pub fn scan() void { defer active_context.deinit(); - var dir = std.fs.cwd().openDirZ(active_context.pathZ(), .{ .access_sub_paths = true, .iterate = true }) catch |e| { + const dir_ = std.fs.cwd().openDirZ(active_context.pathZ(), .{}, true) catch |e| { active_context.last_error = main.allocator.dupeZ(u8, active_context.path.items) catch unreachable; active_context.fatal_error = e; while (main.state == .refresh or main.state == .scan) main.handleEvent(true, true); return; }; + var dir = std.fs.IterableDir{.dir = dir_}; defer dir.close(); - scanDir(active_context, dir, active_context.stat.dev); + var pat = exclude.getPatterns(active_context.pathZ()); + defer pat.deinit(); + scanDir(active_context, &pat, dir, active_context.stat.dev); active_context.popPath(); active_context.final(); } @@ -614,7 +622,10 @@ const Import = struct { return; } } - self.ch = self.rdbuf[self.rdoff]; + // Zig 0.10 copies the entire array to the stack in ReleaseSafe mode, + // work around that bug by indexing into a pointer to the array + // instead. + self.ch = (&self.rdbuf)[self.rdoff]; self.rdoff += 1; self.byte += 1; } @@ -783,7 +794,7 @@ const Import = struct { }, 'd' => { if (eq(u8, key, "dsize")) { - self.ctx.stat.blocks = @intCast(model.Blocks, self.uint(u64)>>9); + self.ctx.stat.blocks = @intCast(self.uint(u64)>>9); return; } if (eq(u8, key, "dev")) { @@ -799,6 +810,7 @@ const Import = struct { if (eq(u8, typ, "otherfs")) special.* = .other_fs else if (eq(u8, typ, "kernfs")) special.* = .kernfs else special.* = .excluded; + return; } }, 'g' => { @@ -899,7 +911,9 @@ const Import = struct { fn item(self: *Self, dev: u64) void { self.ctx.stat = .{}; + var isdir = false; if (self.ch == '[') { + isdir = true; self.ctx.stat.dir = true; self.con(); self.conws(); @@ -908,7 +922,7 @@ const Import = struct { self.iteminfo(dev); self.conws(); - if (self.ctx.stat.dir) { + if (isdir) { const ndev = self.ctx.stat.dev; while (self.ch == ',') { self.con(); @@ -961,7 +975,7 @@ const Import = struct { }; pub fn importRoot(path: [:0]const u8, out: ?std.fs.File) void { - var fd = if (std.mem.eql(u8, "-", path)) std.io.getStdIn() + const fd = if (std.mem.eql(u8, "-", path)) std.io.getStdIn() else std.fs.cwd().openFileZ(path, .{}) catch |e| ui.die("Error reading file: {s}.\n", .{ui.errorString(e)}); defer fd.close(); @@ -974,29 +988,37 @@ pub fn importRoot(path: [:0]const u8, out: ?std.fs.File) void { } var animation_pos: u32 = 0; +var counting_hardlinks: bool = false; var need_confirm_quit = false; fn drawError(err: anyerror) void { - const width = saturateSub(ui.cols, 5); + const width = ui.cols -| 5; const box = ui.Box.create(7, width, "Scan error"); box.move(2, 2); ui.addstr("Path: "); - ui.addstr(ui.shorten(ui.toUtf8(active_context.last_error.?), saturateSub(width, 10))); + ui.addstr(ui.shorten(ui.toUtf8(active_context.last_error.?), width -| 10)); box.move(3, 2); ui.addstr("Error: "); - ui.addstr(ui.shorten(ui.errorString(err), saturateSub(width, 6))); + ui.addstr(ui.shorten(ui.errorString(err), width -| 6)); - box.move(5, saturateSub(width, 27)); + box.move(5, width -| 27); ui.addstr("Press any key to continue"); } +fn drawCounting() void { + const box = ui.Box.create(4, 25, "Finalizing"); + box.move(2, 2); + ui.addstr("Counting hardlinks..."); +} + fn drawBox() void { ui.init(); const ctx = active_context; if (ctx.fatal_error) |err| return drawError(err); - const width = saturateSub(ui.cols, 5); + if (counting_hardlinks) return drawCounting(); + const width = ui.cols -| 5; const box = ui.Box.create(10, width, "Scanning..."); box.move(2, 2); ui.addstr("Total items: "); @@ -1006,12 +1028,12 @@ fn drawBox() void { box.move(2, 30); ui.addstr("size: "); // TODO: Should display the size of the dir-to-be-refreshed on refreshing, not the root. - ui.addsize(.default, blocksToSize(saturateAdd(model.root.entry.blocks, model.inodes.total_blocks))); + ui.addsize(.default, util.blocksToSize(model.root.entry.pack.blocks +| model.inodes.total_blocks)); } box.move(3, 2); ui.addstr("Current item: "); - ui.addstr(ui.shorten(ui.toUtf8(ctx.pathZ()), saturateSub(width, 18))); + ui.addstr(ui.shorten(ui.toUtf8(ctx.pathZ()), width -| 18)); if (ctx.last_error) |path| { box.move(5, 2); @@ -1019,20 +1041,20 @@ fn drawBox() void { ui.addstr("Warning: "); ui.style(.default); ui.addstr("error scanning "); - ui.addstr(ui.shorten(ui.toUtf8(path), saturateSub(width, 28))); + ui.addstr(ui.shorten(ui.toUtf8(path), width -| 28)); box.move(6, 3); ui.addstr("some directory sizes may not be correct."); } if (need_confirm_quit) { - box.move(8, saturateSub(width, 20)); + box.move(8, width -| 20); ui.addstr("Press "); ui.style(.key); ui.addch('y'); ui.style(.default); ui.addstr(" to confirm"); } else { - box.move(8, saturateSub(width, 18)); + box.move(8, width -| 18); ui.addstr("Press "); ui.style(.key); ui.addch('q'); @@ -1045,9 +1067,8 @@ fn drawBox() void { animation_pos += 1; if (animation_pos >= txt.len*2) animation_pos = 0; if (animation_pos < txt.len) { - var i: u32 = 0; box.move(8, 2); - while (i <= animation_pos) : (i += 1) ui.addch(txt[i]); + for (txt[0..animation_pos + 1]) |t| ui.addch(t); } else { var i: u32 = txt.len-1; while (i > animation_pos-txt.len) : (i -= 1) { @@ -1059,24 +1080,27 @@ fn drawBox() void { } pub fn draw() void { - if (active_context.fatal_error != null and main.config.scan_ui != .full) + if (active_context.fatal_error != null and main.config.scan_ui.? != .full) ui.die("Error reading {s}: {s}\n", .{ active_context.last_error.?, ui.errorString(active_context.fatal_error.?) }); - switch (main.config.scan_ui) { + switch (main.config.scan_ui.?) { .none => {}, .line => { var buf: [256]u8 = undefined; var line: []const u8 = undefined; - if (active_context.parents == null) { + if (counting_hardlinks) { + line = "\x1b7\x1b[JCounting hardlinks...\x1b8"; + } else if (active_context.parents == null) { line = std.fmt.bufPrint(&buf, "\x1b7\x1b[J{s: <63} {d:>9} files\x1b8", .{ ui.shorten(active_context.pathZ(), 63), active_context.items_seen } ) catch return; } else { - const r = ui.FmtSize.fmt(blocksToSize(model.root.entry.blocks)); + const r = ui.FmtSize.fmt(util.blocksToSize(model.root.entry.pack.blocks)); line = std.fmt.bufPrint(&buf, "\x1b7\x1b[J{s: <51} {d:>9} files / {s}{s}\x1b8", .{ ui.shorten(active_context.pathZ(), 51), active_context.items_seen, r.num(), r.unit } ) catch return; } - _ = std.io.getStdErr().write(line) catch {}; + const stderr = std.io.getStdErr(); + stderr.writeAll(line) catch {}; }, .full => drawBox(), } @@ -1,18 +1,18 @@ -// SPDX-FileCopyrightText: 2021 Yoran Heling <projects@yorhel.nl> +// SPDX-FileCopyrightText: 2021-2023 Yoran Heling <projects@yorhel.nl> // SPDX-License-Identifier: MIT // Ncurses wrappers and TUI helper functions. const std = @import("std"); const main = @import("main.zig"); -usingnamespace @import("util.zig"); +const util = @import("util.zig"); pub const c = @cImport({ + @cDefine("_XOPEN_SOURCE", "1"); @cInclude("stdio.h"); @cInclude("string.h"); @cInclude("curses.h"); @cInclude("time.h"); - @cDefine("_X_OPEN_SOURCE", "1"); @cInclude("wchar.h"); @cInclude("locale.h"); }); @@ -24,7 +24,8 @@ pub var cols: u32 = undefined; pub fn die(comptime fmt: []const u8, args: anytype) noreturn { deinit(); - _ = std.io.getStdErr().writer().print(fmt, args) catch {}; + const stderr = std.io.getStdErr(); + stderr.writer().print(fmt, args) catch {}; std.process.exit(1); } @@ -45,7 +46,8 @@ pub fn quit() noreturn { pub fn oom() void { const haveui = inited; deinit(); - _ = std.io.getStdErr().writer().writeAll("\x1b7\x1b[JOut of memory, trying again in 1 second. Hit Ctrl-C to abort.\x1b8") catch {}; + const stderr = std.io.getStdErr(); + stderr.writeAll("\x1b7\x1b[JOut of memory, trying again in 1 second. Hit Ctrl-C to abort.\x1b8") catch {}; std.time.sleep(std.time.ns_per_s); if (haveui) init(); @@ -73,7 +75,7 @@ pub fn errorString(e: anyerror) [:0]const u8 { error.ReadOnlyFilesystem => "Read-only filesystem", error.SymlinkLoop => "Symlink loop", error.SystemFdQuotaExceeded => "System file descriptor limit exceeded", - else => @bitCast([:0]const u8, @errorName(e)), // XXX: The bitCast can be removed after a Zig >0.8 release. + else => @errorName(e), }; } @@ -113,7 +115,7 @@ pub fn toUtf8(in: [:0]const u8) [:0]const u8 { to_utf8_buf.writer().print("\\x{X:0>2}", .{in[i]}) catch unreachable; i += 1; } - return arrayListBufZ(&to_utf8_buf); + return util.arrayListBufZ(&to_utf8_buf); } var shorten_buf = std.ArrayList(u8).init(main.allocator); @@ -128,20 +130,21 @@ pub fn shorten(in: [:0]const u8, max_width: u32) [:0] const u8 { var total_width: u32 = 0; var prefix_width: u32 = 0; var prefix_end: u32 = 0; + var prefix_done = false; var it = std.unicode.Utf8View.initUnchecked(in).iterator(); while (it.nextCodepoint()) |cp| { // XXX: libc assumption: wchar_t is a Unicode point. True for most modern libcs? // (The "proper" way is to use mbtowc(), but I'd rather port the musl wcwidth implementation to Zig so that I *know* it'll be Unicode. // On the other hand, ncurses also use wcwidth() so that would cause duplicated code. Ugh) const cp_width_ = c.wcwidth(cp); - const cp_width = @intCast(u32, if (cp_width_ < 0) 1 else cp_width_); + const cp_width: u32 = @intCast(if (cp_width_ < 0) 0 else cp_width_); const cp_len = std.unicode.utf8CodepointSequenceLength(cp) catch unreachable; total_width += cp_width; - if (prefix_width + cp_width <= @divFloor(max_width-1, 2)-1) { + if (!prefix_done and prefix_width + cp_width <= @divFloor(max_width-1, 2)-1) { prefix_width += cp_width; prefix_end += cp_len; - continue; - } + } else + prefix_done = true; } if (total_width <= max_width) return in; @@ -154,7 +157,7 @@ pub fn shorten(in: [:0]const u8, max_width: u32) [:0] const u8 { it = std.unicode.Utf8View.initUnchecked(in[prefix_end..]).iterator(); while (it.nextCodepoint()) |cp| { const cp_width_ = c.wcwidth(cp); - const cp_width = @intCast(u32, if (cp_width_ < 0) 1 else cp_width_); + const cp_width: u32 = @intCast(if (cp_width_ < 0) 0 else cp_width_); const cp_len = std.unicode.utf8CodepointSequenceLength(cp) catch unreachable; start_width += cp_width; start_len += cp_len; @@ -163,7 +166,7 @@ pub fn shorten(in: [:0]const u8, max_width: u32) [:0] const u8 { break; } } - return arrayListBufZ(&shorten_buf); + return util.arrayListBufZ(&shorten_buf); } fn shortenTest(in: [:0]const u8, max_width: u32, out: [:0]const u8) !void { @@ -185,99 +188,109 @@ test "shorten" { try t("AaBCDEFGH", 8, "A...H"); // could optimize this, but w/e try t("ABCDEFGaH", 8, "A...aH"); try t("ABCDEFGH", 15, "ABC...FGH"); + try t("❤︎a❤︎a❤︎a", 5, "❤︎...︎a"); // Variation selectors; not great, there's an additional U+FE0E before 'a'. + try t("ą́ą́ą́ą́ą́ą́", 5, "ą́...̨́ą́"); // Combining marks, similarly bad. } -// ncurses_refs.c -extern fn ncdu_acs_ulcorner() c.chtype; -extern fn ncdu_acs_llcorner() c.chtype; -extern fn ncdu_acs_urcorner() c.chtype; -extern fn ncdu_acs_lrcorner() c.chtype; -extern fn ncdu_acs_hline() c.chtype; -extern fn ncdu_acs_vline() c.chtype; -extern fn ncdu_init_pair(idx: c_short, fg: c_short, bg: c_short) void; - const StyleAttr = struct { fg: i16, bg: i16, attr: u32 }; const StyleDef = struct { name: []const u8, off: StyleAttr, dark: StyleAttr, + darkbg: StyleAttr, fn style(self: *const @This()) StyleAttr { return switch (main.config.ui_color) { .off => self.off, .dark => self.dark, + .darkbg => self.darkbg, }; } }; const styles = [_]StyleDef{ - .{ .name = "default", - .off = .{ .fg = -1, .bg = -1, .attr = 0 }, - .dark = .{ .fg = -1, .bg = -1, .attr = 0 } }, - .{ .name = "bold", - .off = .{ .fg = -1, .bg = -1, .attr = c.A_BOLD }, - .dark = .{ .fg = -1, .bg = -1, .attr = c.A_BOLD } }, - .{ .name = "bold_hd", - .off = .{ .fg = -1, .bg = -1, .attr = c.A_BOLD|c.A_REVERSE }, - .dark = .{ .fg = c.COLOR_BLACK, .bg = c.COLOR_CYAN, .attr = c.A_BOLD } }, - .{ .name = "box_title", - .off = .{ .fg = -1, .bg = -1, .attr = c.A_BOLD }, - .dark = .{ .fg = c.COLOR_BLUE, .bg = -1, .attr = c.A_BOLD } }, - .{ .name = "hd", // header + footer - .off = .{ .fg = -1, .bg = -1, .attr = c.A_REVERSE }, - .dark = .{ .fg = c.COLOR_BLACK, .bg = c.COLOR_CYAN, .attr = 0 } }, - .{ .name = "sel", - .off = .{ .fg = -1, .bg = -1, .attr = c.A_REVERSE }, - .dark = .{ .fg = c.COLOR_WHITE, .bg = c.COLOR_GREEN, .attr = c.A_BOLD } }, - .{ .name = "num", - .off = .{ .fg = -1, .bg = -1, .attr = 0 }, - .dark = .{ .fg = c.COLOR_YELLOW, .bg = -1, .attr = c.A_BOLD } }, - .{ .name = "num_hd", - .off = .{ .fg = -1, .bg = -1, .attr = c.A_REVERSE }, - .dark = .{ .fg = c.COLOR_YELLOW, .bg = c.COLOR_CYAN, .attr = c.A_BOLD } }, - .{ .name = "num_sel", - .off = .{ .fg = -1, .bg = -1, .attr = c.A_REVERSE }, - .dark = .{ .fg = c.COLOR_YELLOW, .bg = c.COLOR_GREEN, .attr = c.A_BOLD } }, - .{ .name = "key", - .off = .{ .fg = -1, .bg = -1, .attr = c.A_BOLD }, - .dark = .{ .fg = c.COLOR_YELLOW, .bg = -1, .attr = c.A_BOLD } }, - .{ .name = "key_hd", - .off = .{ .fg = -1, .bg = -1, .attr = c.A_BOLD|c.A_REVERSE }, - .dark = .{ .fg = c.COLOR_YELLOW, .bg = c.COLOR_CYAN, .attr = c.A_BOLD } }, - .{ .name = "dir", - .off = .{ .fg = -1, .bg = -1, .attr = 0 }, - .dark = .{ .fg = c.COLOR_BLUE, .bg = -1, .attr = c.A_BOLD } }, - .{ .name = "dir_sel", - .off = .{ .fg = -1, .bg = -1, .attr = c.A_REVERSE }, - .dark = .{ .fg = c.COLOR_BLUE, .bg = c.COLOR_GREEN, .attr = c.A_BOLD } }, - .{ .name = "flag", - .off = .{ .fg = -1, .bg = -1, .attr = 0 }, - .dark = .{ .fg = c.COLOR_RED, .bg = -1, .attr = 0 } }, - .{ .name = "flag_sel", - .off = .{ .fg = -1, .bg = -1, .attr = c.A_REVERSE }, - .dark = .{ .fg = c.COLOR_RED, .bg = c.COLOR_GREEN, .attr = 0 } }, - .{ .name = "graph", - .off = .{ .fg = -1, .bg = -1, .attr = 0 }, - .dark = .{ .fg = c.COLOR_MAGENTA, .bg = -1, .attr = 0 } }, - .{ .name = "graph_sel", - .off = .{ .fg = -1, .bg = -1, .attr = c.A_REVERSE }, - .dark = .{ .fg = c.COLOR_MAGENTA, .bg = c.COLOR_GREEN, .attr = 0 } }, + .{ .name = "default", + .off = .{ .fg = -1, .bg = -1, .attr = 0 }, + .dark = .{ .fg = -1, .bg = -1, .attr = 0 }, + .darkbg = .{ .fg = c.COLOR_WHITE, .bg = c.COLOR_BLACK, .attr = 0 } }, + .{ .name = "bold", + .off = .{ .fg = -1, .bg = -1, .attr = c.A_BOLD }, + .dark = .{ .fg = -1, .bg = -1, .attr = c.A_BOLD }, + .darkbg = .{ .fg = c.COLOR_WHITE, .bg = c.COLOR_BLACK, .attr = c.A_BOLD } }, + .{ .name = "bold_hd", + .off = .{ .fg = -1, .bg = -1, .attr = c.A_BOLD|c.A_REVERSE }, + .dark = .{ .fg = c.COLOR_BLACK, .bg = c.COLOR_CYAN, .attr = c.A_BOLD }, + .darkbg = .{ .fg = c.COLOR_BLACK, .bg = c.COLOR_CYAN, .attr = c.A_BOLD } }, + .{ .name = "box_title", + .off = .{ .fg = -1, .bg = -1, .attr = c.A_BOLD }, + .dark = .{ .fg = c.COLOR_BLUE, .bg = -1, .attr = c.A_BOLD }, + .darkbg = .{ .fg = c.COLOR_BLUE, .bg = c.COLOR_BLACK, .attr = c.A_BOLD } }, + .{ .name = "hd", // header + footer + .off = .{ .fg = -1, .bg = -1, .attr = c.A_REVERSE }, + .dark = .{ .fg = c.COLOR_BLACK, .bg = c.COLOR_CYAN, .attr = 0 }, + .darkbg = .{ .fg = c.COLOR_BLACK, .bg = c.COLOR_CYAN, .attr = 0 } }, + .{ .name = "sel", + .off = .{ .fg = -1, .bg = -1, .attr = c.A_REVERSE }, + .dark = .{ .fg = c.COLOR_WHITE, .bg = c.COLOR_GREEN, .attr = c.A_BOLD }, + .darkbg = .{ .fg = c.COLOR_WHITE, .bg = c.COLOR_GREEN, .attr = c.A_BOLD } }, + .{ .name = "num", + .off = .{ .fg = -1, .bg = -1, .attr = 0 }, + .dark = .{ .fg = c.COLOR_YELLOW, .bg = -1, .attr = c.A_BOLD }, + .darkbg = .{ .fg = c.COLOR_YELLOW, .bg = c.COLOR_BLACK, .attr = c.A_BOLD } }, + .{ .name = "num_hd", + .off = .{ .fg = -1, .bg = -1, .attr = c.A_REVERSE }, + .dark = .{ .fg = c.COLOR_YELLOW, .bg = c.COLOR_CYAN, .attr = c.A_BOLD }, + .darkbg = .{ .fg = c.COLOR_YELLOW, .bg = c.COLOR_CYAN, .attr = c.A_BOLD } }, + .{ .name = "num_sel", + .off = .{ .fg = -1, .bg = -1, .attr = c.A_REVERSE }, + .dark = .{ .fg = c.COLOR_YELLOW, .bg = c.COLOR_GREEN, .attr = c.A_BOLD }, + .darkbg = .{ .fg = c.COLOR_YELLOW, .bg = c.COLOR_GREEN, .attr = c.A_BOLD } }, + .{ .name = "key", + .off = .{ .fg = -1, .bg = -1, .attr = c.A_BOLD }, + .dark = .{ .fg = c.COLOR_YELLOW, .bg = -1, .attr = c.A_BOLD }, + .darkbg = .{ .fg = c.COLOR_YELLOW, .bg = c.COLOR_BLACK, .attr = c.A_BOLD } }, + .{ .name = "key_hd", + .off = .{ .fg = -1, .bg = -1, .attr = c.A_BOLD|c.A_REVERSE }, + .dark = .{ .fg = c.COLOR_YELLOW, .bg = c.COLOR_CYAN, .attr = c.A_BOLD }, + .darkbg = .{ .fg = c.COLOR_YELLOW, .bg = c.COLOR_CYAN, .attr = c.A_BOLD } }, + .{ .name = "dir", + .off = .{ .fg = -1, .bg = -1, .attr = 0 }, + .dark = .{ .fg = c.COLOR_BLUE, .bg = -1, .attr = c.A_BOLD }, + .darkbg = .{ .fg = c.COLOR_BLUE, .bg = c.COLOR_BLACK, .attr = c.A_BOLD } }, + .{ .name = "dir_sel", + .off = .{ .fg = -1, .bg = -1, .attr = c.A_REVERSE }, + .dark = .{ .fg = c.COLOR_BLUE, .bg = c.COLOR_GREEN, .attr = c.A_BOLD }, + .darkbg = .{ .fg = c.COLOR_BLUE, .bg = c.COLOR_GREEN, .attr = c.A_BOLD } }, + .{ .name = "flag", + .off = .{ .fg = -1, .bg = -1, .attr = 0 }, + .dark = .{ .fg = c.COLOR_RED, .bg = -1, .attr = 0 }, + .darkbg = .{ .fg = c.COLOR_RED, .bg = c.COLOR_BLACK, .attr = 0 } }, + .{ .name = "flag_sel", + .off = .{ .fg = -1, .bg = -1, .attr = c.A_REVERSE }, + .dark = .{ .fg = c.COLOR_RED, .bg = c.COLOR_GREEN, .attr = 0 }, + .darkbg = .{ .fg = c.COLOR_RED, .bg = c.COLOR_GREEN, .attr = 0 } }, + .{ .name = "graph", + .off = .{ .fg = -1, .bg = -1, .attr = 0 }, + .dark = .{ .fg = c.COLOR_MAGENTA, .bg = -1, .attr = 0 }, + .darkbg = .{ .fg = c.COLOR_MAGENTA, .bg = c.COLOR_BLACK, .attr = 0 } }, + .{ .name = "graph_sel", + .off = .{ .fg = -1, .bg = -1, .attr = c.A_REVERSE }, + .dark = .{ .fg = c.COLOR_MAGENTA, .bg = c.COLOR_GREEN, .attr = 0 }, + .darkbg = .{ .fg = c.COLOR_MAGENTA, .bg = c.COLOR_GREEN, .attr = 0 } }, }; pub const Style = lbl: { - var fields: [styles.len]std.builtin.TypeInfo.EnumField = undefined; - var decls = [_]std.builtin.TypeInfo.Declaration{}; - inline for (styles) |s, i| { - fields[i] = .{ + comptime var fields: [styles.len]std.builtin.Type.EnumField = undefined; + inline for (&fields, styles, 0..) |*field, s, i| { + field.* = .{ .name = s.name, .value = i, }; } break :lbl @Type(.{ .Enum = .{ - .layout = .Auto, .tag_type = u8, .fields = &fields, - .decls = &decls, + .decls = &[_]std.builtin.Type.Declaration{}, .is_exhaustive = true, } }); @@ -314,23 +327,24 @@ pub const Bg = enum { fn updateSize() void { // getmax[yx] macros are marked as "legacy", but Zig can't deal with the "proper" getmaxyx macro. - rows = @intCast(u32, c.getmaxy(c.stdscr)); - cols = @intCast(u32, c.getmaxx(c.stdscr)); + rows = @intCast(c.getmaxy(c.stdscr)); + cols = @intCast(c.getmaxx(c.stdscr)); } fn clearScr() void { // Send a "clear from cursor to end of screen" instruction, to clear a // potential line left behind from scanning in -1 mode. - _ = std.io.getStdErr().write("\x1b[J") catch {}; + const stderr = std.io.getStdErr(); + stderr.writeAll("\x1b[J") catch {}; } pub fn init() void { if (inited) return; clearScr(); if (main.config.nc_tty) { - var tty = c.fopen("/dev/tty", "r+"); - if (tty == null) die("Error opening /dev/tty: {s}.\n", .{ c.strerror(std.c.getErrno(-1)) }); - var term = c.newterm(null, tty, tty); + const tty = c.fopen("/dev/tty", "r+"); + if (tty == null) die("Error opening /dev/tty: {s}.\n", .{ c.strerror(@intFromEnum(std.c.getErrno(-1))) }); + const term = c.newterm(null, tty, tty); if (term == null) die("Error initializing ncurses.\n", .{}); _ = c.set_term(term); } else { @@ -344,8 +358,8 @@ pub fn init() void { _ = c.start_color(); _ = c.use_default_colors(); - for (styles) |s, i| _ = ncdu_init_pair(@intCast(i16, i+1), s.style().fg, s.style().bg); - + for (styles, 0..) |s, i| _ = c.init_pair(@as(i16, @intCast(i+1)), s.style().fg, s.style().bg); + _ = c.bkgd(@intCast(c.COLOR_PAIR(@intFromEnum(Style.default)+1))); inited = true; } @@ -361,18 +375,18 @@ pub fn deinit() void { } pub fn style(s: Style) void { - _ = c.attr_set(styles[@enumToInt(s)].style().attr, @enumToInt(s)+1, null); + _ = c.attr_set(styles[@intFromEnum(s)].style().attr, @intFromEnum(s)+1, null); } pub fn move(y: u32, x: u32) void { - _ = c.move(@intCast(i32, y), @intCast(i32, x)); + _ = c.move(@as(i32, @intCast(y)), @as(i32, @intCast(x))); } // Wraps to the next line if the text overflows, not sure how to disable that. // (Well, addchstr() does that, but not entirely sure I want to go that way. // Does that even work with UTF-8? Or do I really need to go wchar madness?) pub fn addstr(s: [:0]const u8) void { - _ = c.addstr(s); + _ = c.addstr(s.ptr); } // Not to be used for strings that may end up >256 bytes. @@ -396,7 +410,7 @@ pub const FmtSize = struct { pub fn fmt(v: u64) @This() { var r: @This() = undefined; - var f = @intToFloat(f32, v); + var f: f32 = @floatFromInt(v); if (main.config.si) { if(f < 1000.0) { r.unit = " B"; } else if(f < 1e6) { r.unit = " KB"; f /= 1e3; } @@ -420,7 +434,7 @@ pub const FmtSize = struct { } pub fn num(self: *const @This()) [:0]const u8 { - return std.mem.spanZ(&self.buf); + return std.mem.sliceTo(&self.buf, 0); } }; @@ -441,7 +455,7 @@ pub fn addnum(bg: Bg, v: u64) void { const s = std.fmt.bufPrint(&buf, "{d}", .{v}) catch unreachable; var f: [64:0]u8 = undefined; var i: usize = 0; - for (s) |digit, n| { + for (s, 0..) |digit, n| { if (n != 0 and (s.len - n) % 3 == 0) { for (main.config.thousands_sep) |ch| { f[i] = ch; @@ -459,14 +473,14 @@ pub fn addnum(bg: Bg, v: u64) void { // Print a file mode, takes 10 columns pub fn addmode(mode: u32) void { - addch(switch (mode & std.os.S_IFMT) { - std.os.S_IFDIR => 'd', - std.os.S_IFREG => '-', - std.os.S_IFLNK => 'l', - std.os.S_IFIFO => 'p', - std.os.S_IFSOCK => 's', - std.os.S_IFCHR => 'c', - std.os.S_IFBLK => 'b', + addch(switch (mode & std.os.S.IFMT) { + std.os.S.IFDIR => 'd', + std.os.S.IFREG => '-', + std.os.S.IFLNK => 'l', + std.os.S.IFIFO => 'p', + std.os.S.IFSOCK => 's', + std.os.S.IFCHR => 'c', + std.os.S.IFBLK => 'b', else => '?' }); addch(if (mode & 0o400 > 0) 'r' else '-'); @@ -477,12 +491,12 @@ pub fn addmode(mode: u32) void { addch(if (mode & 0o2000 > 0) 's' else if (mode & 0o010 > 0) @as(u7, 'x') else '-'); addch(if (mode & 0o004 > 0) 'r' else '-'); addch(if (mode & 0o002 > 0) 'w' else '-'); - addch(if (mode & 0o1000 > 0) (if (std.os.S_ISDIR(mode)) @as(u7, 't') else 'T') else if (mode & 0o001 > 0) @as(u7, 'x') else '-'); + addch(if (mode & 0o1000 > 0) (if (std.os.S.ISDIR(mode)) @as(u7, 't') else 'T') else if (mode & 0o001 > 0) @as(u7, 'x') else '-'); } // Print a timestamp, takes 25 columns pub fn addts(bg: Bg, ts: u64) void { - const t = castClamp(c.time_t, ts); + const t = util.castClamp(c.time_t, ts); var buf: [32:0]u8 = undefined; const len = c.strftime(&buf, buf.len, "%Y-%m-%d %H:%M:%S %z", c.localtime(&t)); if (len > 0) { @@ -495,7 +509,7 @@ pub fn addts(bg: Bg, ts: u64) void { } pub fn hline(ch: c.chtype, len: u32) void { - _ = c.hline(ch, @intCast(i32, len)); + _ = c.hline(ch, @as(i32, @intCast(len))); } // Draws a bordered box in the center of the screen. @@ -507,26 +521,27 @@ pub const Box = struct { pub fn create(height: u32, width: u32, title: [:0]const u8) Self { const s = Self{ - .start_row = saturateSub(rows>>1, height>>1), - .start_col = saturateSub(cols>>1, width>>1), + .start_row = (rows>>1) -| (height>>1), + .start_col = (cols>>1) -| (width>>1), }; style(.default); if (width < 6 or height < 3) return s; - const ulcorner = ncdu_acs_ulcorner(); - const llcorner = ncdu_acs_llcorner(); - const urcorner = ncdu_acs_urcorner(); - const lrcorner = ncdu_acs_lrcorner(); - const acs_hline = ncdu_acs_hline(); - const acs_vline = ncdu_acs_vline(); + const acs_map = @extern(*[128]c.chtype, .{ .name = "acs_map" }); + const ulcorner = acs_map['l']; + const llcorner = acs_map['m']; + const urcorner = acs_map['k']; + const lrcorner = acs_map['j']; + const acs_hline = acs_map['q']; + const acs_vline = acs_map['x']; var i: u32 = 0; while (i < height) : (i += 1) { s.move(i, 0); - addch(if (i == 0) ulcorner else if (i == height-1) llcorner else acs_hline); - hline(if (i == 0 or i == height-1) acs_vline else ' ', width-2); + addch(if (i == 0) ulcorner else if (i == height-1) llcorner else acs_vline); + hline(if (i == 0 or i == height-1) acs_hline else ' ', width-2); s.move(i, width-1); - addch(if (i == 0) urcorner else if (i == height-1) lrcorner else acs_hline); + addch(if (i == 0) urcorner else if (i == height-1) lrcorner else acs_vline); } s.move(0, 3); @@ -563,20 +578,19 @@ pub fn getch(block: bool) i32 { // In non-blocking mode, we can only assume that ERR means "no input yet". // In blocking mode, give it 100 tries with a 10ms delay in between, // then just give up and die to avoid an infinite loop and unresponsive program. - var attempts: u8 = 0; - while (attempts < 100) : (attempts += 1) { - var ch = c.getch(); + for (0..100) |_| { + const ch = c.getch(); if (ch == c.KEY_RESIZE) { updateSize(); return -1; } if (ch == c.ERR) { if (!block) return 0; - std.os.nanosleep(0, 10*std.time.ns_per_ms); + std.time.sleep(10*std.time.ns_per_ms); continue; } return ch; } die("Error reading keyboard input, assuming TTY has been lost.\n(Potentially nonsensical error message: {s})\n", - .{ c.strerror(std.c.getErrno(-1)) }); + .{ c.strerror(@intFromEnum(std.c.getErrno(-1))) }); } diff --git a/src/util.zig b/src/util.zig index 4c5c8a9..f0fc1d4 100644 --- a/src/util.zig +++ b/src/util.zig @@ -1,18 +1,8 @@ -// SPDX-FileCopyrightText: 2021 Yoran Heling <projects@yorhel.nl> +// SPDX-FileCopyrightText: 2021-2023 Yoran Heling <projects@yorhel.nl> // SPDX-License-Identifier: MIT const std = @import("std"); -pub fn saturateAdd(a: anytype, b: @TypeOf(a)) @TypeOf(a) { - std.debug.assert(@typeInfo(@TypeOf(a)).Int.signedness == .unsigned); - return std.math.add(@TypeOf(a), a, b) catch std.math.maxInt(@TypeOf(a)); -} - -pub fn saturateSub(a: anytype, b: @TypeOf(a)) @TypeOf(a) { - std.debug.assert(@typeInfo(@TypeOf(a)).Int.signedness == .unsigned); - return std.math.sub(@TypeOf(a), a, b) catch std.math.minInt(@TypeOf(a)); -} - // Cast any integer type to the target type, clamping the value to the supported maximum if necessary. pub fn castClamp(comptime T: type, x: anytype) T { // (adapted from std.math.cast) @@ -21,7 +11,7 @@ pub fn castClamp(comptime T: type, x: anytype) T { } else if (std.math.minInt(@TypeOf(x)) < std.math.minInt(T) and x < std.math.minInt(T)) { return std.math.minInt(T); } else { - return @intCast(T, x); + return @intCast(x); } } @@ -29,13 +19,13 @@ pub fn castClamp(comptime T: type, x: anytype) T { pub fn castTruncate(comptime T: type, x: anytype) T { const Ti = @typeInfo(T).Int; const Xi = @typeInfo(@TypeOf(x)).Int; - const nx = if (Xi.signedness != Ti.signedness) @bitCast(std.meta.Int(Ti.signedness, Xi.bits), x) else x; - return if (Xi.bits > Ti.bits) @truncate(T, nx) else nx; + const nx: std.meta.Int(Ti.signedness, Xi.bits) = @bitCast(x); + return if (Xi.bits > Ti.bits) @truncate(nx) else nx; } // Multiplies by 512, saturating. pub fn blocksToSize(b: u64) u64 { - return if (b & 0xFF80000000000000 > 0) std.math.maxInt(u64) else b << 9; + return b *| 512; } // Ensure the given arraylist buffer gets zero-terminated and returns a slice @@ -46,3 +36,106 @@ pub fn arrayListBufZ(buf: *std.ArrayList(u8)) [:0]const u8 { defer buf.items.len -= 1; return buf.items[0..buf.items.len-1:0]; } + +// Straightforward Zig port of strnatcmp() from https://github.com/sourcefrog/natsort/ +// (Requiring nul-terminated strings is ugly, but we've got them anyway and it does simplify the code) +pub fn strnatcmp(a: [:0]const u8, b: [:0]const u8) std.math.Order { + var ai: usize = 0; + var bi: usize = 0; + const isDigit = std.ascii.isDigit; + while (true) { + while (std.ascii.isWhitespace(a[ai])) ai += 1; + while (std.ascii.isWhitespace(b[bi])) bi += 1; + + if (isDigit(a[ai]) and isDigit(b[bi])) { + if (a[ai] == '0' or b[bi] == '0') { // compare_left + while (true) { + if (!isDigit(a[ai]) and !isDigit(b[bi])) break; + if (!isDigit(a[ai])) return .lt; + if (!isDigit(b[bi])) return .gt; + if (a[ai] < b[bi]) return .lt; + if (a[ai] > b[bi]) return .gt; + ai += 1; + bi += 1; + } + } else { // compare_right - for right-aligned numbers + var bias = std.math.Order.eq; + while (true) { + if (!isDigit(a[ai]) and !isDigit(b[bi])) { + if (bias != .eq or (a[ai] == 0 and b[bi] == 0)) return bias + else break; + } + if (!isDigit(a[ai])) return .lt; + if (!isDigit(b[bi])) return .gt; + if (bias == .eq) { + if (a[ai] < b[bi]) bias = .lt; + if (a[ai] > b[bi]) bias = .gt; + } + ai += 1; + bi += 1; + } + } + } + if (a[ai] == 0 and b[bi] == 0) return .eq; + if (a[ai] < b[bi]) return .lt; + if (a[ai] > b[bi]) return .gt; + ai += 1; + bi += 1; + } +} + +test "strnatcmp" { + // Test strings from https://github.com/sourcefrog/natsort/ + // Includes sorted-words, sorted-dates and sorted-fractions. + const w = [_][:0]const u8{ + "1-02", + "1-2", + "1-20", + "1.002.01", + "1.002.03", + "1.002.08", + "1.009.02", + "1.009.10", + "1.009.20", + "1.010.12", + "1.011.02", + "10-20", + "1999-3-3", + "1999-12-25", + "2000-1-2", + "2000-1-10", + "2000-3-23", + "fred", + "jane", + "pic01", + "pic02", + "pic02a", + "pic02000", + "pic05", + "pic2", + "pic3", + "pic4", + "pic 4 else", + "pic 5", + "pic 5 ", + "pic 5 something", + "pic 6", + "pic 7", + "pic100", + "pic100a", + "pic120", + "pic121", + "tom", + "x2-g8", + "x2-y08", + "x2-y7", + "x8-y8", + }; + // Test each string against each other string, simple and thorough. + const eq = std.testing.expectEqual; + for (0..w.len) |i| { + try eq(strnatcmp(w[i], w[i]), .eq); + for (0..i) |j| try eq(strnatcmp(w[i], w[j]), .gt); + for (i+1..w.len) |j| try eq(strnatcmp(w[i], w[j]), .lt); + } +} |