Implement directory refresh

This complicated the scan code more than I had anticipated and has a
few inherent bugs with respect to calculating shared hardlink sizes.

Still, the merge approach avoids creating a full copy of the subtree, so
that's another memory usage related win compared to the C version.
On the other hand, it does leak memory if nodes can't be reused.

Not quite as well tested as I should have, so I'm sure there's bugs.
This commit is contained in:
Yorhel 2021-07-13 13:33:38 +02:00
parent ff3e3bccc6
commit 6c2ab5001c
7 changed files with 370 additions and 88 deletions

View file

@ -29,7 +29,6 @@ backported to the C version, depending on how viable a proper Zig release is.
Missing features: Missing features:
- Help window - Help window
- Directory refresh
- File deletion - File deletion
- Opening a shell - Opening a shell
@ -43,6 +42,7 @@ Already implemented:
- Using separate structs for directory, file and hard link nodes, each storing - Using separate structs for directory, file and hard link nodes, each storing
only the information necessary for that particular type of node. only the information necessary for that particular type of node.
- Using an arena allocator and getting rid of data alignment. - Using an arena allocator and getting rid of data alignment.
- Refreshing a directory no longer creates a full copy of the (sub)tree.
- Improved performance of hard link counting (fixing - Improved performance of hard link counting (fixing
[#121](https://code.blicky.net/yorhel/ncdu/issues/121)). [#121](https://code.blicky.net/yorhel/ncdu/issues/121)).
- Add support for separate counting hard links that are shared with other - Add support for separate counting hard links that are shared with other
@ -70,12 +70,14 @@ Aside from this implementation being unfinished:
the in-memory directory tree. the in-memory directory tree.
- Not nearly as well tested. - Not nearly as well tested.
- Directories that could not be opened are displayed as files. - Directories that could not be opened are displayed as files.
- The disk usage of directory entries themselves is not updated during refresh.
### Minor UI differences ### Minor UI differences
Not sure if these count as improvements or regressions, so I'll just list these Not sure if these count as improvements or regressions, so I'll just list these
separately: separately:
- The browsing UI is not visible during refresh.
- Some columns in the file browser are hidden automatically if the terminal is - Some columns in the file browser are hidden automatically if the terminal is
not wide enough to display them. not wide enough to display them.
- Browsing keys other than changing the currently selected item don't work - Browsing keys other than changing the currently selected item don't work

View file

@ -411,25 +411,25 @@ directory, as some inodes may still be accessible from hard links outside it.
=head1 BUGS =head1 BUGS
Directory hard links are not supported. They will not be detected as being hard Directory hard links and firmlinks (MacOS) are not supported. They will not be
links, and will thus be scanned and counted multiple times. 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 Some minor glitches may appear when displaying filenames that contain multibyte
or multicolumn characters. 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 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 directory larger than 8 EiB minus one byte, ncdu will clip its size to 8 EiB
minus one byte. When deleting items in a directory with a clipped size, the minus one byte. When deleting or refreshing items in a directory with a clipped
resulting sizes will be incorrect. 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
Item counts are stored in a signed 32-bit integer without overflow detection. to have more than 4 billion items in a directory.
If you have a directory with more than 2 billion files, quite literally
anything can happen.
On macOS 10.15 and later, running ncdu on the root directory without
`--exclude-firmlinks` may cause directories to be scanned and counted multiple
times. Firmlink cycles are currently (1.15.1) not detected, so it may also
cause ncdu to get stuck in an infinite loop and eventually run out of memory.
Please report any other bugs you may find at the bug tracker, which can be 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 found on the web site at https://dev.yorhel.nl/ncdu

View file

@ -1,6 +1,7 @@
const std = @import("std"); const std = @import("std");
const main = @import("main.zig"); const main = @import("main.zig");
const model = @import("model.zig"); const model = @import("model.zig");
const scan = @import("scan.zig");
const ui = @import("ui.zig"); const ui = @import("ui.zig");
const c = @cImport(@cInclude("time.h")); const c = @cImport(@cInclude("time.h"));
usingnamespace @import("util.zig"); usingnamespace @import("util.zig");
@ -664,6 +665,14 @@ pub fn keyInput(ch: i32) void {
switch (ch) { switch (ch) {
'q' => if (main.config.confirm_quit) { state = .quit; } else ui.quit(), 'q' => if (main.config.confirm_quit) { state = .quit; } else ui.quit(),
'i' => info.set(dir_items.items[cursor_idx], .info), 'i' => info.set(dir_items.items[cursor_idx], .info),
'r' => {
if (main.config.imported) {
// TODO: Display message
} else {
main.state = .refresh;
scan.setupRefresh(dir_parents.copy());
}
},
// Sort & filter settings // Sort & filter settings
'n' => sortToggle(.name, .asc), 'n' => sortToggle(.name, .asc),

View file

@ -30,6 +30,8 @@ var allocator_state = std.mem.Allocator{
.resizeFn = wrapResize, .resizeFn = wrapResize,
}; };
pub const allocator = &allocator_state; 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 config = struct {
pub const SortCol = enum { name, blocks, size, items, mtime }; pub const SortCol = enum { name, blocks, size, items, mtime };
@ -65,7 +67,7 @@ pub const config = struct {
pub var confirm_quit: bool = false; pub var confirm_quit: bool = false;
}; };
pub var state: enum { scan, browse } = .browse; pub var state: enum { scan, browse, refresh } = .scan;
// Simple generic argument parser, supports getopt_long() style arguments. // 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.: // T can be any type that has a 'fn next(T) ?[:0]const u8' method, e.g.:
@ -257,7 +259,6 @@ pub fn main() void {
event_delay_timer = std.time.Timer.start() catch unreachable; event_delay_timer = std.time.Timer.start() catch unreachable;
defer ui.deinit(); defer ui.deinit();
state = .scan;
var out_file = if (export_file) |f| ( var out_file = if (export_file) |f| (
if (std.mem.eql(u8, f, "-")) std.io.getStdOut() if (std.mem.eql(u8, f, "-")) std.io.getStdOut()
@ -265,9 +266,11 @@ pub fn main() void {
catch |e| ui.die("Error opening export file: {s}.\n", .{ui.errorString(e)}) catch |e| ui.die("Error opening export file: {s}.\n", .{ui.errorString(e)})
) else null; ) else null;
if (import_file) |f| scan.importRoot(f, out_file) if (import_file) |f| {
else scan.scanRoot(scan_dir orelse ".", out_file) scan.importRoot(f, out_file);
catch |e| ui.die("Error opening directory: {s}.\n", .{ui.errorString(e)}); 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 (out_file != null) return;
config.scan_ui = .full; // in case we're refreshing from the UI, always in full mode. config.scan_ui = .full; // in case we're refreshing from the UI, always in full mode.
@ -275,7 +278,14 @@ pub fn main() void {
state = .browse; state = .browse;
browser.loadDir(); browser.loadDir();
while (true) handleEvent(true, false); while (true) {
if (state == .refresh) {
scan.scan();
state = .browse;
browser.loadDir();
} else
handleEvent(true, false);
}
} }
var event_delay_timer: std.time.Timer = undefined; var event_delay_timer: std.time.Timer = undefined;
@ -286,7 +296,7 @@ pub fn handleEvent(block: bool, force_draw: bool) void {
if (block or force_draw or event_delay_timer.read() > config.update_delay) { if (block or force_draw or event_delay_timer.read() > config.update_delay) {
if (ui.inited) _ = ui.c.erase(); if (ui.inited) _ = ui.c.erase();
switch (state) { switch (state) {
.scan => scan.draw(), .scan, .refresh => scan.draw(),
.browse => browser.draw(), .browse => browser.draw(),
} }
if (ui.inited) _ = ui.c.refresh(); if (ui.inited) _ = ui.c.refresh();
@ -303,7 +313,7 @@ pub fn handleEvent(block: bool, force_draw: bool) void {
if (ch == 0) return; if (ch == 0) return;
if (ch == -1) return handleEvent(firstblock, true); if (ch == -1) return handleEvent(firstblock, true);
switch (state) { switch (state) {
.scan => scan.keyInput(ch), .scan, .refresh => scan.keyInput(ch),
.browse => browser.keyInput(ch), .browse => browser.keyInput(ch),
} }
firstblock = false; firstblock = false;

View file

@ -13,6 +13,9 @@ var allocator = std.heap.ArenaAllocator.init(std.heap.page_allocator);
pub const EType = packed enum(u2) { dir, link, file }; pub const EType = packed enum(u2) { dir, link, file };
// Type for the Entry.blocks field. Smaller than a u64 to make room for flags.
pub const Blocks = u60;
// Memory layout: // Memory layout:
// Dir + name (+ alignment + Ext) // Dir + name (+ alignment + Ext)
// or: Link + name (+ alignment + Ext) // or: Link + name (+ alignment + Ext)
@ -31,7 +34,8 @@ pub const EType = packed enum(u2) { dir, link, file };
pub const Entry = packed struct { pub const Entry = packed struct {
etype: EType, etype: EType,
isext: bool, isext: bool,
blocks: u61, // 512-byte blocks counted: bool, // Whether or not this entry's size has been counted in its parents
blocks: Blocks, // 512-byte blocks
size: u64, size: u64,
next: ?*Entry, next: ?*Entry,
@ -107,7 +111,10 @@ pub const Entry = packed struct {
} }
} }
fn addStats(self: *Entry, parents: *const Parents) void { pub fn addStats(self: *Entry, parents: *const Parents) void {
if (self.counted) return;
self.counted = true;
const dev = parents.top().dev; const dev = parents.top().dev;
// Set if this is the first time we've found this hardlink in the bottom-most directory of the given dev. // Set if this is the first time we've found this hardlink in the bottom-most directory of the given dev.
// Means we should count it for other-dev parent dirs, too. // Means we should count it for other-dev parent dirs, too.
@ -154,6 +161,64 @@ pub const Entry = packed struct {
} }
} }
// Opposite of addStats(), but has some limitations:
// - shared_* parent sizes are not updated; there's just no way to
// correctly adjust these without a full rescan of the tree
// - If addStats() saturated adding sizes, then the sizes after delStats()
// will be incorrect.
// - mtime of parents is not adjusted (but that's a feature, possibly?)
//
// The first point can be relaxed so that a delStats() followed by
// addStats() with the same data will not result in broken shared_*
// numbers, but for now the easy (and more efficient) approach is to try
// and avoid using delStats() when not strictly necessary.
//
// This function assumes that, for directories, all sub-entries have
// already been un-counted.
pub fn delStats(self: *Entry, parents: *const Parents) void {
if (!self.counted) return;
self.counted = false;
const dev = parents.top().dev;
var del_hl = false;
var it = parents.iter();
while(it.next()) |p| {
var del_total = false;
p.items = saturateSub(p.items, 1);
if (self.etype == .link and dev != p.dev) {
del_total = del_hl;
} else if (self.link()) |l| {
const n = devices.HardlinkNode{ .ino = l.ino, .dir = p };
var dp = devices.list.items[dev].hardlinks.getEntry(n);
if (dp) |d| {
d.value_ptr.* -= 1;
del_total = d.value_ptr.* == 0;
del_hl = del_total;
if (del_total)
_ = devices.list.items[dev].hardlinks.remove(n);
}
} else
del_total = true;
if(del_total) {
p.entry.size = saturateSub(p.entry.size, self.size);
p.entry.blocks = saturateSub(p.entry.blocks, self.blocks);
}
}
}
pub fn delStatsRec(self: *Entry, parents: *Parents) void {
if (self.dir()) |d| {
parents.push(d);
var it = d.sub;
while (it) |e| : (it = e.next)
e.delStatsRec(parents);
parents.pop();
}
self.delStats(parents);
}
// Insert this entry into the tree at the given directory, updating parent sizes and item counts. // Insert this entry into the tree at the given directory, updating parent sizes and item counts.
pub fn insert(self: *Entry, parents: *const Parents) void { pub fn insert(self: *Entry, parents: *const Parents) void {
self.next = parents.top().sub; self.next = parents.top().sub;
@ -220,6 +285,14 @@ pub const File = packed struct {
_pad: u3, _pad: u3,
name: u8, name: u8,
pub fn resetFlags(f: *@This()) void {
f.err = false;
f.excluded = false;
f.other_fs = false;
f.kernfs = false;
f.notreg = false;
}
}; };
pub const Ext = packed struct { pub const Ext = packed struct {

View file

@ -9,7 +9,7 @@ const c_fnmatch = @cImport(@cInclude("fnmatch.h"));
// Concise stat struct for fields we're interested in, with the types used by the model. // Concise stat struct for fields we're interested in, with the types used by the model.
const Stat = struct { const Stat = struct {
blocks: u61 = 0, blocks: model.Blocks = 0,
size: u64 = 0, size: u64 = 0,
dev: u64 = 0, dev: u64 = 0,
ino: u64 = 0, ino: u64 = 0,
@ -100,6 +100,155 @@ fn writeJsonString(wr: anytype, s: []const u8) !void {
try wr.writeByte('"'); try wr.writeByte('"');
} }
// A ScanDir represents an in-memory directory listing (i.e. model.Dir) where
// entries read from disk can be merged into, without doing an O(1) lookup for
// each entry.
const ScanDir = struct {
// Lookup table for name -> *entry.
// null is never stored in the table, but instead used pass a name string
// as out-of-band argument for lookups.
entries: Map,
const Map = std.HashMap(?*model.Entry, void, HashContext, 80);
const HashContext = struct {
cmp: []const u8 = "",
pub fn hash(self: @This(), v: ?*model.Entry) u64 {
return std.hash.Wyhash.hash(0, if (v) |e| @as([]const u8, e.name()) else self.cmp);
}
pub fn eql(self: @This(), ap: ?*model.Entry, bp: ?*model.Entry) bool {
if (ap == bp) return true;
const a = if (ap) |e| @as([]const u8, e.name()) else self.cmp;
const b = if (bp) |e| @as([]const u8, e.name()) else self.cmp;
return std.mem.eql(u8, a, b);
}
};
const Self = @This();
fn init(parents: *const model.Parents) Self {
var self = Self{ .entries = Map.initContext(main.allocator, HashContext{}) };
var count: Map.Size = 0;
var it = parents.top().sub;
while (it) |e| : (it = e.next) count += 1;
self.entries.ensureCapacity(count) catch unreachable;
it = parents.top().sub;
while (it) |e| : (it = e.next)
self.entries.putAssumeCapacity(e, @as(void,undefined));
return self;
}
fn addSpecial(self: *Self, parents: *model.Parents, name: []const u8, t: Context.Special) void {
var e = blk: {
if (self.entries.getEntryAdapted(@as(?*model.Entry,null), HashContext{ .cmp = name })) |entry| {
// XXX: If the type doesn't match, we could always do an
// 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) {
e.delStats(parents);
e.size = 0;
e.blocks = 0;
e.addStats(parents);
}
e.file().?.resetFlags();
_ = self.entries.removeAdapted(@as(?*model.Entry,null), HashContext{ .cmp = name });
break :blk e;
} else e.delStatsRec(parents);
}
var e = model.Entry.create(.file, false, name);
e.next = parents.top().sub;
parents.top().sub = e;
e.addStats(parents);
break :blk e;
};
var f = e.file().?;
switch (t) {
.err => e.set_err(parents),
.other_fs => f.other_fs = true,
.kernfs => f.kernfs = true,
.excluded => f.excluded = true,
}
}
fn addStat(self: *Self, parents: *model.Parents, name: []const u8, stat: *Stat) *model.Entry {
const etype = if (stat.dir) model.EType.dir
else if (stat.hlinkc) model.EType.link
else model.EType.file;
var e = blk: {
if (self.entries.getEntryAdapted(@as(?*model.Entry,null), HashContext{ .cmp = name })) |entry| {
// 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 simple merge.
const samedev = if (e.dir()) |d| d.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) {
_ = self.entries.removeAdapted(@as(?*model.Entry,null), HashContext{ .cmp = name });
break :blk e;
} else e.delStatsRec(parents);
}
var e = model.Entry.create(etype, main.config.extended, name);
e.next = parents.top().sub;
parents.top().sub = e;
break :blk e;
};
// Ignore the new size/blocks field for directories, as we don't know
// what the original values were without calling delStats() on the
// 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.blocks != stat.blocks or e.size != stat.size)) {
e.delStats(parents);
e.blocks = stat.blocks;
e.size = stat.size;
}
if (e.dir()) |d| d.dev = model.devices.getId(stat.dev);
if (e.file()) |f| {
f.resetFlags();
f.notreg = !stat.dir and !stat.reg;
}
if (e.link()) |l| {
l.ino = stat.ino;
// BUG: shared sizes will be very incorrect if this is different
// from a previous scan. May want to warn the user about that.
l.nlink = stat.nlink;
}
if (e.ext()) |ext| {
if (ext.mtime > stat.ext.mtime)
stat.ext.mtime = ext.mtime;
ext.* = stat.ext;
}
// Assumption: l.link == 0 only happens on import, not refresh.
if (if (e.link()) |l| l.nlink == 0 else false)
model.link_count.add(parents.top().dev, e.link().?.ino)
else
e.addStats(parents);
return e;
}
fn final(self: *Self, parents: *model.Parents) void {
if (self.entries.count() == 0) // optimization for the common case
return;
var it = &parents.top().sub;
while (it.*) |e| {
if (self.entries.contains(e)) {
e.delStatsRec(parents);
it.* = e.next;
} else
it = &e.next;
}
}
fn deinit(self: *Self) void {
self.entries.deinit();
}
};
// Scan/import context. Entries are added in roughly the following way: // Scan/import context. Entries are added in roughly the following way:
// //
// ctx.pushPath(name) // ctx.pushPath(name)
@ -113,6 +262,7 @@ fn writeJsonString(wr: anytype, s: []const u8) !void {
const Context = struct { const Context = struct {
// When scanning to RAM // When scanning to RAM
parents: ?model.Parents = null, parents: ?model.Parents = null,
parent_entries: std.ArrayList(ScanDir) = std.ArrayList(ScanDir).init(main.allocator),
// When scanning to a file // When scanning to a file
wr: ?*Writer = null, wr: ?*Writer = null,
@ -125,6 +275,7 @@ const Context = struct {
name: [:0]const u8 = undefined, name: [:0]const u8 = undefined,
last_error: ?[:0]u8 = null, last_error: ?[:0]u8 = null,
fatal_error: ?anyerror = null,
stat: Stat = undefined, stat: Stat = undefined,
@ -135,7 +286,7 @@ const Context = struct {
ui.die("Error writing to file: {s}.\n", .{ ui.errorString(e) }); ui.die("Error writing to file: {s}.\n", .{ ui.errorString(e) });
} }
fn initFile(out: std.fs.File) Self { fn initFile(out: std.fs.File) *Self {
var buf = main.allocator.create(Writer) catch unreachable; var buf = main.allocator.create(Writer) catch unreachable;
errdefer main.allocator.destroy(buf); errdefer main.allocator.destroy(buf);
buf.* = std.io.bufferedWriter(out.writer()); buf.* = std.io.bufferedWriter(out.writer());
@ -143,11 +294,17 @@ const Context = struct {
wr.writeAll("[1,2,{\"progname\":\"ncdu\",\"progver\":\"" ++ main.program_version ++ "\",\"timestamp\":") catch |e| writeErr(e); wr.writeAll("[1,2,{\"progname\":\"ncdu\",\"progver\":\"" ++ main.program_version ++ "\",\"timestamp\":") catch |e| writeErr(e);
wr.print("{d}", .{std.time.timestamp()}) catch |e| writeErr(e); wr.print("{d}", .{std.time.timestamp()}) catch |e| writeErr(e);
wr.writeByte('}') catch |e| writeErr(e); wr.writeByte('}') catch |e| writeErr(e);
return Self{ .wr = buf };
var self = main.allocator.create(Self) catch unreachable;
self.* = .{ .wr = buf };
return self;
} }
fn initMem() Self { // Ownership of p is passed to the object, it will be deallocated on deinit().
return Self{ .parents = model.Parents{} }; fn initMem(p: model.Parents) *Self {
var self = main.allocator.create(Self) catch unreachable;
self.* = .{ .parents = p };
return self;
} }
fn final(self: *Self) void { fn final(self: *Self) void {
@ -171,11 +328,15 @@ const Context = struct {
} }
fn popPath(self: *Self) void { fn popPath(self: *Self) void {
self.path.items.len = self.path_indices.items[self.path_indices.items.len-1]; self.path.items.len = self.path_indices.pop();
self.path_indices.items.len -= 1;
if (self.stat.dir) { if (self.stat.dir) {
if (self.parents) |*p| if (!p.isRoot()) p.pop(); if (self.parents) |*p| {
var d = self.parent_entries.pop();
d.final(p);
d.deinit();
if (!p.isRoot()) p.pop();
}
if (self.wr) |w| w.writer().writeByte(']') catch |e| writeErr(e); if (self.wr) |w| w.writer().writeByte(']') catch |e| writeErr(e);
} else } else
self.stat.dir = true; // repeated popPath()s mean we're closing parent dirs. self.stat.dir = true; // repeated popPath()s mean we're closing parent dirs.
@ -218,18 +379,9 @@ const Context = struct {
self.last_error = main.allocator.dupeZ(u8, self.path.items) catch unreachable; self.last_error = main.allocator.dupeZ(u8, self.path.items) catch unreachable;
} }
if (self.parents) |*p| { if (self.parents) |*p|
var e = model.Entry.create(.file, false, self.name); self.parent_entries.items[self.parent_entries.items.len-1].addSpecial(p, self.name, t)
e.insert(p); else if (self.wr) |wr|
var f = e.file().?;
switch (t) {
.err => e.set_err(p),
.other_fs => f.other_fs = true,
.kernfs => f.kernfs = true,
.excluded => f.excluded = true,
}
} else if (self.wr) |wr|
self.writeSpecial(wr.writer(), t) catch |e| writeErr(e); self.writeSpecial(wr.writer(), t) catch |e| writeErr(e);
self.items_seen += 1; self.items_seen += 1;
@ -254,25 +406,21 @@ const Context = struct {
// Insert current path as a counted file/dir/hardlink, with information from self.stat // Insert current path as a counted file/dir/hardlink, with information from self.stat
fn addStat(self: *Self, dir_dev: u64) void { fn addStat(self: *Self, dir_dev: u64) void {
if (self.parents) |*p| { if (self.parents) |*p| {
const etype = if (self.stat.dir) model.EType.dir var e = if (self.items_seen == 0) blk: {
else if (self.stat.hlinkc) model.EType.link // Root entry
else model.EType.file; var e = model.Entry.create(.dir, main.config.extended, self.name);
var e = model.Entry.create(etype, main.config.extended, self.name); e.blocks = self.stat.blocks;
e.blocks = self.stat.blocks; e.size = self.stat.size;
e.size = self.stat.size; if (e.ext()) |ext| ext.* = self.stat.ext;
if (e.dir()) |d| d.dev = model.devices.getId(self.stat.dev); model.root = e.dir().?;
if (e.file()) |f| f.notreg = !self.stat.dir and !self.stat.reg; model.root.dev = model.devices.getId(self.stat.dev);
if (e.link()) |l| { break :blk e;
l.ino = self.stat.ino; } else
l.nlink = self.stat.nlink; self.parent_entries.items[self.parent_entries.items.len-1].addStat(p, self.name, &self.stat);
}
if (e.ext()) |ext| ext.* = self.stat.ext;
if (self.items_seen == 0) if (e.dir()) |d| { // Enter the directory
model.root = e.dir().? if (self.items_seen != 0) p.push(d);
else { self.parent_entries.append(ScanDir.init(p)) catch unreachable;
e.insert(p);
if (e.dir()) |d| p.push(d); // Enter the directory
} }
} else if (self.wr) |wr| } else if (self.wr) |wr|
@ -287,11 +435,13 @@ const Context = struct {
if (self.wr) |p| main.allocator.destroy(p); if (self.wr) |p| main.allocator.destroy(p);
self.path.deinit(); self.path.deinit();
self.path_indices.deinit(); self.path_indices.deinit();
self.parent_entries.deinit();
main.allocator.destroy(self);
} }
}; };
// Context that is currently being used for scanning. // Context that is currently being used for scanning.
var active_context: ?*Context = null; var active_context: *Context = undefined;
// Read and index entries of the given dir. // Read and index entries of the given dir.
fn scanDir(ctx: *Context, dir: std.fs.Dir, dir_dev: u64) void { fn scanDir(ctx: *Context, dir: std.fs.Dir, dir_dev: u64) void {
@ -378,24 +528,44 @@ fn scanDir(ctx: *Context, dir: std.fs.Dir, dir_dev: u64) void {
} }
pub fn scanRoot(path: []const u8, out: ?std.fs.File) !void { pub fn scanRoot(path: []const u8, out: ?std.fs.File) !void {
var ctx = if (out) |f| Context.initFile(f) else Context.initMem(); active_context = if (out) |f| Context.initFile(f) else Context.initMem(.{});
active_context = &ctx;
defer active_context = null;
defer ctx.deinit();
const full_path = std.fs.realpathAlloc(main.allocator, path) catch null; const full_path = std.fs.realpathAlloc(main.allocator, path) catch null;
defer if (full_path) |p| main.allocator.free(p); defer if (full_path) |p| main.allocator.free(p);
ctx.pushPath(full_path orelse path); active_context.pushPath(full_path orelse path);
ctx.stat = try Stat.read(std.fs.cwd(), ctx.pathZ(), true); active_context.stat = try Stat.read(std.fs.cwd(), active_context.pathZ(), true);
if (!ctx.stat.dir) return error.NotDir; if (!active_context.stat.dir) return error.NotDir;
ctx.addStat(0); active_context.addStat(0);
scan();
}
var dir = try std.fs.cwd().openDirZ(ctx.pathZ(), .{ .access_sub_paths = true, .iterate = true }); pub fn setupRefresh(parents: model.Parents) void {
active_context = Context.initMem(parents);
var full_path = std.ArrayList(u8).init(main.allocator);
defer full_path.deinit();
parents.fmtPath(true, &full_path);
active_context.pushPath(full_path.items);
active_context.parent_entries.append(ScanDir.init(&parents)) catch unreachable;
active_context.stat.dir = true;
active_context.stat.dev = model.devices.getDev(parents.top().dev);
active_context.items_seen = 1; // The "root" item has already been added.
}
// 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| {
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;
};
defer dir.close(); defer dir.close();
scanDir(&ctx, dir, ctx.stat.dev); scanDir(active_context, dir, active_context.stat.dev);
ctx.popPath(); active_context.popPath();
ctx.final(); active_context.final();
} }
// Using a custom recursive descent JSON parser here. std.json is great, but // Using a custom recursive descent JSON parser here. std.json is great, but
@ -409,7 +579,7 @@ pub fn scanRoot(path: []const u8, out: ?std.fs.File) !void {
// worth factoring out the JSON parts into a separate abstraction for which // worth factoring out the JSON parts into a separate abstraction for which
// tests can be written. // tests can be written.
const Import = struct { const Import = struct {
ctx: Context, ctx: *Context,
rd: std.fs.File, rd: std.fs.File,
rdoff: usize = 0, rdoff: usize = 0,
@ -611,7 +781,7 @@ const Import = struct {
}, },
'd' => { 'd' => {
if (eq(u8, key, "dsize")) { if (eq(u8, key, "dsize")) {
self.ctx.stat.blocks = @intCast(u61, self.uint(u64)>>9); self.ctx.stat.blocks = @intCast(model.Blocks, self.uint(u64)>>9);
return; return;
} }
if (eq(u8, key, "dev")) { if (eq(u8, key, "dev")) {
@ -794,12 +964,8 @@ pub fn importRoot(path: [:0]const u8, out: ?std.fs.File) void {
catch |e| ui.die("Error reading file: {s}.\n", .{ui.errorString(e)}); catch |e| ui.die("Error reading file: {s}.\n", .{ui.errorString(e)});
defer fd.close(); defer fd.close();
var imp = Import{ active_context = if (out) |f| Context.initFile(f) else Context.initMem(.{});
.ctx = if (out) |f| Context.initFile(f) else Context.initMem(), var imp = Import{ .ctx = active_context, .rd = fd };
.rd = fd,
};
active_context = &imp.ctx;
defer active_context = null;
defer imp.ctx.deinit(); defer imp.ctx.deinit();
imp.root(); imp.root();
imp.ctx.final(); imp.ctx.final();
@ -808,9 +974,26 @@ pub fn importRoot(path: [:0]const u8, out: ?std.fs.File) void {
var animation_pos: u32 = 0; var animation_pos: u32 = 0;
var need_confirm_quit = false; var need_confirm_quit = false;
fn drawError(err: anyerror) void {
const width = saturateSub(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)));
box.move(3, 2);
ui.addstr("Error: ");
ui.addstr(ui.shorten(ui.errorString(err), saturateSub(width, 6)));
box.move(5, saturateSub(width, 27));
ui.addstr("Press any key to continue");
}
fn drawBox() void { fn drawBox() void {
ui.init(); ui.init();
const ctx = active_context.?; const ctx = active_context;
if (ctx.fatal_error) |err| return drawError(err);
const width = saturateSub(ui.cols, 5); const width = saturateSub(ui.cols, 5);
const box = ui.Box.create(10, width, "Scanning..."); const box = ui.Box.create(10, width, "Scanning...");
box.move(2, 2); box.move(2, 2);
@ -878,14 +1061,14 @@ pub fn draw() void {
.line => { .line => {
var buf: [256]u8 = undefined; var buf: [256]u8 = undefined;
var line: []const u8 = undefined; var line: []const u8 = undefined;
if (active_context.?.parents == null) { if (active_context.parents == null) {
line = std.fmt.bufPrint(&buf, "\x1b7\x1b[J{s: <63} {d:>9} files\x1b8", line = std.fmt.bufPrint(&buf, "\x1b7\x1b[J{s: <63} {d:>9} files\x1b8",
.{ ui.shorten(active_context.?.pathZ(), 63), active_context.?.items_seen } .{ ui.shorten(active_context.pathZ(), 63), active_context.items_seen }
) catch return; ) catch return;
} else { } else {
const r = ui.FmtSize.fmt(blocksToSize(model.root.entry.blocks)); const r = ui.FmtSize.fmt(blocksToSize(model.root.entry.blocks));
line = std.fmt.bufPrint(&buf, "\x1b7\x1b[J{s: <51} {d:>9} files / {s}{s}\x1b8", 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 } .{ ui.shorten(active_context.pathZ(), 51), active_context.items_seen, r.num(), r.unit }
) catch return; ) catch return;
} }
_ = std.io.getStdErr().write(line) catch {}; _ = std.io.getStdErr().write(line) catch {};
@ -895,6 +1078,11 @@ pub fn draw() void {
} }
pub fn keyInput(ch: i32) void { pub fn keyInput(ch: i32) void {
if (active_context.fatal_error != null) {
if (main.state == .scan) ui.quit()
else main.state = .browse;
return;
}
if (need_confirm_quit) { if (need_confirm_quit) {
switch (ch) { switch (ch) {
'y', 'Y' => if (need_confirm_quit) ui.quit(), 'y', 'Y' => if (need_confirm_quit) ui.quit(),

View file

@ -50,7 +50,7 @@ pub fn oom() void {
// Lazy strerror() for Zig file I/O, not complete. // Lazy strerror() for Zig file I/O, not complete.
// (Would be nicer if Zig just exposed errno so I could call strerror() directly) // (Would be nicer if Zig just exposed errno so I could call strerror() directly)
pub fn errorString(e: anyerror) []const u8 { pub fn errorString(e: anyerror) [:0]const u8 {
return switch (e) { return switch (e) {
error.DiskQuota => "Disk quota exceeded", error.DiskQuota => "Disk quota exceeded",
error.FileTooBig => "File too big", error.FileTooBig => "File too big",