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.
|
||||
|
||||
+40
-2
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Folder,
|
||||
Link2,
|
||||
Trash2,
|
||||
Pencil,
|
||||
MoreVertical,
|
||||
@@ -631,6 +632,21 @@ export const FileItem = ({
|
||||
const clickTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
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 ? (
|
||||
<Folder className="h-5 w-5 text-duck-yellow fill-duck-yellow/30" />
|
||||
) : (
|
||||
@@ -820,7 +836,18 @@ export const FileItem = ({
|
||||
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>
|
||||
<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)}
|
||||
/>
|
||||
) : (
|
||||
<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">
|
||||
|
||||
@@ -205,6 +205,20 @@ export type DirEntry = {
|
||||
* response, which simply means no thumbnail — the file icon, exactly as before.
|
||||
*/
|
||||
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 = {
|
||||
|
||||
Reference in New Issue
Block a user