daedalOS/components/system/Files/FileEntry/index.tsx
Dustin Brett 24c17c0088
Some checks failed
Tests / tests (push) Has been cancelled
Add memo to everything
2025-07-01 09:10:22 -07:00

677 lines
19 KiB
TypeScript

import { basename, dirname, extname, join } from "path";
import { useTheme } from "styled-components";
import {
memo,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import dynamic from "next/dynamic";
import { m as motion } from "motion/react";
import { type Columns } from "components/system/Files/FileManager/Columns/constants";
import StyledFigure from "components/system/Files/FileEntry/StyledFigure";
import SubIcons from "components/system/Files/FileEntry/SubIcons";
import {
getCachedIconUrl,
getCachedShortcut,
getDateModified,
getFileType,
getTextWrapData,
} from "components/system/Files/FileEntry/functions";
import useFile from "components/system/Files/FileEntry/useFile";
import useFileContextMenu from "components/system/Files/FileEntry/useFileContextMenu";
import useFileInfo from "components/system/Files/FileEntry/useFileInfo";
import FileManager from "components/system/Files/FileManager";
import { isSelectionIntersecting } from "components/system/Files/FileManager/Selection/functions";
import { type SelectionRect } from "components/system/Files/FileManager/Selection/useSelection";
import { type FileStat } from "components/system/Files/FileManager/functions";
import useFileDrop from "components/system/Files/FileManager/useFileDrop";
import { type FocusEntryFunctions } from "components/system/Files/FileManager/useFocusableEntries";
import { type FileActions } from "components/system/Files/FileManager/useFolder";
import {
type FileManagerViewNames,
FileEntryIconSize,
} from "components/system/Files/Views";
import { useFileSystem } from "contexts/fileSystem";
import { useProcesses } from "contexts/process";
import useDoubleClick from "hooks/useDoubleClick";
import Button from "styles/common/Button";
import Icon from "styles/common/Icon";
import {
ICON_CACHE,
ICON_CACHE_EXTENSION,
ICON_PATH,
IMAGE_FILE_EXTENSIONS,
LIST_VIEW_ANIMATION,
MOUNTABLE_EXTENSIONS,
NON_BREAKING_HYPHEN,
ONE_TIME_PASSIVE_EVENT,
PREVENT_SCROLL,
SHORTCUT_EXTENSION,
TRANSITIONS_IN_MILLISECONDS,
USER_ICON_PATH,
VIDEO_FILE_EXTENSIONS,
} from "utils/constants";
import {
blobToBase64,
bufferToUrl,
fetchBlob,
getExtension,
getFormattedSize,
getHtmlToImage,
hasFinePointer,
isCanvasDrawn,
isYouTubeUrl,
preloadImage,
} from "utils/functions";
import { spotlightEffect } from "utils/spotlightEffect";
import { useIsVisible } from "hooks/useIsVisible";
import { UNKNOWN_SIZE } from "contexts/fileSystem/core";
const ColumnRow = dynamic(
() => import("components/system/Files/FileEntry/ColumnRow")
);
const Down = dynamic(() =>
import("components/apps/FileExplorer/NavigationIcons").then((mod) => mod.Down)
);
const RenameBox = dynamic(
() => import("components/system/Files/FileEntry/RenameBox")
);
type FileEntryProps = {
columns?: Columns;
fileActions: FileActions;
fileManagerId?: string;
fileManagerRef: React.RefObject<HTMLOListElement | null>;
focusFunctions: FocusEntryFunctions;
focusedEntries: string[];
hasNewFolderIcon?: boolean;
hideShortcutIcon?: boolean;
isDesktop?: boolean;
isHeading?: boolean;
isLoadingFileManager: boolean;
loadIconImmediately?: boolean;
name: string;
path: string;
readOnly?: boolean;
renaming: boolean;
selectionRect?: SelectionRect;
setRenaming: React.Dispatch<React.SetStateAction<string>>;
stats: FileStat;
view: FileManagerViewNames;
};
const truncateName = (
name: string,
fontSize: string,
fontFamily: string,
maxWidth: number
): string => {
const nonBreakingName = name.replace(/-/g, NON_BREAKING_HYPHEN);
const { lines, truncatedText } = getTextWrapData(
nonBreakingName,
fontSize,
fontFamily,
maxWidth
);
if (lines.length > 2) {
const text = name.includes(" ") ? truncatedText : lines[0];
return `${text.slice(0, -3).trim()}...`;
}
return nonBreakingName;
};
const focusing: string[] = [];
const cacheQueue: (() => Promise<void>)[] = [];
const FileEntry: FC<FileEntryProps> = ({
columns,
fileActions,
fileManagerId,
fileManagerRef,
focusedEntries,
focusFunctions,
hideShortcutIcon,
isDesktop,
isHeading,
isLoadingFileManager,
loadIconImmediately,
name,
path,
readOnly,
renaming,
selectionRect,
setRenaming,
stats,
hasNewFolderIcon,
view,
}) => {
const { blurEntry, focusEntry } = focusFunctions;
const { url: changeUrl } = useProcesses();
const buttonRef = useRef<HTMLButtonElement | null>(null);
const isVisible = useIsVisible(buttonRef, fileManagerRef, isDesktop);
const isDirectory = useMemo(() => stats.isDirectory(), [stats]);
const [{ comment, getIcon, icon, pid, subIcons, url }, setInfo] = useFileInfo(
path,
isDirectory,
hasNewFolderIcon,
isDesktop || isVisible
);
const openFile = useFile(url, path);
const {
createPath,
exists,
fs,
mkdirRecursive,
pasteList,
readdir,
stat,
updateFolder,
writeFile,
} = useFileSystem();
const [showInFileManager, setShowInFileManager] = useState(false);
const { formats, sizes } = useTheme();
const listView = useMemo(() => view === "list", [view]);
const fileName = useMemo(() => basename(path), [path]);
const urlExt = useMemo(
() => (isDirectory ? "" : getExtension(url)),
[isDirectory, url]
);
const isYTUrl = useMemo(() => Boolean(url) && isYouTubeUrl(url), [url]);
const isDynamicIcon = useMemo(
() =>
IMAGE_FILE_EXTENSIONS.has(urlExt) ||
VIDEO_FILE_EXTENSIONS.has(urlExt) ||
isYTUrl,
[isYTUrl, urlExt]
);
const isOnlyFocusedEntry = useMemo(
() => focusedEntries.length === 1 && focusedEntries[0] === fileName,
[fileName, focusedEntries]
);
const extension = useMemo(() => getExtension(path), [path]);
const isShortcut = useMemo(
() => extension === SHORTCUT_EXTENSION,
[extension]
);
const directory = isShortcut ? url : path;
const fileDrop = useFileDrop({
callback: async (fileDropName, data) => {
if (!focusedEntries.includes(fileName)) {
const uniqueName = await createPath(fileDropName, directory, data);
if (uniqueName) {
updateFolder(directory, uniqueName);
return uniqueName;
}
}
return "";
},
directory,
});
const openInFileExplorer = useMemo(() => pid === "FileExplorer", [pid]);
const truncatedName = useMemo(
() =>
truncateName(
name,
sizes.fileEntry.fontSize,
formats.systemFont,
sizes.fileEntry[
listView ? "maxListTextDisplayWidth" : "maxIconTextDisplayWidth"
]
),
[formats.systemFont, listView, name, sizes.fileEntry]
);
const iconRef = useRef<HTMLImageElement | null>(null);
const isIconCached = useRef(false);
const isDynamicIconLoaded = useRef(false);
const getIconAbortController = useRef<AbortController>(undefined);
const createTooltip = useCallback(async (): Promise<string> => {
if (isDirectory) return "";
if (isShortcut) {
if (comment) return comment;
if (url) {
if (url.startsWith("http:") || url.startsWith("https:")) {
return decodeURIComponent(url);
}
const directoryPath = dirname(url);
return `Location: ${basename(url, extname(url))}${
!directoryPath || directoryPath === "." ? "" : ` (${dirname(url)})`
}`;
}
return "";
}
const type = getFileType(extension);
const fullStats = stats.size === UNKNOWN_SIZE ? await stat(path) : stats;
const { size: sizeInBytes } = fullStats;
const size = getFormattedSize(sizeInBytes);
const toolTip = `Type: ${type}${
size === "-1 bytes" ? "" : `\nSize: ${size}`
}`;
const dateModified = getDateModified(path, fullStats, formats.dateModified);
return `${toolTip}\nDate modified: ${dateModified}`;
}, [
comment,
extension,
formats.dateModified,
isDirectory,
isShortcut,
path,
stat,
stats,
url,
]);
const [tooltip, setTooltip] = useState<string>();
const doubleClickHandler = useCallback(() => {
if (
openInFileExplorer &&
fileManagerId &&
!window.globalKeyStates?.ctrlKey &&
!MOUNTABLE_EXTENSIONS.has(urlExt)
) {
changeUrl(fileManagerId, url);
blurEntry();
} else if (openInFileExplorer && listView) {
setShowInFileManager((currentState) => !currentState);
} else {
openFile(pid, isDynamicIcon ? undefined : icon);
}
}, [
blurEntry,
changeUrl,
fileManagerId,
icon,
isDynamicIcon,
listView,
openFile,
openInFileExplorer,
pid,
url,
urlExt,
]);
const showColumn = useMemo(
() => isVisible && columns !== undefined && view === "details",
[columns, isVisible, view]
);
const columnWidth = useMemo(
() =>
showColumn && columns
? columns.name.width - sizes.fileManager.detailsStartPadding
: 0,
[columns, showColumn, sizes.fileManager.detailsStartPadding]
);
const preloadedImages = useRef(false);
const preloadImages = useCallback(() => {
if (preloadedImages.current) return;
preloadedImages.current = true;
readdir(path).then((files) =>
files
.map((file) => getCachedShortcut(join(path, file)) || {})
.forEach(({ icon: image }) => image && preloadImage(image))
);
}, [path, readdir]);
const onMouseOverButton = useCallback(() => {
if (listView && isDirectory) preloadImages();
createTooltip().then(setTooltip);
}, [createTooltip, isDirectory, listView, preloadImages]);
const lockWidthStyle = useMemo(
() => ({ maxWidth: columnWidth, minWidth: columnWidth }),
[columnWidth]
);
const renameFile = useCallback(
(origPath: string, newName?: string) => {
fileActions.renameFile(origPath, newName);
setRenaming("");
},
[fileActions, setRenaming]
);
useEffect(() => {
if (!isLoadingFileManager && isVisible && !isIconCached.current) {
const updateIcon = async (): Promise<void> => {
if (icon.startsWith("blob:") || icon.startsWith("data:")) {
if (icon.startsWith("data:image/jpeg;base64,")) return;
isIconCached.current = true;
const cachedIconPath = join(
ICON_CACHE,
`${path}${ICON_CACHE_EXTENSION}`
);
if (
urlExt !== ".ico" &&
!url.startsWith(ICON_PATH) &&
!url.startsWith(USER_ICON_PATH) &&
!(await exists(cachedIconPath)) &&
iconRef.current instanceof HTMLImageElement
) {
const cacheIcon = async (
retryCanvasDraw?: boolean
): Promise<void> => {
if (iconRef.current instanceof HTMLImageElement) {
const nextQueueItem = (): Promise<void> => {
cacheQueue.shift();
return cacheQueue[0]?.();
};
let generatedIcon = "";
if (
iconRef.current.currentSrc.startsWith(
"data:image/gif;base64,"
) ||
iconRef.current.currentSrc.startsWith(
"data:image/png;base64,"
)
) {
generatedIcon = iconRef.current.currentSrc;
} else if (iconRef.current.currentSrc.startsWith("blob:")) {
generatedIcon = await blobToBase64(
await fetchBlob(iconRef.current.currentSrc)
);
} else {
const { clientHeight, clientWidth } = iconRef.current;
const { naturalHeight, naturalWidth } = iconRef.current;
const naturalAspectRatio = naturalWidth / naturalHeight;
const clientAspectRatio = clientWidth / clientHeight;
let height: number | undefined;
let width: number | undefined;
if (naturalAspectRatio !== clientAspectRatio) {
if (naturalWidth > naturalHeight) {
height = clientHeight / naturalAspectRatio;
} else {
width = clientWidth * naturalAspectRatio;
}
}
const htmlToImage = await getHtmlToImage();
let iconCanvas: HTMLCanvasElement | undefined;
try {
iconCanvas = await htmlToImage?.toCanvas(iconRef.current, {
height,
skipAutoScale: true,
style: {
objectPosition: height
? "top"
: width
? "left"
: undefined,
},
width,
});
} catch {
// Ignore failure to capture
}
if (
iconCanvas &&
(isCanvasDrawn(iconCanvas) || retryCanvasDraw)
) {
generatedIcon = iconCanvas.toDataURL("image/png");
} else {
setTimeout(
() => cacheIcon(true),
TRANSITIONS_IN_MILLISECONDS.WINDOW
);
}
}
if (generatedIcon) {
cacheQueue.push(async () => {
const baseCachedPath = dirname(cachedIconPath);
await mkdirRecursive(baseCachedPath);
const cachedIcon = Buffer.from(
generatedIcon.replace(/data:.*;base64,/, ""),
"base64"
);
await writeFile(cachedIconPath, cachedIcon, true);
setInfo((info) => ({
...info,
icon: bufferToUrl(cachedIcon),
}));
updateFolder(baseCachedPath, basename(cachedIconPath));
return nextQueueItem();
});
}
if (cacheQueue.length === 1) await cacheQueue[0]();
}
};
if (iconRef.current.complete) cacheIcon();
else {
iconRef.current.addEventListener(
"load",
() => cacheIcon(),
ONE_TIME_PASSIVE_EVENT
);
}
}
} else if (!isShortcut || typeof getIcon === "function" || isYTUrl) {
if (isIconCached.current || !fs) return;
const cachedIconUrl = await getCachedIconUrl(
fs,
join(ICON_CACHE, `${path}${ICON_CACHE_EXTENSION}`)
);
if (cachedIconUrl) {
if (!isIconCached.current) {
isIconCached.current = true;
setInfo((info) => ({ ...info, icon: cachedIconUrl }));
}
} else if (
!isDynamicIconLoaded.current &&
buttonRef.current &&
typeof getIcon === "function"
) {
getIconAbortController.current = new AbortController();
await getIcon(getIconAbortController.current.signal);
isDynamicIconLoaded.current =
!getIconAbortController.current.signal.aborted;
}
}
};
updateIcon();
}
if (!isVisible && getIconAbortController.current) {
getIconAbortController.current.abort();
}
}, [
exists,
fs,
getIcon,
icon,
isLoadingFileManager,
isShortcut,
isVisible,
isYTUrl,
mkdirRecursive,
path,
setInfo,
updateFolder,
url,
urlExt,
writeFile,
]);
useEffect(
() => () => {
try {
getIconAbortController.current?.abort();
} catch {
// Failed to abort getIcon
}
},
[]
);
useLayoutEffect(() => {
if (buttonRef.current && fileManagerRef.current) {
const inFocusedEntries = focusedEntries.includes(fileName);
const inFocusing = focusing.includes(fileName);
const isFocused = inFocusedEntries || inFocusing;
if (inFocusedEntries && inFocusing) {
focusing.splice(focusing.indexOf(fileName), 1);
}
if (selectionRect) {
const selected = isSelectionIntersecting(
buttonRef.current.getBoundingClientRect(),
fileManagerRef.current.getBoundingClientRect(),
selectionRect,
fileManagerRef.current.scrollTop
);
if (selected && !isFocused) {
focusing.push(fileName);
focusEntry(fileName);
buttonRef.current.focus(PREVENT_SCROLL);
} else if (!selected && isFocused) {
blurEntry(fileName);
}
} else if (
isFocused &&
buttonRef.current !== document.activeElement &&
focusedEntries.length === 1 &&
!buttonRef.current.contains(document.activeElement)
) {
buttonRef.current.focus(PREVENT_SCROLL);
}
}
}, [
blurEntry,
fileManagerRef,
fileName,
focusEntry,
focusedEntries,
selectionRect,
]);
return (
<>
<Button
ref={buttonRef}
aria-label={name}
onMouseOverCapture={onMouseOverButton}
title={tooltip}
{...(listView && { ...LIST_VIEW_ANIMATION, as: motion.button })}
{...useDoubleClick(doubleClickHandler, listView)}
{...(openInFileExplorer && fileDrop)}
{...useFileContextMenu(
url,
pid,
path,
setRenaming,
fileActions,
focusFunctions,
focusedEntries,
stats,
fileManagerId,
readOnly
)}
>
<StyledFigure
ref={useCallback(
(figureRef: HTMLElement) => {
if (listView && hasFinePointer()) spotlightEffect(figureRef);
},
[listView]
)}
$renaming={renaming}
style={showColumn ? lockWidthStyle : undefined}
{...(isHeading && {
"aria-level": 1,
role: "heading",
})}
>
<Icon
ref={iconRef}
$eager={loadIconImmediately}
$moving={pasteList[path] === "move"}
alt=""
src={icon}
{...FileEntryIconSize[view]}
/>
<SubIcons
alt=""
icon={icon}
isDesktop={isDesktop}
showShortcutIcon={Boolean(hideShortcutIcon || stats.systemShortcut)}
subIcons={subIcons}
view={view}
/>
{renaming ? (
<RenameBox
isDesktop={isDesktop}
name={name}
path={path}
renameFile={renameFile}
setRenaming={setRenaming}
view={view}
/>
) : (
<figcaption>
{!showColumn &&
(!isOnlyFocusedEntry || name.length === truncatedName.length)
? truncatedName
: name}
</figcaption>
)}
{listView && openInFileExplorer && <Down flip={showInFileManager} />}
</StyledFigure>
{showColumn && columns && (
<ColumnRow
columns={columns}
isDirectory={isDirectory}
path={path}
stats={stats}
/>
)}
</Button>
{showInFileManager && (
<FileManager
url={url}
hideFolders
hideLoading
hideShortcutIcons
isStartMenu
loadIconsImmediately
readOnly
skipFsWatcher
skipSorting
/>
)}
</>
);
};
export default memo(FileEntry);