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:
@@ -1,6 +1,7 @@
|
||||
import { createRouter } from '@@/create-router';
|
||||
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 { getOwnerHomeDir, DATA_PATH } from '@@/data-path';
|
||||
import { resolveHomeDir } from '@@/user-home';
|
||||
@@ -188,9 +189,17 @@ router.get('/ls', async (ctx) => {
|
||||
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 {
|
||||
names = await readdir(absPath);
|
||||
names = await readdir(absPath, { withFileTypes: true });
|
||||
} catch (ex) {
|
||||
// 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 entries = await Promise.all(
|
||||
names.map(async (name) => {
|
||||
names.map(async (entry) => {
|
||||
const name = entry.name;
|
||||
const fullPath = resolve(absPath, name);
|
||||
// Skip entries that escape the home dir (shouldn't happen but be safe)
|
||||
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);
|
||||
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 {
|
||||
name,
|
||||
type: isDirectory ? 'directory' : 'file',
|
||||
size: s.size,
|
||||
modifiedAt: s.mtimeMs,
|
||||
size: own.size,
|
||||
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
|
||||
rather than one on each side of the wire.
|
||||
|
||||
Reference in New Issue
Block a user