diff --git a/README.md b/README.md
index 668c97832c21837ad4a11a7ddee9cbcd7dba399d..5401904f0be9c7457fb6d6317d4471b643f8a972 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:
 
-- Listing paths for the same hard link
 - Help window
 - Directory refresh
 - File deletion
@@ -51,6 +50,7 @@ Already implemented:
   [#36](https://code.blicky.net/yorhel/ncdu/issues/36)).
 - Faster --exclude-kernfs thanks to `statfs()` caching.
 - Improved handling of Unicode and special characters.
+- Key to switch to path from a file's hard link listing.
 - Remembers item position when switching directories.
 
 Potentially to be implemented:
diff --git a/src/browser.zig b/src/browser.zig
index e5f1f84ce7dddcb2c92f2ea723316c956d9b8ba4..2cbcafb1b4367503b4f24425cd818899d01c820a 100644
--- a/src/browser.zig
+++ b/src/browser.zig
@@ -127,7 +127,7 @@ pub fn loadDir() void {
     dir_max_blocks = 1;
     dir_has_shared = false;
 
-    if (dir_parents.top() != model.root)
+    if (!dir_parents.isRoot())
         dir_items.append(null) catch unreachable;
     var it = dir_parents.top().sub;
     while (it) |e| {
@@ -329,9 +329,65 @@ const quit = struct {
 };
 
 const info = struct {
-    // TODO: List of paths for the same hardlink.
+    const Tab = enum { info, links };
+
+    var tab: Tab = .info;
+    var entry: ?*model.Entry = null;
+    var links: ?model.LinkPaths = null;
+    var links_top: usize = 0;
+    var links_idx: usize = 0;
+
+    // Set the displayed entry to the currently selected item and open the tab.
+    fn set(e: ?*model.Entry, t: Tab) void {
+        if (e != entry) {
+            if (links) |*l| l.deinit();
+            links = null;
+            links_top = 0;
+            links_idx = 0;
+        }
+        entry = e;
+        if (e == null) {
+            state = .main;
+            return;
+        }
+        state = .info;
+        tab = t;
+        if (tab == .links and links == null) {
+            links = model.LinkPaths.find(&dir_parents, e.?.link().?);
+            for (links.?.paths.items) |n,i| {
+                if (&n.node.entry == e) {
+                    links_idx = i;
+                }
+            }
+        }
+    }
+
+    fn drawLinks(box: ui.Box, row: *u32, rows: u32, cols: u32) void {
+        var pathbuf = std.ArrayList(u8).init(main.allocator);
+
+        const numrows = saturateSub(rows, 4);
+        if (links_idx < links_top) links_top = links_idx;
+        if (links_idx >= links_top + numrows) links_top = links_idx - numrows + 1;
+
+        var i: u32 = 0;
+        while (i < numrows) : (i += 1) {
+            if (i + links_top >= links.?.paths.items.len) break;
+            const e = links.?.paths.items[i+links_top];
+            ui.style(if (i+links_top == links_idx) .sel else .default);
+            box.move(row.*, 2);
+            ui.addch(if (&e.node.entry == entry) '*' else ' ');
+            pathbuf.shrinkRetainingCapacity(0);
+            e.fmtPath(false, &pathbuf);
+            ui.addstr(ui.shorten(ui.toUtf8(arrayListBufZ(&pathbuf)), saturateSub(cols, 5)));
+            row.* += 1;
+        }
+        ui.style(.default);
+        box.move(rows-2, 4);
+        ui.addprint("{:>3}/{}", .{ links_idx+1, links.?.paths.items.len });
+        pathbuf.deinit();
+    }
 
-    fn drawSizeRow(box: *const ui.Box, row: *u32, label: [:0]const u8, size: u64) void {
+    fn drawSizeRow(box: ui.Box, row: *u32, label: [:0]const u8, size: u64) void {
         box.move(row.*, 3);
         ui.addstr(label);
         ui.addsize(.default, size);
@@ -341,7 +397,7 @@ const info = struct {
         row.* += 1;
     }
 
-    fn drawSize(box: *const ui.Box, row: *u32, label: [:0]const u8, size: u64, shared: u64) void {
+    fn drawSize(box: ui.Box, row: *u32, label: [:0]const u8, size: u64, shared: u64) void {
         ui.style(.bold);
         drawSizeRow(box, row, label, size);
         if (shared > 0) {
@@ -351,33 +407,17 @@ const info = struct {
         }
     }
 
-    fn draw() void {
-        const e = dir_items.items[cursor_idx].?;
-        // XXX: The dynamic height is a bit jarring, especially when that
-        // causes the same lines of information to be placed on different rows
-        // for each item. Not really sure how to handle yet.
-        const rows = 5 // border + padding + close message
-            + 4 // name + type + disk usage + apparent size
-            + (if (e.ext() != null) @as(u32, 1) else 0) // last modified
-            + (if (e.link() != null) @as(u32, 1) else 0) // link count
-            + (if (e.dir()) |d| 1 // sub items
-                    + (if (d.shared_size > 0) @as(u32, 2) else 0)
-                    + (if (d.shared_blocks > 0) @as(u32, 2) else 0)
-                else 0);
-        const cols = 60; // TODO: dynamic width?
-        const box = ui.Box.create(rows, cols, "Item info");
-        var row: u32 = 2;
-
+    fn drawInfo(box: ui.Box, row: *u32, cols: u32, e: *model.Entry) void {
         // Name
-        box.move(row, 3);
+        box.move(row.*, 3);
         ui.style(.bold);
         ui.addstr("Name: ");
         ui.style(.default);
         ui.addstr(ui.shorten(ui.toUtf8(e.name()), cols-11));
-        row += 1;
+        row.* += 1;
 
         // Type / Mode+UID+GID
-        box.move(row, 3);
+        box.move(row.*, 3);
         ui.style(.bold);
         if (e.ext()) |ext| {
             ui.addstr("Mode: ");
@@ -397,47 +437,78 @@ const info = struct {
             ui.style(.default);
             ui.addstr(if (e.isDirectory()) "Directory" else if (if (e.file()) |f| f.notreg else false) "Other" else "File");
         }
-        row += 1;
+        row.* += 1;
 
         // Last modified
         if (e.ext()) |ext| {
-            box.move(row, 3);
+            box.move(row.*, 3);
             ui.style(.bold);
             ui.addstr("Last modified: ");
             ui.addts(.default, ext.mtime);
-            row += 1;
+            row.* += 1;
         }
 
         // Disk usage & Apparent size
-        drawSize(&box, &row, "   Disk usage: ", blocksToSize(e.blocks), if (e.dir()) |d| blocksToSize(d.shared_blocks) else 0);
-        drawSize(&box, &row, "Apparent size: ", e.size,                 if (e.dir()) |d| d.shared_size                 else 0);
+        drawSize(box, row, "   Disk usage: ", blocksToSize(e.blocks), if (e.dir()) |d| blocksToSize(d.shared_blocks) else 0);
+        drawSize(box, row, "Apparent size: ", e.size,                 if (e.dir()) |d| d.shared_size                 else 0);
 
         // Number of items
         if (e.dir()) |d| {
-            box.move(row, 3);
+            box.move(row.*, 3);
             ui.style(.bold);
             ui.addstr("    Sub items: ");
             ui.addnum(.default, d.items);
-            row += 1;
+            row.* += 1;
         }
 
         // Number of links + inode (dev?)
         if (e.link()) |l| {
-            box.move(row, 3);
+            box.move(row.*, 3);
             ui.style(.bold);
             ui.addstr("   Link count: ");
             ui.addnum(.default, l.nlink);
-            box.move(row, 23);
+            box.move(row.*, 23);
             ui.style(.bold);
             ui.addstr("  Inode: ");
             ui.style(.default);
             var buf: [32]u8 = undefined;
             ui.addstr(std.fmt.bufPrintZ(&buf, "{}", .{ l.ino }) catch unreachable);
-            row += 1;
+            row.* += 1;
+        }
+    }
+
+    fn draw() void {
+        const e = dir_items.items[cursor_idx].?;
+        // XXX: The dynamic height is a bit jarring, especially when that
+        // causes the same lines of information to be placed on different rows
+        // for each item. Think it's better to have a dynamic height based on
+        // terminal size and scroll if the content doesn't fit.
+        const rows = 5 // border + padding + close message
+            + if (tab == .links) 8 else
+              4 // name + type + disk usage + apparent size
+            + (if (e.ext() != null) @as(u32, 1) else 0) // last modified
+            + (if (e.link() != null) @as(u32, 1) else 0) // link count
+            + (if (e.dir()) |d| 1 // sub items
+                    + (if (d.shared_size > 0) @as(u32, 2) else 0)
+                    + (if (d.shared_blocks > 0) @as(u32, 2) else 0)
+                else 0);
+        const cols = 60; // TODO: dynamic width?
+        const box = ui.Box.create(rows, cols, "Item info");
+        var row: u32 = 2;
+
+        // Tabs
+        if (e.etype == .link) {
+            box.tab(cols-19, tab == .info, 1, "Info");
+            box.tab(cols-10, tab == .links, 2, "Links");
+        }
+
+        switch (tab) {
+            .info => drawInfo(box, &row, cols, e),
+            .links => drawLinks(box, &row, rows, cols),
         }
 
         // "Press i to close this window"
-        box.move(row+1, cols-30);
+        box.move(rows-2, cols-30);
         ui.style(.default);
         ui.addstr("Press ");
         ui.style(.key);
@@ -446,15 +517,42 @@ const info = struct {
         ui.addstr(" to close this window");
     }
 
-    fn keyInput(ch: i32) void {
-        if (keyInputSelection(ch)) {
-            if (dir_items.items[cursor_idx] == null) state = .main;
-            return;
+    fn keyInput(ch: i32) bool {
+        if (entry.?.etype == .link) {
+            switch (ch) {
+                '1', 'h', ui.c.KEY_LEFT => { set(entry, .info); return true; },
+                '2', 'l', ui.c.KEY_RIGHT => { set(entry, .links); return true; },
+                else => {},
+            }
+        }
+        if (tab == .links) {
+            if (keyInputSelection(ch, &links_idx, links.?.paths.items.len, 5))
+                return true;
+            if (ch == 10) { // Enter - go to selected entry
+                // XXX: This jump can be a little bit jarring as, usually,
+                // browsing to parent directory will cause the previously
+                // opened dir to be selected. This jump doesn't update the View
+                // state of parent dirs, so that won't be the case anymore.
+                const p = links.?.paths.items[links_idx];
+                dir_parents.stack.shrinkRetainingCapacity(0);
+                dir_parents.stack.appendSlice(p.path.stack.items) catch unreachable;
+                loadDir();
+                for (dir_items.items) |e, i| {
+                    if (e == &p.node.entry)
+                        cursor_idx = i;
+                }
+                set(null, .info);
+            }
+        }
+        if (keyInputSelection(ch, &cursor_idx, dir_items.items.len, saturateSub(ui.rows, 3))) {
+            set(dir_items.items[cursor_idx], .info);
+            return true;
         }
         switch (ch) {
-            'i', 'q' => state = .main,
-            else => {},
+            'i', 'q' => set(null, .info),
+            else => return false,
         }
+        return true;
     }
 };
 
@@ -484,7 +582,7 @@ pub fn draw() void {
     ui.style(.dir);
 
     var pathbuf = std.ArrayList(u8).init(main.allocator);
-    dir_parents.path(&pathbuf);
+    dir_parents.fmtPath(true, &pathbuf);
     ui.addstr(ui.shorten(ui.toUtf8(arrayListBufZ(&pathbuf)), saturateSub(ui.cols, 5)));
     pathbuf.deinit();
 
@@ -537,35 +635,35 @@ fn sortToggle(col: main.config.SortCol, default_order: main.config.SortOrder) vo
     sortDir();
 }
 
-fn keyInputSelection(ch: i32) bool {
+fn keyInputSelection(ch: i32, idx: *usize, len: usize, page: u32) bool {
     switch (ch) {
         'j', ui.c.KEY_DOWN => {
-            if (cursor_idx+1 < dir_items.items.len) cursor_idx += 1;
+            if (idx.*+1 < len) idx.* += 1;
         },
         'k', ui.c.KEY_UP => {
-            if (cursor_idx > 0) cursor_idx -= 1;
+            if (idx.* > 0) idx.* -= 1;
         },
-        ui.c.KEY_HOME => cursor_idx = 0,
-        ui.c.KEY_END, ui.c.KEY_LL => cursor_idx = saturateSub(dir_items.items.len, 1),
-        ui.c.KEY_PPAGE => cursor_idx = saturateSub(cursor_idx, saturateSub(ui.rows, 3)),
-        ui.c.KEY_NPAGE => cursor_idx = std.math.min(saturateSub(dir_items.items.len, 1), cursor_idx + saturateSub(ui.rows, 3)),
+        ui.c.KEY_HOME => idx.* = 0,
+        ui.c.KEY_END, ui.c.KEY_LL => idx.* = saturateSub(len, 1),
+        ui.c.KEY_PPAGE => idx.* = saturateSub(idx.*, page),
+        ui.c.KEY_NPAGE => idx.* = std.math.min(saturateSub(len, 1), idx.* + page),
         else => return false,
     }
     return true;
 }
 
 pub fn keyInput(ch: i32) void {
+    defer current_view.save();
+
     switch (state) {
         .main => {}, // fallthrough
         .quit => return quit.keyInput(ch),
-        .info => return info.keyInput(ch),
+        .info => if (info.keyInput(ch)) return,
     }
 
-    defer current_view.save();
-
     switch (ch) {
         'q' => if (main.config.confirm_quit) { state = .quit; } else ui.quit(),
-        'i' => if (dir_items.items[cursor_idx] != null) { state = .info; },
+        'i' => info.set(dir_items.items[cursor_idx], .info),
 
         // Sort & filter settings
         'n' => sortToggle(.name, .asc),
@@ -575,6 +673,7 @@ pub fn keyInput(ch: i32) void {
         'e' => {
             main.config.show_hidden = !main.config.show_hidden;
             loadDir();
+            state = .main;
         },
         't' => {
             main.config.sort_dirsfirst = !main.config.sort_dirsfirst;
@@ -599,16 +698,19 @@ pub fn keyInput(ch: i32) void {
                 if (e.dir()) |d| {
                     dir_parents.push(d);
                     loadDir();
+                    state = .main;
                 }
-            } else if (dir_parents.top() != model.root) {
+            } else if (!dir_parents.isRoot()) {
                 dir_parents.pop();
                 loadDir();
+                state = .main;
             }
         },
         'h', '<', ui.c.KEY_BACKSPACE, ui.c.KEY_LEFT => {
-            if (dir_parents.top() != model.root) {
+            if (!dir_parents.isRoot()) {
                 dir_parents.pop();
                 loadDir();
+                state = .main;
             }
         },
 
@@ -628,6 +730,6 @@ pub fn keyInput(ch: i32) void {
             .unique => .off,
         },
 
-        else => _ = keyInputSelection(ch),
+        else => _ = keyInputSelection(ch, &cursor_idx, dir_items.items.len, saturateSub(ui.rows, 3)),
     }
 }
diff --git a/src/model.zig b/src/model.zig
index 26ca9849ee4a55a8d86aa11857d36260ebaf8d0a..47c2636208499073a20ec97c0655535bd0ae2559 100644
--- a/src/model.zig
+++ b/src/model.zig
@@ -392,6 +392,10 @@ pub const Parents = struct {
         return self.stack.append(dir) catch unreachable;
     }
 
+    pub fn isRoot(self: *Self) bool {
+        return self.stack.items.len == 0;
+    }
+
     // Attempting to remove the root node is considered a bug.
     pub fn pop(self: *Self) void {
         _ = self.stack.pop();
@@ -419,23 +423,97 @@ pub const Parents = struct {
     }
 
     // Append the path to the given arraylist. The list is assumed to use main.allocator, so it can't fail.
-    pub fn path(self: *const Self, out: *std.ArrayList(u8)) void {
+    pub fn fmtPath(self: *const Self, withRoot: bool, out: *std.ArrayList(u8)) void {
         const r = root.entry.name();
-        out.appendSlice(r) catch unreachable;
+        if (withRoot) out.appendSlice(r) catch unreachable;
         var i: usize = 0;
         while (i < self.stack.items.len) {
-            if (i != 0 or r[r.len-1] != '/') out.append('/') catch unreachable;
+            if (i != 0 or (withRoot and r[r.len-1] != '/')) out.append('/') catch unreachable;
             out.appendSlice(self.stack.items[i].entry.name()) catch unreachable;
             i += 1;
         }
     }
 
+    pub fn copy(self: *const Self) Self {
+        var c = Self{};
+        c.stack.appendSlice(self.stack.items) catch unreachable;
+        return c;
+    }
+
     pub fn deinit(self: *Self) void {
         self.stack.deinit();
     }
 };
 
 
+// List of paths for the same inode.
+pub const LinkPaths = struct {
+    paths: std.ArrayList(Path) = std.ArrayList(Path).init(main.allocator),
+
+    pub const Path = struct {
+        path: Parents,
+        node: *Link,
+
+        fn lt(_: void, a: Path, b: Path) bool {
+            var i: usize = 0;
+            while (i < a.path.stack.items.len and i < b.path.stack.items.len) : (i += 1)
+                if (a.path.stack.items[i] != b.path.stack.items[i])
+                    return std.mem.lessThan(u8, a.path.stack.items[i].entry.name(), b.path.stack.items[i].entry.name());
+            if (a.path.stack.items.len != b.path.stack.items.len)
+                return a.path.stack.items.len < b.path.stack.items.len;
+            return std.mem.lessThan(u8, a.node.entry.name(), b.node.entry.name());
+        }
+
+        pub fn fmtPath(self: Path, withRoot: bool, out: *std.ArrayList(u8)) void {
+            self.path.fmtPath(withRoot, out);
+            out.append('/') catch unreachable;
+            out.appendSlice(self.node.entry.name()) catch unreachable;
+        }
+    };
+
+    const Self = @This();
+
+    fn findRec(self: *Self, parent: *Parents, node: *const Link) void {
+        var entry = parent.top().sub;
+        while (entry) |e| : (entry = e.next) {
+            if (e.link()) |l| {
+                if (l.ino == node.ino)
+                    self.paths.append(Path{ .path = parent.copy(), .node = l }) catch unreachable;
+            }
+            if (e.dir()) |d| {
+                if (d.dev == parent.top().dev) {
+                    parent.push(d);
+                    self.findRec(parent, node);
+                    parent.pop();
+                }
+            }
+        }
+    }
+
+    // Find all paths for the given link
+    pub fn find(parents_: *const Parents, node: *const Link) Self {
+        var parents = parents_.copy();
+        var self = Self{};
+        // First find the bottom-most parent that has no shared_size,
+        // all links are guaranteed to be inside that directory.
+        while (!parents.isRoot() and parents.top().shared_size > 0)
+            parents.pop();
+        self.findRec(&parents, node);
+        // TODO: Zig's sort() implementation is type-generic and not very
+        // small. I suspect we can get a good save on our binary size by using
+        // a smaller or non-generic sort. This doesn't have to be very fast.
+        std.sort.sort(Path, self.paths.items, @as(void, undefined), Path.lt);
+        parents.deinit();
+        return self;
+    }
+
+    pub fn deinit(self: *Self) void {
+        for (self.paths.items) |*p| p.path.deinit();
+        self.paths.deinit();
+    }
+};
+
+
 test "entry" {
     var e = Entry.create(.file, false, "hello") catch unreachable;
     std.debug.assert(e.etype == .file);
diff --git a/src/scan.zig b/src/scan.zig
index 5a5377c71a5652325663c62fb1df951c1efa0a92..f653737a0ed6930bcc513f2beb01ee1f75e2d987 100644
--- a/src/scan.zig
+++ b/src/scan.zig
@@ -175,7 +175,7 @@ const Context = struct {
         self.path_indices.items.len -= 1;
 
         if (self.stat.dir) {
-            if (self.parents) |*p| if (p.top() != model.root) p.pop();
+            if (self.parents) |*p| 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.
diff --git a/src/ui.zig b/src/ui.zig
index 57a09abbe0f682af3f05e8f2c5d882ad95b106df..66cad66fd854e5c63e0a6454ef029b95d5b2cf8a 100644
--- a/src/ui.zig
+++ b/src/ui.zig
@@ -330,7 +330,6 @@ pub fn init() void {
     updateSize();
     _ = c.cbreak();
     _ = c.noecho();
-    _ = c.nonl();
     _ = c.curs_set(0);
     _ = c.keypad(c.stdscr, true);
 
@@ -530,6 +529,17 @@ pub const Box = struct {
         return s;
     }
 
+    pub fn tab(s: Self, col: u32, sel: bool, num: u3, label: [:0]const u8) void {
+        const bg: Bg = if (sel) .hd else .default;
+        s.move(0, col);
+        bg.fg(.key);
+        addch('0' + @as(u8, num));
+        bg.fg(.default);
+        addch(':');
+        addstr(label);
+        style(.default);
+    }
+
     // Move the global cursor to the given coordinates inside the box.
     pub fn move(s: Self, row: u32, col: u32) void {
         ui.move(s.start_row + row, s.start_col + col);