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 { 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.
|
||||||
|
|||||||
+40
-2
@@ -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 = {
|
||||||
|
|||||||
Reference in New Issue
Block a user