import extensions from "components/system/Files/FileEntry/extensions"; import { getModifiedTime, getTextWrapData, } from "components/system/Files/FileEntry/functions"; import StyledFigure from "components/system/Files/FileEntry/StyledFigure"; import SubIcons from "components/system/Files/FileEntry/SubIcons"; 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 type { FileStat } from "components/system/Files/FileManager/functions"; import { isSelectionIntersecting } from "components/system/Files/FileManager/Selection/functions"; import type { SelectionRect } from "components/system/Files/FileManager/Selection/useSelection"; 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 } from "components/system/Files/Views"; import { FileEntryIconSize } from "components/system/Files/Views"; import { useFileSystem } from "contexts/fileSystem"; import { useProcesses } from "contexts/process"; import { m as motion } from "framer-motion"; import useDoubleClick from "hooks/useDoubleClick"; import dynamic from "next/dynamic"; import { basename, dirname, extname, join } from "path"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { useTheme } from "styled-components"; import Button from "styles/common/Button"; import Icon from "styles/common/Icon"; import { DEFAULT_LOCALE, 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, SMALLEST_PNG_SIZE, TRANSITIONS_IN_MILLISECONDS, USER_ICON_PATH, VIDEO_FILE_EXTENSIONS, } from "utils/constants"; import { bufferToUrl, getFormattedSize, getHtmlToImage, isCanvasEmpty, isYouTubeUrl, } from "utils/functions"; import { spotlightEffect } from "utils/spotlightEffect"; const Down = dynamic(() => import("components/apps/FileExplorer/NavigationIcons").then((mod) => mod.Down) ); const RenameBox = dynamic( () => import("components/system/Files/FileEntry/RenameBox") ); type FileEntryProps = { fileActions: FileActions; fileManagerId?: string; fileManagerRef: React.MutableRefObject; focusFunctions: FocusEntryFunctions; focusedEntries: string[]; hideShortcutIcon?: boolean; isLoadingFileManager: boolean; loadIconImmediately?: boolean; name: string; path: string; readOnly?: boolean; renaming: boolean; selectionRect?: SelectionRect; setRenaming: React.Dispatch>; stats: FileStat; useNewFolderIcon?: boolean; view: FileManagerViewNames; }; const truncateName = ( name: string, fontSize: string, fontFamily: string, maxWidth: number ): string => { const nonBreakingName = name.replace(/-/g, NON_BREAKING_HYPHEN); const { lines } = getTextWrapData( nonBreakingName, fontSize, fontFamily, maxWidth ); if (lines.length > 2) { const text = name.includes(" ") ? lines.slice(0, 2).join("") : lines[0]; return `${text.slice(0, -3)}...`; } return nonBreakingName; }; const focusing: string[] = []; const cacheQueue: (() => Promise)[] = []; const FileEntry: FC = ({ fileActions, fileManagerId, fileManagerRef, focusedEntries, focusFunctions, hideShortcutIcon, isLoadingFileManager, loadIconImmediately, name, path, readOnly, renaming, selectionRect, setRenaming, stats, useNewFolderIcon, view, }) => { const { blurEntry, focusEntry } = focusFunctions; const { url: changeUrl } = useProcesses(); const [{ comment, getIcon, icon, pid, subIcons, url }, setInfo] = useFileInfo( path, stats.isDirectory(), useNewFolderIcon ); const openFile = useFile(url); const { createPath, exists, mkdirRecursive, pasteList, readFile, stat, unlink, updateFolder, writeFile, } = useFileSystem(); const [showInFileManager, setShowInFileManager] = useState(false); const { formats, sizes } = useTheme(); const listView = view === "list"; const buttonRef = useRef(null); const figureRef = useRef(null); const fileName = basename(path); const urlExt = extname(url).toLowerCase(); const isDynamicIcon = useMemo( () => IMAGE_FILE_EXTENSIONS.has(urlExt) || VIDEO_FILE_EXTENSIONS.has(urlExt) || isYouTubeUrl(url), [url, urlExt] ); const isOnlyFocusedEntry = focusedEntries.length === 1 && focusedEntries[0] === fileName; const extension = extname(path).toLowerCase(); const isShortcut = extension === SHORTCUT_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); } }, directory, onDragLeave: () => buttonRef.current?.parentElement?.classList.remove("focus-within"), onDragOver: () => buttonRef.current?.parentElement?.classList.add("focus-within"), }); const openInFileExplorer = pid === "FileExplorer"; const truncatedName = useMemo( () => truncateName( name, sizes.fileEntry.fontSize, formats.systemFont, sizes.fileEntry[ listView ? "maxListTextDisplayWidth" : "maxIconTextDisplayWidth" ] ), [formats.systemFont, listView, name, sizes.fileEntry] ); const iconRef = useRef(null); const isIconCached = useRef(false); const isDynamicIconLoaded = useRef(false); const getIconAbortController = useRef(); const createTooltip = useCallback(async (): Promise => { if (stats.isDirectory()) return ""; if (isShortcut) { if (comment) return comment; if (url) { if (url.startsWith("http:") || url.startsWith("https:")) return url; return `Location: ${basename(url, extname(url))} (${dirname(url)})`; } return ""; } const type = extensions[extension]?.type || `${extension.toUpperCase().replace(".", "")} File`; const fullStats = stats.size < 0 ? await stat(path) : stats; const { size: sizeInBytes } = fullStats; const modifiedTime = getModifiedTime(path, fullStats); const size = getFormattedSize(sizeInBytes); const toolTip = `Type: ${type}${size === "-1" ? "" : `\nSize: ${size}`}`; const date = new Date(modifiedTime).toISOString().slice(0, 10); const time = new Intl.DateTimeFormat( DEFAULT_LOCALE, formats.dateModified ).format(modifiedTime); const dateModified = `${date} ${time}`; return `${toolTip}\nDate modified: ${dateModified}`; }, [ comment, extension, formats.dateModified, isShortcut, path, stat, stats, url, ]); const [tooltip, setTooltip] = useState(); const doubleClickHandler = useCallback(() => { if ( openInFileExplorer && fileManagerId && !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, ]); useEffect(() => { if (!isLoadingFileManager && !isIconCached.current) { const updateIcon = async (): Promise => { 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 (): Promise => { if (iconRef.current instanceof HTMLImageElement) { const nextQueueItem = (): Promise => { cacheQueue.shift(); return cacheQueue[0]?.(); }; let generatedIcon = ""; if ( iconRef.current.currentSrc.startsWith( "data:image/gif;base64," ) ) { generatedIcon = 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 && !isCanvasEmpty(iconCanvas)) { generatedIcon = iconCanvas.toDataURL("image/png"); } else { setTimeout(cacheIcon, 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 (isIconCached.current) return; const cachedIconPath = join( ICON_CACHE, `${path}${ICON_CACHE_EXTENSION}` ); if (await exists(cachedIconPath)) { const cachedIconData = await readFile(cachedIconPath); if (cachedIconData.length >= SMALLEST_PNG_SIZE) { if (!isIconCached.current) { isIconCached.current = true; setInfo((info) => ({ ...info, icon: bufferToUrl(cachedIconData), })); } } else { try { await unlink(cachedIconPath); updateFolder(dirname(path)); } catch { // Ignore issues deleting bad cached icon } } } else if ( !isDynamicIconLoaded.current && buttonRef.current && typeof getIcon === "function" ) { isDynamicIconLoaded.current = true; new IntersectionObserver( ([{ intersectionRatio }], observer) => { if (intersectionRatio > 0) { observer.disconnect(); getIconAbortController.current = new AbortController(); getIcon(getIconAbortController.current.signal); } }, { root: fileManagerRef.current, rootMargin: "5px" } ).observe(buttonRef.current); } } }; updateIcon(); } }, [ exists, fileManagerRef, getIcon, icon, isLoadingFileManager, mkdirRecursive, path, readFile, setInfo, unlink, 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 && focusedEntries.length === 1 && !buttonRef.current.contains(document.activeElement) ) { blurEntry(); focusEntry(fileName); buttonRef.current.focus(PREVENT_SCROLL); } } }, [ blurEntry, fileManagerRef, fileName, focusEntry, focusedEntries, selectionRect, ]); return ( <> {showInFileManager && ( )} ); }; export default FileEntry;