diff --git a/README.md b/README.md
index 5401904f0be9c7457fb6d6317d4471b643f8a972..678f195350667a479757bdb95a62c45cc47aa614 100644
--- a/README.md
+++ b/README.md
@@ -29,7 +29,6 @@ backported to the C version, depending on how viable a proper Zig release is.
 Missing features:
 
 - Help window
-- Directory refresh
 - File deletion
 - Opening a shell
 
@@ -43,6 +42,7 @@ Already implemented:
   - Using separate structs for directory, file and hard link nodes, each storing
     only the information necessary for that particular type of node.
   - 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
   [#121](https://code.blicky.net/yorhel/ncdu/issues/121)).
 - 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.
 - Not nearly as well tested.
 - 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
 
 Not sure if these count as improvements or regressions, so I'll just list these
 separately:
 
+- The browsing UI is not visible during refresh.
 - Some columns in the file browser are hidden automatically if the terminal is
   not wide enough to display them.
 - Browsing keys other than changing the currently selected item don't work
diff --git a/doc/ncdu.pod b/doc/ncdu.pod
index 7fc04f5d487b91d1716ab420bef1abd6ea0111f2..c61dcdcdfa416d411e32564657fc542da4ddb0cd 100644
--- a/doc/ncdu.pod
+++ b/doc/ncdu.pod
@@ -411,25 +411,25 @@ directory, as some inodes may still be accessible from hard links outside it.
 
 =head1 BUGS
 
-Directory hard links are not supported. They will not be detected as being hard
-links, and will thus be scanned and counted multiple times.
+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 items in a directory with a clipped size, the
-resulting sizes will be incorrect.
-
-Item counts are stored in a signed 32-bit integer without overflow detection.
-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.
+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
diff --git a/src/browser.zig b/src/browser.zig
index 2cbcafb1b4367503b4f24425cd818899d01c820a..91c4cc63fad15dd8bfb1f906f271f48a8328f16e 100644
--- a/src/browser.zig
+++ b/src/browser.zig
@@ -1,6 +1,7 @@
 const std = @import("std");
 const main = @import("main.zig");
 const model = @import("model.zig");
+const scan = @import("scan.zig");
 const ui = @import("ui.zig");
 const c = @cImport(@cInclude("time.h"));
 usingnamespace @import("util.zig");
@@ -664,6 +665,14 @@ pub fn keyInput(ch: i32) void {
     switch (ch) {
         'q' => if (main.config.confirm_quit) { state = .quit; } else ui.quit(),
         '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
         'n' => sortToggle(.name, .asc),
diff --git a/src/main.zig b/src/main.zig
index 1063405aef98faae00a7c15b92b63da708c59320..71afcd1c5c227a37d6b22209ecbc30ca2547673d 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -30,6 +30,8 @@ var allocator_state = std.mem.Allocator{
     .resizeFn = wrapResize,
 };
 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 };
@@ -65,7 +67,7 @@ pub const config = struct {
     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.
 // 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;
     defer ui.deinit();
-    state = .scan;
 
     var out_file = if (export_file) |f| (
         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)})
     ) else null;
 
-    if (import_file) |f| scan.importRoot(f, out_file)
-    else scan.scanRoot(scan_dir orelse ".", out_file)
-         catch |e| ui.die("Error opening directory: {s}.\n", .{ui.errorString(e)});
+    if (import_file) |f| {
+        scan.importRoot(f, out_file);
+        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;
 
     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;
     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;
@@ -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 (ui.inited) _ = ui.c.erase();
         switch (state) {
-            .scan => scan.draw(),
+            .scan, .refresh => scan.draw(),
             .browse => browser.draw(),
         }
         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 == -1) return handleEvent(firstblock, true);
         switch (state) {
-            .scan => scan.keyInput(ch),
+            .scan, .refresh => scan.keyInput(ch),
             .browse => browser.keyInput(ch),
         }
         firstblock = false;
