the file browser says when something is a link

An arrow beside the name, and the target in its tooltip.

`stat` follows a symlink, so the listing has always described what the link POINTS AT and never that
there was a link in the way. That is right for behaviour — a link to a folder must still say
`directory` so opening it works — and it is why `stat` cannot answer this question at all.

`readdir({ withFileTypes: true })` can, without a second syscall per entry: the kind comes off the
directory entry itself. `readlink` is asked only for the entries that are links, which in an ordinary
folder is none of them.

A BROKEN link now gets a row, and did not before. `stat` failed on it, the entry returned null, and it
disappeared from the listing — nothing said it was there, nothing could delete it, and the folder
quietly had fewer things in it than `ls` showed. It is drawn in the destructive colour with its dead
target in the tooltip.

The target is stored exactly as written rather than resolved. `../originals` moves with the folder and
`/home/…/originals` does not, and rewriting one into the other would hide that difference at the
moment somebody is looking in order to understand it.

An arrow, not a badge or a different icon: the icon says what the thing IS, and a symlink to a folder
is still a folder to whoever uses it. What the arrow adds is the indirection — which is what explains
why deleting it freed no space, or why two places show the same file.

Verified against real links: to a file, to a directory, absolute, and broken.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-31 15:10:21 +01:00
co-authored by Claude Opus 5
parent ab2336516f
commit cdbf72c7fe
3 changed files with 97 additions and 10 deletions
+43 -8
View File
@@ -1,6 +1,7 @@
import { createRouter } from '@@/create-router'; import { createRouter } from '@@/create-router';
import { resolve, dirname, join, sep, parse as parsePath } from 'node:path'; import { resolve, dirname, join, sep, parse as parsePath } from 'node:path';
import { readdir, stat, mkdir, rm, rename, readFile, cp } from 'node:fs/promises'; import { readdir, lstat, readlink, stat, mkdir, rm, rename, readFile, cp } from 'node:fs/promises';
import type { Dirent } from 'node:fs';
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import { getOwnerHomeDir, DATA_PATH } from '@@/data-path'; import { getOwnerHomeDir, DATA_PATH } from '@@/data-path';
import { resolveHomeDir } from '@@/user-home'; import { resolveHomeDir } from '@@/user-home';
@@ -188,9 +189,17 @@ router.get('/ls', async (ctx) => {
await cleanOldCacheDirs(rootDir); await cleanOldCacheDirs(rootDir);
} }
let names: string[]; /*
`withFileTypes`, so a symlink can be spotted without a second syscall per entry.
The kind comes off the directory entry itself on every filesystem that reports it, and where it
does not Node falls back to an lstat it would otherwise have had to make anyway. `stat` below
FOLLOWS the link — which is right, and is why it cannot answer this question: a symlink to a folder
must still say `directory` so that opening it works.
*/
let names: Dirent[];
try { try {
names = await readdir(absPath); names = await readdir(absPath, { withFileTypes: true });
} catch (ex) { } catch (ex) {
// A missing directory resets the browser to the root, which is the right answer for a stale path. // A missing directory resets the browser to the root, which is the right answer for a stale path.
// //
@@ -213,18 +222,44 @@ router.get('/ls', async (ctx) => {
const claimed = await pluginFileTypes(); const claimed = await pluginFileTypes();
const entries = await Promise.all( const entries = await Promise.all(
names.map(async (name) => { names.map(async (entry) => {
const name = entry.name;
const fullPath = resolve(absPath, name); const fullPath = resolve(absPath, name);
// Skip entries that escape the home dir (shouldn't happen but be safe) // Skip entries that escape the home dir (shouldn't happen but be safe)
if (!isInside(rootDir, fullPath)) return null; if (!isInside(rootDir, fullPath)) return null;
/*
Where a link POINTS, exactly as it was written.
Not resolved to an absolute path: a relative target is a fact about the link, and rewriting it
would hide that `../originals` moves with the folder while `/home/…/originals` does not. Only
asked for the entries that are links, which in an ordinary folder is none of them.
*/
const target = entry.isSymbolicLink() ? await readlink(fullPath).catch(() => null) : null;
const s = await stat(fullPath).catch(() => null); const s = await stat(fullPath).catch(() => null);
if (!s) return null;
const isDirectory = s.isDirectory(); /*
A BROKEN link still gets a row, and did not before.
`stat` follows, so a link whose target is gone fails here and the entry returned null — the file
vanished from the listing entirely. Nothing said it was there, nothing could delete it, and the
folder simply had fewer things in it than `ls` showed. `lstat` describes the link itself, which
exists whatever it points at.
*/
const own = s ?? (target !== null ? await lstat(fullPath).catch(() => null) : null);
if (!own) return null;
const isDirectory = s?.isDirectory() ?? false;
return { return {
name, name,
type: isDirectory ? 'directory' : 'file', type: isDirectory ? 'directory' : 'file',
size: s.size, size: own.size,
modifiedAt: s.mtimeMs, modifiedAt: own.mtimeMs,
/** Where it points, or absent when it is an ordinary file. See the note above. */
symlink: target,
/** A link whose target is gone. `type` is `file` for want of anything truer to say. */
broken: target !== null && s === null ? true : undefined,
/* /*
Whether `/thumb` will produce something, decided HERE so there is one list of extensions Whether `/thumb` will produce something, decided HERE so there is one list of extensions
rather than one on each side of the wire. rather than one on each side of the wire.
@@ -1,6 +1,7 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { import {
Folder, Folder,
Link2,
Trash2, Trash2,
Pencil, Pencil,
MoreVertical, MoreVertical,
@@ -631,6 +632,21 @@ export const FileItem = ({
const clickTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const clickTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const isDir = entry.type === 'directory'; const isDir = entry.type === 'directory';
/*
A link, and where it goes.
Drawn as an arrow beside the name rather than as a badge or a different icon: the icon already says
what the thing IS — a folder, a photograph — and a symlink to a folder is still a folder to anyone
using it. What the arrow adds is that there is an indirection in the way, which is the thing that
explains why deleting it did not free any space, or why two places show the same file.
The target goes in `title`. It is often long and absolute, so it belongs in a tooltip rather than in
a row that has to fit a name, a type, a size and a date.
*/
const linkLabel = entry.symlink
? `${entry.broken ? 'Broken symlink' : 'Symlink'} → ${entry.symlink}`
: undefined;
const icon = isDir ? ( const icon = isDir ? (
<Folder className="h-5 w-5 text-duck-yellow fill-duck-yellow/30" /> <Folder className="h-5 w-5 text-duck-yellow fill-duck-yellow/30" />
) : ( ) : (
@@ -820,7 +836,18 @@ export const FileItem = ({
onCancel={() => setRenaming(false)} onCancel={() => setRenaming(false)}
/> />
) : ( ) : (
<span className="text-sm font-medium text-duck-dark truncate block">{entry.name}</span> <span className="flex min-w-0 items-center gap-1">
<span className="truncate text-sm font-medium text-duck-dark">{entry.name}</span>
{linkLabel && (
<span
title={linkLabel}
aria-label={linkLabel}
className={`shrink-0 leading-none ${entry.broken ? 'text-destructive' : 'text-duck-dark/45'}`}
>
<Link2 className="h-3.5 w-3.5" />
</span>
)}
</span>
)} )}
</div> </div>
<span className="hidden md:inline text-xs text-duck-dark/50 shrink-0 w-20 text-right"> <span className="hidden md:inline text-xs text-duck-dark/50 shrink-0 w-20 text-right">
@@ -886,7 +913,18 @@ export const FileItem = ({
onCancel={() => setRenaming(false)} onCancel={() => setRenaming(false)}
/> />
) : ( ) : (
<span className="w-full truncate text-center text-sm font-medium text-duck-dark">{entry.name}</span> <span className="flex w-full min-w-0 items-center justify-center gap-1">
<span className="truncate text-center text-sm font-medium text-duck-dark">{entry.name}</span>
{linkLabel && (
<span
title={linkLabel}
aria-label={linkLabel}
className={`shrink-0 leading-none ${entry.broken ? 'text-destructive' : 'text-duck-dark/45'}`}
>
<Link2 className="h-3.5 w-3.5" />
</span>
)}
</span>
)} )}
<span className="text-xs text-duck-dark/50"> <span className="text-xs text-duck-dark/50">
@@ -205,6 +205,20 @@ export type DirEntry = {
* response, which simply means no thumbnail — the file icon, exactly as before. * response, which simply means no thumbnail — the file icon, exactly as before.
*/ */
thumb?: 'image' | 'video' | null; thumb?: 'image' | 'video' | null;
/**
* Where this points, when it is a symlink — exactly as the link was written.
*
* Relative targets stay relative. `../originals` and `/home/…/originals` behave differently when the
* folder moves, and resolving one into the other would hide the difference at the moment somebody is
* looking to understand it.
*
* `type` still describes the TARGET, so a link to a folder opens like a folder. This says only that
* there is a link in the way. Absent on an older response, which reads as "not a link" — the same
* thing every entry said before.
*/
symlink?: string | null;
/** The target is gone. The row is drawn anyway, because the link itself is really there. */
broken?: boolean;
}; };
type ListDirResponse = { type ListDirResponse = {