diff --git a/src/servers/api/file-browser/router.ts b/src/servers/api/file-browser/router.ts index 91f5c4dd..70c2d4ea 100644 --- a/src/servers/api/file-browser/router.ts +++ b/src/servers/api/file-browser/router.ts @@ -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. diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx index 2c92a307..ab93be59 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx @@ -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 | 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 ? ( ) : ( @@ -820,7 +836,18 @@ export const FileItem = ({ onCancel={() => setRenaming(false)} /> ) : ( - {entry.name} + + {entry.name} + {linkLabel && ( + + + + )} + )} @@ -886,7 +913,18 @@ export const FileItem = ({ onCancel={() => setRenaming(false)} /> ) : ( - {entry.name} + + {entry.name} + {linkLabel && ( + + + + )} + )} diff --git a/src/workspaces/officerdev/src/hooks/useFilesAPI.ts b/src/workspaces/officerdev/src/hooks/useFilesAPI.ts index a10220a9..cdcb02a7 100644 --- a/src/workspaces/officerdev/src/hooks/useFilesAPI.ts +++ b/src/workspaces/officerdev/src/hooks/useFilesAPI.ts @@ -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 = {