diff --git a/src/model.zig b/src/model.zig
index 47c2636208499073a20ec97c0655535bd0ae2559..ccc7657d36c1d7f138dd4b926072466c3f7f3681 100644
--- a/src/model.zig
+++ b/src/model.zig
@@ -13,6 +13,9 @@ var allocator = std.heap.ArenaAllocator.init(std.heap.page_allocator);
 
 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:
 //      Dir + 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 {
     etype: EType,
     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,
     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;
         // 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.
@@ -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.
     pub fn insert(self: *Entry, parents: *const Parents) void {
         self.next = parents.top().sub;
@@ -220,6 +285,14 @@ pub const File = packed struct {
     _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;
+    }
 };
 
 pub const Ext = packed struct {
diff --git a/src/scan.zig b/src/scan.zig
index f653737a0ed6930bcc513f2beb01ee1f75e2d987..4ac7c5f2cdf90fe885c0964e618d577c36babc88 100644
--- a/src/scan.zig
+++ b/src/scan.zig
@@ -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.
 const Stat = struct {
-    blocks: u61 = 0,
+    blocks: model.Blocks = 0,
     size: u64 = 0,
     dev: u64 = 0,
     ino: u64 = 0,
@@ -100,6 +100,155 @@ fn writeJsonString(wr: anytype, s: []const u8) !void {
     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:
 //
 //   ctx.pushPath(name)
@@ -113,6 +262,7 @@ fn writeJsonString(wr: anytype, s: []const u8) !void {
 const Context = struct {
     // When scanning to RAM
     parents: ?model.Parents = null,
+    parent_entries: std.ArrayList(ScanDir) = std.ArrayList(ScanDir).init(main.allocator),
     // When scanning to a file
     wr: ?*Writer = null,
 
@@ -125,6 +275,7 @@ const Context = struct {
     name: [:0]const u8 = undefined,
 
     last_error: ?[:0]u8 = null,
+    fatal_error: ?anyerror = null,
 
     stat: Stat = undefined,
 
@@ -135,7 +286,7 @@ const Context = struct {
         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;
         errdefer main.allocator.destroy(buf);
         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.print("{d}", .{std.time.timestamp()}) 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 {
-        return Self{ .parents = model.Parents{} };
+    // Ownership of p is passed to the object, it will be deallocated on deinit().
+    fn initMem(p: model.Parents) *Self {
+        var self = main.allocator.create(Self) catch unreachable;
+        self.* = .{ .parents = p };
+        return self;
     }
 
     fn final(self: *Self) void {
@@ -171,11 +328,15 @@ const Context = struct {
     }
 
     fn popPath(self: *Self) void {
-        self.path.items.len = self.path_indices.items[self.path_indices.items.len-1];
-        self.path_indices.items.len -= 1;
+        self.path.items.len = self.path_indices.pop();
 
         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);
         } else
             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;
         }
 
-        if (self.parents) |*p| {
-            var e = model.Entry.create(.file, false, self.name);
-            e.insert(p);
-            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|
+        if (self.parents) |*p|
+            self.parent_entries.items[self.parent_entries.items.len-1].addSpecial(p, self.name, t)
+        else if (self.wr) |wr|
             self.writeSpecial(wr.writer(), t) catch |e| writeErr(e);
 
         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
     fn addStat(self: *Self, dir_dev: u64) void {
         if (self.parents) |*p| {
-            const etype = if (self.stat.dir) model.EType.dir
-                          else if (self.stat.hlinkc) model.EType.link
-                          else model.EType.file;
-            var e = model.Entry.create(etype, main.config.extended, self.name);
-            e.blocks = self.stat.blocks;
-            e.size = self.stat.size;
-            if (e.dir()) |d| d.dev = model.devices.getId(self.stat.dev);
-            if (e.file()) |f| f.notreg = !self.stat.dir and !self.stat.reg;
-            if (e.link()) |l| {
-                l.ino = self.stat.ino;
-                l.nlink = self.stat.nlink;
-            }
-            if (e.ext()) |ext| ext.* = self.stat.ext;
-
-            if (self.items_seen == 0)
-                model.root = e.dir().?
-            else {
-                e.insert(p);
-                if (e.dir()) |d| p.push(d); // Enter the directory
+            var e = if (self.items_seen == 0) blk: {
+                // Root entry
+                var e = model.Entry.create(.dir, main.config.extended, self.name);
+                e.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);
+                break :blk e;
+            } else
+                self.parent_entries.items[self.parent_entries.items.len-1].addStat(p, self.name, &self.stat);
+
+            if (e.dir()) |d| { // Enter the directory
+                if (self.items_seen != 0) p.push(d);
+                self.parent_entries.append(ScanDir.init(p)) catch unreachable;
             }
 
         } else if (self.wr) |wr|
@@ -287,11 +435,13 @@ const Context = struct {
         if (self.wr) |p| main.allocator.destroy(p);
         self.path.deinit();
         self.path_indices.deinit();
+        self.parent_entries.deinit();
+        main.allocator.destroy(self);
     }
 };
 
 // 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.
 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 {
-    var ctx = if (out) |f| Context.initFile(f) else Context.initMem();
-    active_context = &ctx;
-    defer active_context = null;
-    defer ctx.deinit();
+    active_context = if (out) |f| Context.initFile(f) else Context.initMem(.{});
 
     const full_path = std.fs.realpathAlloc(main.allocator, path) catch null;
     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);
-    if (!ctx.stat.dir) return error.NotDir;
-    ctx.addStat(0);
+    active_context.stat = try Stat.read(std.fs.cwd(), active_context.pathZ(), true);
+    if (!active_context.stat.dir) return error.NotDir;
+    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();
-    scanDir(&ctx, dir, ctx.stat.dev);
-    ctx.popPath();
-    ctx.final();
+    scanDir(active_context, dir, active_context.stat.dev);
+    active_context.popPath();
+    active_context.final();
 }
 
 // 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
 // tests can be written.
 const Import = struct {
-    ctx: Context,
+    ctx: *Context,
 
     rd: std.fs.File,
     rdoff: usize = 0,
@@ -611,7 +781,7 @@ const Import = struct {
             },
             'd' => {
                 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;
                 }
                 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)});
     defer fd.close();
 
-    var imp = Import{
-        .ctx = if (out) |f| Context.initFile(f) else Context.initMem(),
-        .rd = fd,
-    };
-    active_context = &imp.ctx;
-    defer active_context = null;
+    active_context = if (out) |f| Context.initFile(f) else Context.initMem(.{});
+    var imp = Import{ .ctx = active_context, .rd = fd };
     defer imp.ctx.deinit();
     imp.root();
     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 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 {
     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 box = ui.Box.create(10, width, "Scanning...");
     box.move(2, 2);
@@ -878,14 +1061,14 @@ pub fn draw() void {
         .line => {
             var buf: [256]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",
-                    .{ ui.shorten(active_context.?.pathZ(), 63), active_context.?.items_seen }
+                    .{ ui.shorten(active_context.pathZ(), 63), active_context.items_seen }
                 ) catch return;
             } else {
                 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",
-                    .{ 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;
             }
             _ = std.io.getStdErr().write(line) catch {};
@@ -895,6 +1078,11 @@ pub fn draw() 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) {
         switch (ch) {
             'y', 'Y' => if (need_confirm_quit) ui.quit(),
diff --git a/src/ui.zig b/src/ui.zig
index 66cad66fd854e5c63e0a6454ef029b95d5b2cf8a..628cefe4310453e70872ba3e7b4789c415138d15 100644
--- a/src/ui.zig
+++ b/src/ui.zig
@@ -50,7 +50,7 @@ pub fn oom() void {
 
 // Lazy strerror() for Zig file I/O, not complete.
 // (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) {
         error.DiskQuota => "Disk quota exceeded",
         error.FileTooBig => "File too big",