diff --git a/.gitignore b/.gitignore
index 53dc7b4bbfb8408219e5a60fb941db8c52a220e3..d8a6230d838a89409f77b57c0fcceef665e9642a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
 zig-cache/
+zig-out/
 ncdu.1
 *~
 *.swp
diff --git a/README.md b/README.md
index a3a64431b747d0cb0c44921dc95eb581cdb85cf2..29a917bdd528fc91140d29c03e3c3f6eeeb54f11 100644
--- a/README.md
+++ b/README.md
@@ -28,7 +28,6 @@ backported to the C version, depending on how viable a proper Zig release is.
 
 Missing features:
 
-- File import
 - Lots of informational UI windows
 - Directory refresh
 - File deletion
diff --git a/src/browser.zig b/src/browser.zig
index 469bc8c7e2f3ab4641f9d81ccd604cd09daadd94..042537e9423385e557eeeea486a32751f9954dbb 100644
--- a/src/browser.zig
+++ b/src/browser.zig
@@ -324,11 +324,13 @@ pub fn draw() !void {
     ui.addch('?');
     ui.style(.hd);
     ui.addstr(" for help");
-    if (main.config.read_only) {
+    if (main.config.imported) {
+        ui.move(0, saturateSub(ui.cols, 10));
+        ui.addstr("[imported]");
+    } else if (main.config.read_only) {
         ui.move(0, saturateSub(ui.cols, 10));
         ui.addstr("[readonly]");
     }
-    // TODO: [imported] indicator
 
     ui.style(.default);
     ui.move(1,0);
@@ -387,7 +389,7 @@ fn sortToggle(col: main.config.SortCol, default_order: main.config.SortOrder) vo
     sortDir();
 }
 
-pub fn key(ch: i32) !void {
+pub fn keyInput(ch: i32) !void {
     if (need_confirm_quit) {
         switch (ch) {
             'y', 'Y' => if (need_confirm_quit) ui.quit(),
diff --git a/src/main.zig b/src/main.zig
index 573b9318329ae08125849dafb8c77caaaf82b24a..e0bdba643dfae28ab943cf7d530ebae7a4de6aa8 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -37,6 +37,7 @@ pub const config = struct {
     pub var sort_dirsfirst: bool = false;
 
     pub var read_only: bool = false;
+    pub var imported: bool = false;
     pub var can_shell: bool = true;
     pub var confirm_quit: bool = false;
 };
@@ -164,7 +165,6 @@ fn readExcludeFile(path: []const u8) !void {
 // TODO: Better error reporting
 pub fn main() !void {
     // Grab thousands_sep from the current C locale.
-    // (We can safely remove this when not linking against libc, it's a somewhat obscure feature)
     _ = c.setlocale(c.LC_ALL, "");
     if (c.localeconv()) |locale| {
         if (locale.*.thousands_sep) |sep| {
@@ -176,8 +176,8 @@ pub fn main() !void {
 
     var args = Args(std.process.ArgIteratorPosix).init(std.process.ArgIteratorPosix.init());
     var scan_dir: ?[]const u8 = null;
-    var import_file: ?[]const u8 = null;
-    var export_file: ?[]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| {
@@ -197,7 +197,9 @@ pub fn main() !void {
         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
@@ -220,24 +222,28 @@ pub fn main() !void {
         ui.die("The --exclude-kernfs tag 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) {
         if (export_file) |f| {
             if (!out_tty or std.mem.eql(u8, f, "-")) config.scan_ui = .none
             else config.scan_ui = .line;
         }
     }
-    config.nc_tty = if (export_file) |f| std.mem.eql(u8, f, "-") else false;
+    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", .{});
+    config.nc_tty = !in_tty or (if (export_file) |f| std.mem.eql(u8, f, "-") else false);
 
     event_delay_timer = try std.time.Timer.start();
     defer ui.deinit();
+    state = .scan;
 
     var out_file = if (export_file) |f| (
         if (std.mem.eql(u8, f, "-")) std.io.getStdOut()
-        else try std.fs.cwd().createFile(f, .{})
+        else try std.fs.cwd().createFileZ(f, .{})
     ) else null;
 
-    state = .scan;
-    try scan.scanRoot(scan_dir orelse ".", out_file);
+    try if (import_file) |f| scan.importRoot(f, out_file)
+        else scan.scanRoot(scan_dir orelse ".", out_file);
     if (out_file != null) return;
 
     config.scan_ui = .full; // in case we're refreshing from the UI, always in full mode.
@@ -274,8 +280,8 @@ pub fn handleEvent(block: bool, force_draw: bool) !void {
         if (ch == 0) return;
         if (ch == -1) return handleEvent(firstblock, true);
         switch (state) {
-            .scan => try scan.key(ch),
-            .browse => try browser.key(ch),
+            .scan => try scan.keyInput(ch),
+            .browse => try browser.keyInput(ch),
         }
         firstblock = false;
     }
diff --git a/src/model.zig b/src/model.zig
index 31474cfc343c4292756e8ce1e7eb3bf25e812338..760300eff495d5d8d8703340f418d49c25fce50a 100644
--- a/src/model.zig
+++ b/src/model.zig
@@ -196,10 +196,10 @@ pub const File = packed struct {
 };
 
 pub const Ext = packed struct {
-    mtime: u64,
-    uid: u32,
-    gid: u32,
-    mode: u16,
+    mtime: u64 = 0,
+    uid: u32 = 0,
+    gid: u32 = 0,
+    mode: u16 = 0,
 };
 
 comptime {
diff --git a/src/scan.zig b/src/scan.zig
index 0af71a78ef5d238d9071ae5563e08ceadcc47a29..b90b8479e9000b0c7be2a13d27950f5ef951d3d8 100644
--- a/src/scan.zig
+++ b/src/scan.zig
@@ -9,15 +9,16 @@ 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,
-    size: u64,
-    dev: u64,
-    ino: u64,
-    nlink: u32,
-    dir: bool,
-    reg: bool,
-    symlink: bool,
-    ext: model.Ext,
+    blocks: u61 = 0,
+    size: u64 = 0,
+    dev: u64 = 0,
+    ino: u64 = 0,
+    nlink: u32 = 0,
+    hlinkc: bool = false,
+    dir: bool = false,
+    reg: bool = true,
+    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);
@@ -35,6 +36,7 @@ const Stat = struct {
             .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),
@@ -103,27 +105,20 @@ fn writeJsonString(wr: anytype, s: []const u8) !void {
 //   ctx.pushPath(name)
 //   ctx.stat = ..;
 //   ctx.addSpecial() or ctx.addStat()
-//   if (is_dir) {
-//      // ctx.enterDir() is implicit in ctx.addStat() for directory entries.
+//   if (ctx.stat.dir) {
 //      // repeat top-level steps for files in dir, recursively.
-//      ctx.leaveDir();
 //   }
 //   ctx.popPath();
 //
-// (Multithreaded scanning note: when scanning to RAM, we can support multiple
-// of these Contexts in parallel, just need to make sure to lock any access to
-// model.* related functions. Parallel scanning to a file will require major
-// changes to the export format or buffering with specially guided scanning to
-// avoid buffering /everything/... neither seems fun)
 const Context = struct {
     // When scanning to RAM
-    parents: ?*model.Parents = null,
+    parents: ?model.Parents = null,
     // When scanning to a file
-    wr: ?std.io.BufferedWriter(4096, std.fs.File.Writer).Writer = null,
+    wr: ?*Writer = null,
 
     path: std.ArrayList(u8) = std.ArrayList(u8).init(main.allocator),
     path_indices: std.ArrayList(usize) = std.ArrayList(usize).init(main.allocator),
-    items_seen: u32 = 1,
+    items_seen: u32 = 0,
 
     // 0-terminated name of the top entry, points into 'path', invalid after popPath().
     // This is a workaround to Zig's directory iterator not returning a [:0]const u8.
@@ -133,8 +128,31 @@ const Context = struct {
 
     stat: Stat = undefined,
 
+    const Writer = std.io.BufferedWriter(4096, std.fs.File.Writer);
     const Self = @This();
 
+    fn initFile(out: std.fs.File) !Self {
+        var buf = try main.allocator.create(Writer);
+        errdefer main.allocator.destroy(buf);
+        buf.* = std.io.bufferedWriter(out.writer());
+        var wr = buf.writer();
+        try wr.writeAll("[1,2,{\"progname\":\"ncdu\",\"progver\":\"" ++ main.program_version ++ "\",\"timestamp\":");
+        try wr.print("{d}", .{std.time.timestamp()});
+        try wr.writeByte('}');
+        return Self{ .wr = buf };
+    }
+
+    fn initMem() Self {
+        return Self{ .parents = model.Parents{} };
+    }
+
+    fn final(self: *Self) !void {
+        if (self.wr) |wr| {
+            try wr.writer().writeByte(']');
+            try wr.flush();
+        }
+    }
+
     // Add the name of the file/dir entry we're currently inspecting
     fn pushPath(self: *Self, name: []const u8) !void {
         try self.path_indices.append(self.path.items.len);
@@ -145,14 +163,17 @@ const Context = struct {
         try self.path.append(0);
         self.name = self.path.items[start..self.path.items.len-1:0];
         self.path.items.len -= 1;
-
-        self.items_seen += 1;
-        self.stat.dir = false; // used by addSpecial(); if we've failed to stat() then don't consider it a dir.
     }
 
     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;
+
+        if (self.stat.dir) {
+            if (self.parents) |*p| if (p.top() != model.root) p.pop();
+            if (self.wr) |w| w.writer().writeByte(']') catch ui.die("Error writing to file.", .{});
+        } else
+            self.stat.dir = true; // repeated popPath()s mean we're closing parent dirs.
     }
 
     fn pathZ(self: *Self) [:0]const u8 {
@@ -162,19 +183,22 @@ const Context = struct {
     // 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.top().entry.set_err(p);
+        if (self.parents) |*p| p.top().entry.set_err(p);
     }
 
     const Special = enum { err, other_fs, kernfs, excluded };
 
     // 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 = try main.allocator.dupeZ(u8, self.path.items);
         }
 
-        if (self.parents) |p| {
+        if (self.parents) |*p| {
             var e = try model.Entry.create(.file, false, self.name);
             e.insert(p) catch unreachable;
             var f = e.file().?;
@@ -184,9 +208,9 @@ const Context = struct {
                 .kernfs => f.kernfs = true,
                 .excluded => f.excluded = true,
             }
-        }
 
-        if (self.wr) |w| {
+        } else if (self.wr) |wr| {
+            var w = wr.writer();
             try w.writeAll(",\n");
             if (self.stat.dir) try w.writeByte('[');
             try w.writeAll("{\"name\":");
@@ -200,30 +224,37 @@ const Context = struct {
             try w.writeByte('}');
             if (self.stat.dir) try w.writeByte(']');
         }
+        self.items_seen += 1;
     }
 
     // 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| {
+        if (self.parents) |*p| {
             const etype = if (self.stat.dir) model.EType.dir
-                          else if (self.stat.nlink > 1) model.EType.link
+                          else if (self.stat.hlinkc) model.EType.link
                           else model.EType.file;
             var e = try 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 = try model.getDevId(self.stat.dev);
             if (e.file()) |f| f.notreg = !self.stat.dir and !self.stat.reg;
+            // TODO: Handle the scenario where we don't know the hard link count
+            // (i.e. on imports from old ncdu versions that don't have the "nlink" field)
             if (e.link()) |l| {
                 l.ino = self.stat.ino;
                 l.nlink = self.stat.nlink;
             }
             if (e.ext()) |ext| ext.* = self.stat.ext;
-            try e.insert(p);
 
-            if (e.dir()) |d| try p.push(d); // Enter the directory
-        }
+            if (self.items_seen == 0)
+                model.root = e.dir().?
+            else {
+                try e.insert(p);
+                if (e.dir()) |d| try p.push(d); // Enter the directory
+            }
 
-        if (self.wr) |w| {
+        } else if (self.wr) |wr| {
+            var w = wr.writer();
             try w.writeAll(",\n");
             if (self.stat.dir) try w.writeByte('[');
             try w.writeAll("{\"name\":");
@@ -231,23 +262,21 @@ const Context = struct {
             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.dir and self.stat.dev != dir_dev) try w.print(",\"dev\":{d}", .{ self.stat.dev });
-            if (!self.stat.dir and self.stat.nlink > 1) try w.print(",\"ino\":{d},\"hlnkc\":true,\"nlink\":{d}", .{ self.stat.ino, self.stat.nlink });
+            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");
             if (main.config.extended)
                 try w.print(",\"uid\":{d},\"gid\":{d},\"mode\":{d},\"mtime\":{d}",
                     .{ self.stat.ext.uid, self.stat.ext.gid, self.stat.ext.mode, self.stat.ext.mtime });
             try w.writeByte('}');
         }
-    }
 
-    fn leaveDir(self: *Self) !void {
-        if (self.parents) |p| p.pop();
-        if (self.wr) |w| try w.writeByte(']');
+        self.items_seen += 1;
     }
 
     fn deinit(self: *Self) void {
         if (self.last_error) |p| main.allocator.free(p);
-        if (self.parents) |p| p.deinit();
+        if (self.parents) |*p| p.deinit();
+        if (self.wr) |p| main.allocator.destroy(p);
         self.path.deinit();
         self.path_indices.deinit();
     }
@@ -259,6 +288,7 @@ var active_context: ?*Context = null;
 // Read and index entries of the given dir.
 // (TODO: shouldn't error on OOM but instead call a function that waits or something)
 fn scanDir(ctx: *Context, dir: std.fs.Dir, dir_dev: u64) (std.fs.File.Writer.Error || std.mem.Allocator.Error)!void {
+    // XXX: The iterator allocates 8k+ bytes on the stack, may want to do heap allocation here?
     var it = dir.iterate();
     while(true) {
         const entry = it.next() catch {
@@ -266,6 +296,7 @@ fn scanDir(ctx: *Context, dir: std.fs.Dir, dir_dev: u64) (std.fs.File.Writer.Err
             return;
         } orelse break;
 
+        ctx.stat.dir = false;
         try ctx.pushPath(entry.name);
         defer ctx.popPath();
         try main.handleEvent(false, false);
@@ -335,54 +366,426 @@ fn scanDir(ctx: *Context, dir: std.fs.Dir, dir_dev: u64) (std.fs.File.Writer.Err
         }
 
         try ctx.addStat(dir_dev);
-
-        if (ctx.stat.dir) {
-            try scanDir(ctx, edir.?, ctx.stat.dev);
-            try ctx.leaveDir();
-        }
+        if (ctx.stat.dir) try scanDir(ctx, edir.?, ctx.stat.dev);
     }
 }
 
 pub fn scanRoot(path: []const u8, out: ?std.fs.File) !void {
-    const full_path = std.fs.realpathAlloc(main.allocator, path) catch path;
-    defer main.allocator.free(full_path);
-
-    var ctx = Context{};
-    defer ctx.deinit();
-    try ctx.pushPath(full_path);
+    var ctx = if (out) |f| try 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;
+    defer if (full_path) |p| main.allocator.free(p);
+    try ctx.pushPath(full_path orelse path);
 
     ctx.stat = try Stat.read(std.fs.cwd(), ctx.pathZ(), true);
     if (!ctx.stat.dir) return error.NotADirectory;
+    try ctx.addStat(0);
 
-    var parents = model.Parents{};
-    var buf = if (out) |f| std.io.bufferedWriter(f.writer()) else undefined;
+    var dir = try std.fs.cwd().openDirZ(ctx.pathZ(), .{ .access_sub_paths = true, .iterate = true });
+    defer dir.close();
+    try scanDir(&ctx, dir, ctx.stat.dev);
+    ctx.popPath();
+    try ctx.final();
+}
 
-    if (out) |f| {
-        ctx.wr = buf.writer();
-        try ctx.wr.?.writeAll("[1,2,{\"progname\":\"ncdu\",\"progver\":\"" ++ main.program_version ++ "\",\"timestamp\":");
-        try ctx.wr.?.print("{d}", .{std.time.timestamp()});
-        try ctx.wr.?.writeByte('}');
-        try ctx.addStat(0);
+// Using a custom recursive descent JSON parser here. std.json is great, but
+// has two major downsides:
+// - It does strict UTF-8 validation. Which is great in general, but not so
+//   much for ncdu dumps that may contain non-UTF-8 paths encoded as strings.
+// - The streaming parser requires complex and overly large buffering in order
+//   to read strings, which doesn't work so well in our case.
+//
+// TODO: This code isn't very elegant and is likely contains bugs. It may be
+// worth factoring out the JSON parts into a separate abstraction for which
+// tests can be written.
+const Import = struct {
+    ctx: Context,
+    rd: std.io.BufferedReader(4096, std.fs.File.Reader),
+    ch: u8 = 0, // last read character, 0 = EOF (or invalid null byte, who cares)
+    byte: u64 = 1,
+    line: u64 = 1,
+    namebuf: [32*1024]u8 = undefined,
 
-    } else {
-        ctx.parents = &parents;
-        model.root = (try model.Entry.create(.dir, false, full_path)).dir().?;
-        model.root.entry.blocks = ctx.stat.blocks;
-        model.root.entry.size = ctx.stat.size;
-        model.root.dev = try model.getDevId(ctx.stat.dev);
-        if (model.root.entry.ext()) |ext| ext.* = ctx.stat.ext;
+    const Self = @This();
+
+    fn die(self: *Self, str: []const u8) noreturn {
+        ui.die("Error importing file on line {}:{}: {s}.\n", .{ self.line, self.byte, str });
     }
 
-    var dir = try std.fs.cwd().openDirZ(ctx.pathZ(), .{ .access_sub_paths = true, .iterate = true });
-    defer dir.close();
-    try scanDir(&ctx, dir, ctx.stat.dev);
-    if (out != null) {
-        try ctx.leaveDir();
-        try ctx.wr.?.writeByte(']');
-        try buf.flush();
+    // Advance to the next byte, sets ch.
+    fn con(self: *Self) void {
+        // XXX: This indirection through a BufferedReader to just read 1 byte
+        // at a time may have some extra overhead. Wrapping our own LinearFifo
+        // may or may not be worth it, needs benchmarking.
+        self.ch = self.rd.reader().readByte() catch |e| switch (e) {
+            error.EndOfStream => 0,
+            error.InputOutput => self.die("I/O error"),
+            // TODO: This one can be retried
+            error.SystemResources => self.die("out of memory"),
+            else => unreachable,
+        };
+        self.byte += 1;
+    }
+
+    // Advance to the next non-whitespace byte.
+    fn conws(self: *Self) void {
+        while (true) {
+            switch (self.ch) {
+                '\n' => {
+                    self.line += 1;
+                    self.byte = 1;
+                },
+                ' ', '\t', '\r' => {},
+                else => break,
+            }
+            self.con();
+        }
+    }
+
+    // Returns the current byte and advances to the next.
+    fn next(self: *Self) u8 {
+        defer self.con();
+        return self.ch;
+    }
+
+    fn hexdig(self: *Self) u16 {
+        return switch (self.ch) {
+            '0'...'9' => self.next() - '0',
+            'a'...'f' => self.next() - 'a' + 10,
+            'A'...'F' => self.next() - 'A' + 10,
+            else => self.die("invalid hex digit"),
+        };
+    }
+
+    // Read a string into buf.
+    // Any characters beyond the size of the buffer are consumed but otherwise discarded.
+    // (May store fewer characters in the case of \u escapes, it's not super precise)
+    fn string(self: *Self, buf: []u8) []u8 {
+        std.debug.assert(self.ch == '"');
+        if (self.next() != '"') self.die("expected '\"'");
+        var n: u64 = 0;
+        while (true) {
+            const ch = self.next();
+            switch (ch) {
+                '"' => break,
+                '\\' => switch (self.next()) {
+                    '"' => if (n < buf.len) { buf[n] = '"'; n += 1; },
+                    '\\'=> if (n < buf.len) { buf[n] = '\\';n += 1; },
+                    '/' => if (n < buf.len) { buf[n] = '/'; n += 1; },
+                    'b' => if (n < buf.len) { buf[n] = 0x8; n += 1; },
+                    'f' => if (n < buf.len) { buf[n] = 0xc; n += 1; },
+                    'n' => if (n < buf.len) { buf[n] = 0xa; n += 1; },
+                    'r' => if (n < buf.len) { buf[n] = 0xd; n += 1; },
+                    't' => if (n < buf.len) { buf[n] = 0x9; n += 1; },
+                    'u' => {
+                        const char = (self.hexdig()<<12) + (self.hexdig()<<8) + (self.hexdig()<<4) + self.hexdig();
+                        if (n + 6 < buf.len)
+                            n += std.unicode.utf8Encode(char, buf[n..n+5]) catch unreachable;
+                    },
+                    else => self.die("invalid escape sequence"),
+                },
+                0x20, 0x21, 0x23...0x5b, 0x5d...0xff => if (n < buf.len) { buf[n] = ch; n += 1; },
+                else => self.die("invalid character in string"),
+            }
+        }
+        return buf[0..n];
+    }
+
+    fn uint(self: *Self, T: anytype) T {
+        if (self.ch == '0') {
+            self.con();
+            return 0;
+        }
+        var v: T = 0;
+        while (self.ch >= '0' and self.ch <= '9') {
+            const newv = v *% 10 +% (self.ch - '0');
+            if (newv < v) self.die("integer out of range");
+            v = newv;
+            self.con();
+        }
+        if (v == 0) self.die("expected number");
+        return v;
+    }
+
+    fn boolean(self: *Self) bool {
+        switch (self.next()) {
+            't' => {
+                if (self.next() == 'r' and self.next() == 'u' and self.next() == 'e')
+                    return true;
+            },
+            'f' => {
+                if (self.next() == 'a' and self.next() == 'l' and self.next() == 's' and self.next() == 'e')
+                    return false;
+            },
+            else => {}
+        }
+        self.die("expected boolean");
+    }
+
+    // Consume and discard any JSON value.
+    fn conval(self: *Self) void {
+        switch (self.ch) {
+            't' => _ = self.boolean(),
+            'f' => _ = self.boolean(),
+            'n' => {
+                self.con();
+                if (!(self.next() == 'u' and self.next() == 'l' and self.next() == 'l'))
+                    self.die("invalid JSON value");
+            },
+            '"' => _ = self.string(&[0]u8{}),
+            '{' => {
+                self.con();
+                self.conws();
+                if (self.ch == '}') { self.con(); return; }
+                while (true) {
+                    self.conws();
+                    _ = self.string(&[0]u8{});
+                    self.conws();
+                    if (self.next() != ':') self.die("expected ':'");
+                    self.conws();
+                    self.conval();
+                    self.conws();
+                    switch (self.next()) {
+                        ',' => continue,
+                        '}' => break,
+                        else => self.die("expected ',' or '}'"),
+                    }
+                }
+            },
+            '[' => {
+                self.con();
+                self.conws();
+                if (self.ch == ']') { self.con(); return; }
+                while (true) {
+                    self.conws();
+                    self.conval();
+                    self.conws();
+                    switch (self.next()) {
+                        ',' => continue,
+                        ']' => break,
+                        else => self.die("expected ',' or ']'"),
+                    }
+                }
+            },
+            '-', '0'...'9' => {
+                self.con();
+                // Numbers are kind of annoying, this "parsing" is invalid and ultra-lazy.
+                while (true) {
+                    switch (self.ch) {
+                        '-', '+', 'e', 'E', '.', '0'...'9' => self.con(),
+                        else => return,
+                    }
+                }
+            },
+            else => self.die("invalid JSON value"),
+        }
     }
+
+    fn itemkey(self: *Self, key: []const u8, name: *?[]u8, special: *?Context.Special) void {
+        const eq = std.mem.eql;
+        switch (if (key.len > 0) key[0] else @as(u8,0)) {
+            'a' => {
+                if (eq(u8, key, "asize")) {
+                    self.ctx.stat.size = self.uint(u64);
+                    return;
+                }
+            },
+            'd' => {
+                if (eq(u8, key, "dsize")) {
+                    self.ctx.stat.blocks = @intCast(u61, self.uint(u64)>>9);
+                    return;
+                }
+                if (eq(u8, key, "dev")) {
+                    self.ctx.stat.dev = self.uint(u64);
+                    return;
+                }
+            },
+            'e' => {
+                if (eq(u8, key, "excluded")) {
+                    var buf: [32]u8 = undefined;
+                    const typ = self.string(&buf);
+                    // "frmlnk" is also possible, but currently considered equivalent to "pattern".
+                    if (eq(u8, typ, "otherfs")) special.* = .other_fs
+                    else if (eq(u8, typ, "kernfs")) special.* = .kernfs
+                    else special.* = .excluded;
+                }
+            },
+            'g' => {
+                if (eq(u8, key, "gid")) {
+                    self.ctx.stat.ext.gid = self.uint(u32);
+                    return;
+                }
+            },
+            'h' => {
+                if (eq(u8, key, "hlinkc")) {
+                    self.ctx.stat.hlinkc = true;
+                    return;
+                }
+            },
+            'i' => {
+                if (eq(u8, key, "ino")) {
+                    self.ctx.stat.ino = self.uint(u64);
+                    return;
+                }
+            },
+            'm' => {
+                if (eq(u8, key, "mode")) {
+                    self.ctx.stat.ext.mode = self.uint(u16);
+                    return;
+                }
+                if (eq(u8, key, "mtime")) {
+                    self.ctx.stat.ext.mtime = self.uint(u64);
+                    // Accept decimal numbers, but discard the fractional part because our data model doesn't support it.
+                    if (self.ch == '.') {
+                        self.con();
+                        while (self.ch >= '0' and self.ch <= '9')
+                            self.con();
+                    }
+                    return;
+                }
+            },
+            'n' => {
+                if (eq(u8, key, "name")) {
+                    if (name.* != null) self.die("duplicate key");
+                    name.* = self.string(&self.namebuf);
+                    if (name.*.?.len > self.namebuf.len-5) self.die("too long file name");
+                    return;
+                }
+                if (eq(u8, key, "nlink")) {
+                    self.ctx.stat.nlink = self.uint(u32);
+                    if (!self.ctx.stat.dir and self.ctx.stat.nlink > 1)
+                        self.ctx.stat.hlinkc = true;
+                    return;
+                }
+                if (eq(u8, key, "notreg")) {
+                    self.ctx.stat.reg = !self.boolean();
+                    return;
+                }
+            },
+            'r' => {
+                if (eq(u8, key, "read_error")) {
+                    if (self.boolean())
+                        special.* = .err;
+                    return;
+                }
+            },
+            'u' => {
+                if (eq(u8, key, "uid")) {
+                    self.ctx.stat.ext.uid = self.uint(u32);
+                    return;
+                }
+            },
+            else => {},
+        }
+        self.conval();
+    }
+
+    fn iteminfo(self: *Self, dir_dev: u64) void {
+        if (self.next() != '{') self.die("expected '{'");
+        self.ctx.stat.dev = dir_dev;
+        var name: ?[]u8 = null;
+        var special: ?Context.Special = null;
+        while (true) {
+            self.conws();
+            var keybuf: [32]u8 = undefined;
+            const key = self.string(&keybuf);
+            self.conws();
+            if (self.next() != ':') self.die("expected ':'");
+            self.conws();
+            self.itemkey(key, &name, &special);
+            self.conws();
+            switch (self.next()) {
+                ',' => continue,
+                '}' => break,
+                else => self.die("expected ',' or '}'"),
+            }
+        }
+        if (name) |n| self.ctx.pushPath(n) catch unreachable
+        else self.die("missing \"name\" field");
+        if (special) |s| self.ctx.addSpecial(s) catch unreachable
+        else self.ctx.addStat(dir_dev) catch unreachable;
+    }
+
+    fn item(self: *Self, dev: u64) void {
+        self.ctx.stat = .{};
+        if (self.ch == '[') {
+            self.ctx.stat.dir = true;
+            self.con();
+            self.conws();
+        }
+
+        self.iteminfo(dev);
+
+        self.conws();
+        if (self.ctx.stat.dir) {
+            const ndev = self.ctx.stat.dev;
+            while (self.ch == ',') {
+                self.con();
+                self.conws();
+                self.item(ndev);
+                self.conws();
+            }
+            if (self.next() != ']') self.die("expected ',' or ']'");
+        }
+        self.ctx.popPath();
+
+        if ((self.ctx.items_seen & 1023) == 0)
+            main.handleEvent(false, false) catch unreachable;
+    }
+
+    fn root(self: *Self) void {
+        self.con();
+        self.conws();
+        if (self.next() != '[') self.die("expected '['");
+        self.conws();
+        if (self.uint(u16) != 1) self.die("incompatible major format version");
+        self.conws();
+        if (self.next() != ',') self.die("expected ','");
+        self.conws();
+        _ = self.uint(u16); // minor version, ignored for now
+        self.conws();
+        if (self.next() != ',') self.die("expected ','");
+        self.conws();
+        // metadata object
+        if (self.ch != '{') self.die("expected '{'");
+        self.conval(); // completely discarded
+        self.conws();
+        if (self.next() != ',') self.die("expected ','");
+        self.conws();
+        // root element
+        if (self.ch != '[') self.die("expected '['"); // top-level entry must be a dir
+        self.item(0);
+        self.conws();
+        // any trailing elements
+        while (self.ch == ',') {
+            self.con();
+            self.conws();
+            self.conval();
+            self.conws();
+        }
+        if (self.next() != ']') self.die("expected ',' or ']'");
+        self.conws();
+        if (self.ch != 0) self.die("trailing garbage");
+    }
+};
+
+pub fn importRoot(path: [:0]const u8, out: ?std.fs.File) !void {
+    var fd = if (std.mem.eql(u8, "-", path)) std.io.getStdIn()
+             else try std.fs.cwd().openFileZ(path, .{});
+    defer fd.close();
+
+    var imp = Import{
+        .ctx = if (out) |f| try Context.initFile(f) else Context.initMem(),
+        .rd = std.io.bufferedReader(fd.reader()),
+    };
+    active_context = &imp.ctx;
+    defer active_context = null;
+    defer imp.ctx.deinit();
+    imp.root();
+    try imp.ctx.final();
 }
 
 var animation_pos: u32 = 0;
@@ -474,7 +877,7 @@ pub fn draw() !void {
     }
 }
 
-pub fn key(ch: i32) !void {
+pub fn keyInput(ch: i32) !void {
     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 73183f2b49743777c74dd1dd3101ff972da5618f..4a2f7610549b3bc27dc04c3e2d584a31d614c974 100644
--- a/src/ui.zig
+++ b/src/ui.zig
@@ -285,7 +285,6 @@ pub fn init() void {
         if (term == null) die("Error initializing ncurses.\n", .{});
         _ = c.set_term(term);
     } else {
-        if (!std.io.getStdIn().isTty()) die("Standard input is not a TTY. Did you mean to import a file using '-f -'?\n", .{});
         if (c.initscr() == null) die("Error initializing ncurses.\n", .{});
     }
     updateSize();