diff --git a/components/apps/FileExplorer/StyledFileExplorer.ts b/components/apps/FileExplorer/StyledFileExplorer.ts index 1e6e0e2366..b0d10c720d 100644 --- a/components/apps/FileExplorer/StyledFileExplorer.ts +++ b/components/apps/FileExplorer/StyledFileExplorer.ts @@ -1,12 +1,16 @@ import styled from "styled-components"; import StyledLoading from "components/system/Files/FileManager/StyledLoading"; +import StyledDetailsFileManager from "components/system/Files/Views/Details/StyledFileManager"; import StyledIconFileManager from "components/system/Files/Views/Icon/StyledFileManager"; const StyledFileExplorer = styled.div` - ${StyledIconFileManager} { - column-gap: 2px; + ${StyledDetailsFileManager}, ${StyledIconFileManager} { height: ${({ theme }) => `calc(100% - ${theme.sizes.fileExplorer.navBarHeight} - ${theme.sizes.fileExplorer.statusBarHeight})`}; + } + + ${StyledIconFileManager} { + column-gap: 2px; padding: 6px; figcaption { diff --git a/components/apps/FileExplorer/index.tsx b/components/apps/FileExplorer/index.tsx index d89dbc2062..2129191fbd 100644 --- a/components/apps/FileExplorer/index.tsx +++ b/components/apps/FileExplorer/index.tsx @@ -110,7 +110,7 @@ const FileExplorer: FC = ({ id }) => { return url ? ( - + ) : // eslint-disable-next-line unicorn/no-null null; diff --git a/components/system/Desktop/index.tsx b/components/system/Desktop/index.tsx index 63e87b04e0..c94097783c 100644 --- a/components/system/Desktop/index.tsx +++ b/components/system/Desktop/index.tsx @@ -13,7 +13,6 @@ const Desktop: FC = ({ children }) => { = ({ icon, id, isShortcut, pid, url }) => { const { closeWithTransition, icon: setIcon } = useProcesses(); const { setIconPositions } = useSession(); const extension = useMemo(() => getExtension(url || ""), [url]); - const { type } = extensions[extension] || {}; - const extType = type || `${extension.toUpperCase().replace(".", "")} File`; + const extType = getFileType(extension); const inputRef = useRef(null); const { fs, readdir, rename, stat, updateFolder } = useFileSystem(); const stats = useStats(url); diff --git a/components/system/Files/FileEntry/ColumnRow.tsx b/components/system/Files/FileEntry/ColumnRow.tsx new file mode 100644 index 0000000000..f8d8203d0d --- /dev/null +++ b/components/system/Files/FileEntry/ColumnRow.tsx @@ -0,0 +1,64 @@ +import type Stats from "browserfs/dist/node/core/node_fs_stats"; +import { useCallback, useState, useRef, useEffect, memo } from "react"; +import { useTheme } from "styled-components"; +import StyledColumnRow from "components/system/Files/FileEntry/StyledColumnRow"; +import { type Columns } from "components/system/Files/FileManager/Columns/constants"; +import { + getDateModified, + getFileType, +} from "components/system/Files/FileEntry/functions"; +import { UNKNOWN_SIZE } from "contexts/fileSystem/core"; +import { useFileSystem } from "contexts/fileSystem"; +import { getExtension, getFormattedSize } from "utils/functions"; + +type ColumnDataProps = { + date: string; + size: string; + type: string; +}; + +const ColumnRow: FC<{ + columns: Columns; + isDirectory: boolean; + isShortcut: boolean; + path: string; + stats: Stats; +}> = ({ columns, isDirectory, isShortcut, path, stats }) => { + const { stat } = useFileSystem(); + const { formats } = useTheme(); + const getColumnData = useCallback(async (): Promise => { + const fullStats = stats.size === UNKNOWN_SIZE ? await stat(path) : stats; + + return { + date: getDateModified(path, fullStats, formats.dateModified), + size: isDirectory ? "" : getFormattedSize(fullStats.size, true), + type: isDirectory + ? "File folder" + : isShortcut + ? "Shortcut" + : getFileType(getExtension(path)), + }; + }, [formats.dateModified, isDirectory, isShortcut, path, stat, stats]); + const [columnData, setColumnData] = useState(); + const creatingRef = useRef(false); + + useEffect(() => { + if (!columnData && !creatingRef.current) { + creatingRef.current = true; + getColumnData().then((newColumnData) => { + setColumnData(newColumnData); + creatingRef.current = false; + }); + } + }, [columnData, getColumnData]); + + return ( + +
{columnData?.date}
+
{columnData?.type}
+
{columnData?.size}
+
+ ); +}; + +export default memo(ColumnRow); diff --git a/components/system/Files/FileEntry/StyledColumnRow.ts b/components/system/Files/FileEntry/StyledColumnRow.ts new file mode 100644 index 0000000000..0d11fd0b6c --- /dev/null +++ b/components/system/Files/FileEntry/StyledColumnRow.ts @@ -0,0 +1,19 @@ +import styled from "styled-components"; + +const StyledColumnRow = styled.div` + display: flex; + + div { + color: #fff; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + &:last-child { + margin-right: -6px; + padding-right: 6px; + } + } +`; + +export default StyledColumnRow; diff --git a/components/system/Files/FileEntry/SubIcons.tsx b/components/system/Files/FileEntry/SubIcons.tsx index f413e98226..c03f544138 100644 --- a/components/system/Files/FileEntry/SubIcons.tsx +++ b/components/system/Files/FileEntry/SubIcons.tsx @@ -19,7 +19,7 @@ type IconProps = { }; type SharedSubIconProps = { - imgSize?: 64 | 32 | 16; + imgSize?: 64 | 32 | 16 | 8; isDesktop?: boolean; }; @@ -51,18 +51,25 @@ const SubIcon: FC = ({ totalSubIcons, view, }) => { - const iconView = useMemo( - () => - FileEntryIconSize[ - ![SHORTCUT_ICON, FOLDER_FRONT_ICON].includes(icon) && - !icon.startsWith("blob:") && - !icon.startsWith(ICON_CACHE) && - !icon.startsWith(YT_ICON_CACHE) - ? "sub" - : view - ], - [icon, view] - ); + const iconView = useMemo(() => { + const isSub = + ![SHORTCUT_ICON, FOLDER_FRONT_ICON].includes(icon) && + !icon.startsWith("blob:") && + !icon.startsWith(ICON_CACHE) && + !icon.startsWith(YT_ICON_CACHE); + + if (icon === SHORTCUT_ICON && view === "details") { + return { + displaySize: 16, + imgSize: 48, + }; + } + + return FileEntryIconSize[ + isSub ? (view === "details" ? "detailsSub" : "sub") : view + ]; + }, [icon, view]); + const style = useMemo((): React.CSSProperties | undefined => { if (icon === FOLDER_FRONT_ICON) return { zIndex: 3 }; @@ -120,10 +127,19 @@ const SubIcons: FC = ({ : subIcons, [showShortcutIcon, subIcons] ); - const filteredSubIcons = useMemo( - () => icons?.filter((subIcon) => subIcon !== icon) || [], - [icon, icons] - ); + const filteredSubIcons = useMemo(() => { + const iconsLength = icons?.length; + + if ( + iconsLength && + view === "details" && + icons[iconsLength - 1] === FOLDER_FRONT_ICON + ) { + return []; + } + + return icons?.filter((subIcon) => subIcon !== icon) || []; + }, [icon, icons, view]); return ( <> diff --git a/components/system/Files/FileEntry/functions.ts b/components/system/Files/FileEntry/functions.ts index 43d5a174c1..e11be9236e 100644 --- a/components/system/Files/FileEntry/functions.ts +++ b/components/system/Files/FileEntry/functions.ts @@ -13,6 +13,7 @@ import processDirectory from "contexts/process/directory"; import { AUDIO_FILE_EXTENSIONS, BASE_2D_CONTEXT_OPTIONS, + DEFAULT_LOCALE, DYNAMIC_EXTENSION, DYNAMIC_PREFIX, FOLDER_BACK_ICON, @@ -78,7 +79,13 @@ export const isExistingFile = ( export const getModifiedTime = (path: string, stats: FileStat): number => { const { mtimeMs } = stats; - return isExistingFile(stats) ? get9pModifiedTime(path) || mtimeMs : mtimeMs; + if (isExistingFile(stats)) { + const storedMtime = get9pModifiedTime(path); + + if (storedMtime > 0) return storedMtime; + } + + return mtimeMs; }; export const getIconFromIni = ( @@ -879,3 +886,21 @@ export const getTextWrapData = ( width: Math.min(maxWidth, totalWidth), }; }; + +export const getDateModified = ( + path: string, + fullStats: Stats, + format: Intl.DateTimeFormatOptions +): string => { + const modifiedTime = getModifiedTime(path, fullStats); + const date = new Date(modifiedTime).toISOString().slice(0, 10); + const time = new Intl.DateTimeFormat(DEFAULT_LOCALE, format).format( + modifiedTime + ); + + return `${date} ${time}`; +}; + +export const getFileType = (extension: string): string => + extensions[extension]?.type || + `${extension.toUpperCase().replace(".", "")} File`; diff --git a/components/system/Files/FileEntry/index.tsx b/components/system/Files/FileEntry/index.tsx index e5fb0dd268..4a3ead3dc6 100644 --- a/components/system/Files/FileEntry/index.tsx +++ b/components/system/Files/FileEntry/index.tsx @@ -10,12 +10,14 @@ import { } from "react"; import dynamic from "next/dynamic"; import { m as motion } from "framer-motion"; +import ColumnRow from "components/system/Files/FileEntry/ColumnRow"; +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 extensions from "components/system/Files/FileEntry/extensions"; import { getCachedIconUrl, - getModifiedTime, + getDateModified, + getFileType, getTextWrapData, } from "components/system/Files/FileEntry/functions"; import useFile from "components/system/Files/FileEntry/useFile"; @@ -38,7 +40,6 @@ import useDoubleClick from "hooks/useDoubleClick"; import Button from "styles/common/Button"; import Icon from "styles/common/Icon"; import { - DEFAULT_LOCALE, ICON_CACHE, ICON_CACHE_EXTENSION, ICON_PATH, @@ -76,6 +77,7 @@ const RenameBox = dynamic( ); type FileEntryProps = { + columns?: Columns; fileActions: FileActions; fileManagerId?: string; fileManagerRef: React.MutableRefObject; @@ -129,6 +131,7 @@ const focusing: string[] = []; const cacheQueue: (() => Promise)[] = []; const FileEntry: FC = ({ + columns, fileActions, fileManagerId, fileManagerRef, @@ -153,9 +156,10 @@ const FileEntry: FC = ({ const { url: changeUrl } = useProcesses(); const buttonRef = useRef(null); const isVisible = useIsVisible(buttonRef, fileManagerRef, isDesktop); + const isDirectory = useMemo(() => stats.isDirectory(), [stats]); const [{ comment, getIcon, icon, pid, subIcons, url }, setInfo] = useFileInfo( path, - stats.isDirectory(), + isDirectory, hasNewFolderIcon, isDesktop || isVisible ); @@ -226,7 +230,7 @@ const FileEntry: FC = ({ const isDynamicIconLoaded = useRef(false); const getIconAbortController = useRef(); const createTooltip = useCallback(async (): Promise => { - if (stats.isDirectory()) return ""; + if (isDirectory) return ""; if (isShortcut) { if (comment) return comment; @@ -244,28 +248,21 @@ const FileEntry: FC = ({ return ""; } - const type = - extensions[extension]?.type || - `${extension.toUpperCase().replace(".", "")} File`; + const type = getFileType(extension); const fullStats = stats.size === UNKNOWN_SIZE ? await stat(path) : stats; const { size: sizeInBytes } = fullStats; - const modifiedTime = getModifiedTime(path, fullStats); const size = getFormattedSize(sizeInBytes); const toolTip = `Type: ${type}${ size === "-1 bytes" ? "" : `\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}`; + const dateModified = getDateModified(path, fullStats, formats.dateModified); return `${toolTip}\nDate modified: ${dateModified}`; }, [ comment, extension, formats.dateModified, + isDirectory, isShortcut, path, stat, @@ -300,6 +297,17 @@ const FileEntry: FC = ({ 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] + ); useEffect(() => { if (!isLoadingFileManager && isVisible && !isIconCached.current) { @@ -564,6 +572,11 @@ const FileEntry: FC = ({ [listView] )} $renaming={renaming} + style={ + showColumn + ? { maxWidth: columnWidth, minWidth: columnWidth } + : undefined + } {...(isHeading && { "aria-level": 1, role: "heading", @@ -605,14 +618,23 @@ const FileEntry: FC = ({ )} {listView && openInFileExplorer && } + {showColumn && columns && ( + + )} {showInFileManager && ( theme.sizes.fileManager.detailsStartPadding}px; + position: sticky; + top: 0; + width: fit-content; + z-index: 1; + + ol { + display: flex; + height: ${({ theme }) => theme.sizes.fileManager.columnHeight}; + + li { + color: rgb(222, 222, 222); + display: flex; + font-size: 12px; + padding-left: 6px; + place-items: center; + position: relative; + top: -1px; + + div { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &::after { + border-right: 1px solid rgb(99, 99, 99); + content: ""; + cursor: col-resize; + height: 25px; + padding-left: ${({ theme }) => + theme.sizes.fileManager.columnResizeWidth}px; + position: absolute; + right: 0; + } + + &:hover { + background-color: rgb(67, 67, 67); + + &::after { + border-right: none; + } + } + + &:active { + background-color: rgb(131, 131, 131); + } + + &:first-child { + padding-left: 17px; + } + } + } +`; + +export default StyledColumns; diff --git a/components/system/Files/FileManager/Columns/constants.ts b/components/system/Files/FileManager/Columns/constants.ts new file mode 100644 index 0000000000..5396a35569 --- /dev/null +++ b/components/system/Files/FileManager/Columns/constants.ts @@ -0,0 +1,35 @@ +type ColumnData = { + name: string; + width: number; +}; + +export const MAX_STEPS_PER_RESIZE = 15; +export type ColumnName = "date" | "name" | "size" | "type"; + +export type Columns = Record; + +export const DEFAULT_COLUMN_ORDER: ColumnName[] = [ + "name", + "date", + "type", + "size", +]; + +export const DEFAULT_COLUMNS: Columns = { + date: { + name: "Date modified", + width: 150, + }, + name: { + name: "Name", + width: 150, + }, + size: { + name: "Size", + width: 80, + }, + type: { + name: "Type", + width: 80, + }, +}; diff --git a/components/system/Files/FileManager/Columns/index.tsx b/components/system/Files/FileManager/Columns/index.tsx new file mode 100644 index 0000000000..7ac8c47825 --- /dev/null +++ b/components/system/Files/FileManager/Columns/index.tsx @@ -0,0 +1,105 @@ +import { memo, useRef } from "react"; +import { useTheme } from "styled-components"; +import { sortFiles } from "components/system/Files/FileManager/functions"; +import { type SortBy } from "components/system/Files/FileManager/useSortBy"; +import StyledColumns from "components/system/Files/FileManager/Columns/StyledColumns"; +import { + DEFAULT_COLUMN_ORDER, + MAX_STEPS_PER_RESIZE, + type ColumnName, + type Columns as ColumnsObject, +} from "components/system/Files/FileManager/Columns/constants"; +import { useSession } from "contexts/session"; +import { type Files } from "components/system/Files/FileManager/useFolder"; + +type ColumnsProps = { + columns: ColumnsObject; + directory: string; + files: Files; + setColumns: React.Dispatch>; +}; + +const Columns: FC = ({ + columns, + directory, + files, + setColumns, +}) => { + const { sizes } = useTheme(); + const draggingRef = useRef(""); + const lastClientX = useRef(0); + const { setSortOrder, sortOrders } = useSession(); + // eslint-disable-next-line unicorn/no-unreadable-array-destructuring + const [, , ascending] = sortOrders[directory] ?? []; + + return ( + +
    + {DEFAULT_COLUMN_ORDER.map((name) => ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions +
  1. { + const sortBy = name as SortBy; + + setSortOrder( + directory, + Object.keys(sortFiles(directory, files, sortBy, !ascending)), + sortBy, + !ascending + ); + }} + onPointerDownCapture={(event) => { + const widthToEdge = + (event.target as HTMLElement).clientWidth - + event.nativeEvent.offsetX; + const startDragging = + widthToEdge <= sizes.fileManager.columnResizeWidth && + widthToEdge >= 0; + + draggingRef.current = startDragging ? name : ""; + lastClientX.current = event.clientX; + }} + onPointerMoveCapture={(event) => { + if (draggingRef.current) { + const dragName = draggingRef.current as ColumnName; + + setColumns((currentColumns) => { + if (!currentColumns?.[dragName]) return currentColumns; + + const newColumns = { ...currentColumns }; + const newSize = + newColumns[dragName].width + + event.clientX - + lastClientX.current; + + if ( + newSize < sizes.fileManager.columnMinWidth || + Math.abs(lastClientX.current - event.clientX) > + MAX_STEPS_PER_RESIZE + ) { + return newColumns; + } + + newColumns[dragName].width = newSize; + lastClientX.current = event.clientX; + + return newColumns; + }); + } + }} + onPointerUpCapture={() => { + draggingRef.current = ""; + lastClientX.current = 0; + }} + style={{ width: `${columns[name].width}px` }} + > +
    {columns[name].name}
    +
  2. + ))} +
+
+ ); +}; + +export default memo(Columns); diff --git a/components/system/Files/FileManager/Selection/StyledSelection.tsx b/components/system/Files/FileManager/Selection/StyledSelection.tsx index fd0a96e16d..d29aab480c 100644 --- a/components/system/Files/FileManager/Selection/StyledSelection.tsx +++ b/components/system/Files/FileManager/Selection/StyledSelection.tsx @@ -6,7 +6,7 @@ const NoGlobalPointerEvents = createGlobalStyle` } `; -const StyledSelectionComponent = styled.span` +export const StyledSelectionComponent = styled.span` background-color: ${({ theme }) => theme.colors.selectionHighlightBackground}; border: ${({ theme }) => `1px solid ${theme.colors.selectionHighlight}`}; position: absolute; diff --git a/components/system/Files/FileManager/StatusBar.tsx b/components/system/Files/FileManager/StatusBar.tsx index 71f733d7bd..7b45fe4672 100644 --- a/components/system/Files/FileManager/StatusBar.tsx +++ b/components/system/Files/FileManager/StatusBar.tsx @@ -6,21 +6,26 @@ import { useRef, useState, } from "react"; +import { useTheme } from "styled-components"; +import { type FileManagerViewNames } from "components/system/Files/Views"; import StyledStatusBar from "components/system/Files/FileManager/StyledStatusBar"; import { type FileDrop } from "components/system/Files/FileManager/useFileDrop"; import { useFileSystem } from "contexts/fileSystem"; import useResizeObserver from "hooks/useResizeObserver"; import { getFormattedSize, haltEvent, label } from "utils/functions"; import { UNKNOWN_SIZE } from "contexts/fileSystem/core"; +import Icon from "styles/common/Icon"; +import Button from "styles/common/Button"; type StatusBarProps = { count: number; directory: string; fileDrop: FileDrop; selected: string[]; + setView: (view: FileManagerViewNames) => void; + view: FileManagerViewNames; }; -const MINIMUM_STATUSBAR_WIDTH = 225; const UNCALCULATED_SIZE = -2; const StatusBar: FC = ({ @@ -28,12 +33,18 @@ const StatusBar: FC = ({ directory, fileDrop, selected, + setView, + view, }) => { const { exists, lstat, stat } = useFileSystem(); const [selectedSize, setSelectedSize] = useState(UNKNOWN_SIZE); const [showSelected, setShowSelected] = useState(false); - const updateShowSelected = (width: number): void => - setShowSelected(width > MINIMUM_STATUSBAR_WIDTH); + const { sizes } = useTheme(); + const updateShowSelected = useCallback( + (width: number): void => + setShowSelected(width > sizes.fileExplorer.minimumStatusBarWidth), + [sizes.fileExplorer.minimumStatusBarWidth] + ); const statusBarRef = useRef(null); useEffect(() => { @@ -68,13 +79,13 @@ const StatusBar: FC = ({ if (statusBarRef.current) { updateShowSelected(statusBarRef.current.getBoundingClientRect().width); } - }, []); + }, [updateShowSelected]); useResizeObserver( statusBarRef.current, useCallback( ([{ contentRect: { width = 0 } = {} }]) => updateShowSelected(width), - [] + [updateShowSelected] ) ); @@ -95,6 +106,30 @@ const StatusBar: FC = ({ : ""} )} + ); }; diff --git a/components/system/Files/FileManager/StyledStatusBar.ts b/components/system/Files/FileManager/StyledStatusBar.ts index f26eeae8e1..83468bacd9 100644 --- a/components/system/Files/FileManager/StyledStatusBar.ts +++ b/components/system/Files/FileManager/StyledStatusBar.ts @@ -8,7 +8,7 @@ const StyledStatusBar = styled.footer` font-size: 12px; font-weight: 200; height: ${({ theme }) => theme.sizes.fileExplorer.statusBarHeight}; - padding: 0 5px; + padding: 0 4px 0 5px; position: relative; white-space: nowrap; width: 100%; @@ -35,6 +35,36 @@ const StyledStatusBar = styled.footer` } } } + + nav { + display: flex; + position: absolute; + right: 4px; + + button { + border: 1px solid transparent; + display: flex; + height: ${({ theme }) => theme.sizes.fileExplorer.statusBarHeight}; + place-content: center; + place-items: center; + width: 22px; + + &:hover { + background-color: rgb(77, 77, 77); + border: 1px solid rgb(99, 99, 99); + } + + &.active { + background-color: rgb(102, 102, 102); + border: 1px solid rgb(131, 131, 131); + + picture { + padding-left: 1px; + padding-top: 1px; + } + } + } + } `; export default StyledStatusBar; diff --git a/components/system/Files/FileManager/index.tsx b/components/system/Files/FileManager/index.tsx index a245e3d84e..40a5c82684 100644 --- a/components/system/Files/FileManager/index.tsx +++ b/components/system/Files/FileManager/index.tsx @@ -1,6 +1,10 @@ import { basename, join } from "path"; import { memo, useEffect, useMemo, useRef, useState } from "react"; import dynamic from "next/dynamic"; +import { + DEFAULT_COLUMNS, + type Columns as ColumnsObject, +} from "components/system/Files/FileManager/Columns/constants"; import FileEntry from "components/system/Files/FileEntry"; import StyledSelection from "components/system/Files/FileManager/Selection/StyledSelection"; import useSelection from "components/system/Files/FileManager/Selection/useSelection"; @@ -10,10 +14,7 @@ import useFileKeyboardShortcuts from "components/system/Files/FileManager/useFil import useFocusableEntries from "components/system/Files/FileManager/useFocusableEntries"; import useFolder from "components/system/Files/FileManager/useFolder"; import useFolderContextMenu from "components/system/Files/FileManager/useFolderContextMenu"; -import { - type FileManagerViewNames, - FileManagerViews, -} from "components/system/Files/Views"; +import { FileManagerViews } from "components/system/Files/Views"; import { useFileSystem } from "contexts/fileSystem"; import { FOCUSABLE_ELEMENT, @@ -22,6 +23,8 @@ import { SHORTCUT_EXTENSION, } from "utils/constants"; import { getExtension, haltEvent } from "utils/functions"; +import Columns from "components/system/Files/FileManager/Columns"; +import { useSession } from "contexts/session"; const StatusBar = dynamic( () => import("components/system/Files/FileManager/StatusBar") @@ -50,9 +53,10 @@ type FileManagerProps = { skipFsWatcher?: boolean; skipSorting?: boolean; url: string; - view: FileManagerViewNames; }; +const DEFAULT_VIEW = "icon"; + const FileManager: FC = ({ allowMovingDraggableEntries, hideFolders, @@ -68,8 +72,18 @@ const FileManager: FC = ({ skipFsWatcher, skipSorting, url, - view, }) => { + const { views, setViews } = useSession(); + const view = useMemo(() => { + if (isDesktop) return "icon"; + if (isStartMenu) return "list"; + + return views[url] || DEFAULT_VIEW; + }, [isDesktop, isStartMenu, url, views]); + const isDetailsView = useMemo(() => view === "details", [view]); + const [columns, setColumns] = useState(() => + isDetailsView ? DEFAULT_COLUMNS : undefined + ); const [currentUrl, setCurrentUrl] = useState(url); const [renaming, setRenaming] = useState(""); const [mounted, setMounted] = useState(false); @@ -129,7 +143,7 @@ const FileManager: FC = ({ !isDesktop && !isStartMenu && !loading && - view === "icon" && + view !== "list" && fileKeys.length === 0; useEffect(() => { @@ -198,6 +212,10 @@ const FileManager: FC = ({ } }, [isDesktop, isStartMenu, loading]); + useEffect(() => { + setColumns(isDetailsView ? DEFAULT_COLUMNS : undefined); + }, [isDetailsView]); + return ( <> {loading ? ( @@ -220,6 +238,14 @@ const FileManager: FC = ({ })} {...FOCUSABLE_ELEMENT} > + {isDetailsView && columns && ( + + )} {isSelecting && } {fileKeys.map((file) => ( = ({ {...focusableEntry(file)} > = ({ directory={url} fileDrop={fileDrop} selected={focusedEntries} + setView={(newView) => { + setViews((currentViews) => ({ ...currentViews, [url]: newView })); + setColumns(newView === "details" ? DEFAULT_COLUMNS : undefined); + }} + view={view} /> )} diff --git a/components/system/Files/Views/Details/StyledFileEntry.ts b/components/system/Files/Views/Details/StyledFileEntry.ts new file mode 100644 index 0000000000..e40162ba5a --- /dev/null +++ b/components/system/Files/Views/Details/StyledFileEntry.ts @@ -0,0 +1,50 @@ +import styled from "styled-components"; +import { type StyledFileEntryProps } from "components/system/Files/Views"; + +const StyledFileEntry = styled.li` + margin-left: ${({ theme }) => theme.sizes.fileManager.detailsStartPadding}px; + width: fit-content; + + button { + display: flex; + padding-left: 4px; + text-align: left; + + figure { + bottom: 1px; + display: flex; + flex-direction: row; + height: 22px; + place-items: center; + position: relative; + + figcaption { + color: ${({ theme }) => theme.colors.fileEntry.text}; + font-size: ${({ theme }) => theme.sizes.fileEntry.fontSize}; + overflow: hidden; + padding-left: 4px; + text-overflow: ellipsis; + white-space: nowrap; + word-break: break-word; + } + } + } + + &:hover { + background-color: ${({ theme }) => theme.colors.fileEntry.background}; + } + + &.focus-within { + background-color: ${({ theme }) => + theme.colors.fileEntry.backgroundFocused}; + + &:hover { + background-color: ${({ theme, $selecting }) => + $selecting + ? theme.colors.fileEntry.backgroundFocused + : theme.colors.fileEntry.backgroundFocusedHover}; + } + } +`; + +export default StyledFileEntry; diff --git a/components/system/Files/Views/Details/StyledFileManager.ts b/components/system/Files/Views/Details/StyledFileManager.ts new file mode 100644 index 0000000000..bc0ae73aba --- /dev/null +++ b/components/system/Files/Views/Details/StyledFileManager.ts @@ -0,0 +1,24 @@ +import styled from "styled-components"; +import { StyledSelectionComponent } from "components/system/Files/FileManager/Selection/StyledSelection"; +import { type StyledFileManagerProps } from "components/system/Files/Views"; +import ScrollBars from "styles/common/ScrollBars"; + +const StyledFileManager = styled.ol` + ${({ $scrollable }) => ($scrollable ? ScrollBars() : undefined)}; + + contain: strict; + overflow: ${({ $isEmptyFolder, $scrollable }) => + !$isEmptyFolder && $scrollable ? undefined : "hidden"}; + pointer-events: ${({ $selecting }) => ($selecting ? "auto" : undefined)}; + scrollbar-gutter: auto; + + picture:not(:first-of-type) { + position: absolute; + } + + ${StyledSelectionComponent} { + top: 0; + } +`; + +export default StyledFileManager; diff --git a/components/system/Files/Views/index.ts b/components/system/Files/Views/index.ts index f0e23cd36c..92b9313998 100644 --- a/components/system/Files/Views/index.ts +++ b/components/system/Files/Views/index.ts @@ -1,3 +1,5 @@ +import StyledDetailsFileEntry from "components/system/Files/Views/Details/StyledFileEntry"; +import StyledDetailsFileManager from "components/system/Files/Views/Details/StyledFileManager"; import StyledIconFileEntry from "components/system/Files/Views/Icon/StyledFileEntry"; import StyledIconFileManager from "components/system/Files/Views/Icon/StyledFileManager"; import StyledListFileEntry from "components/system/Files/Views/List/StyledFileEntry"; @@ -18,14 +20,21 @@ export type StyledFileManagerProps = { type FileManagerView = { StyledFileEntry: typeof StyledIconFileEntry | typeof StyledListFileEntry; + /* eslint-disable @typescript-eslint/no-duplicate-type-constituents */ StyledFileManager: + | typeof StyledDetailsFileManager | typeof StyledIconFileManager | typeof StyledListFileManager; + /* eslint-enable @typescript-eslint/no-duplicate-type-constituents */ }; -export type FileManagerViewNames = "icon" | "list"; +export type FileManagerViewNames = "details" | "icon" | "list"; export const FileManagerViews: Record = { + details: { + StyledFileEntry: StyledDetailsFileEntry, + StyledFileManager: StyledDetailsFileManager, + }, icon: { StyledFileEntry: StyledIconFileEntry, StyledFileManager: StyledIconFileManager, @@ -37,9 +46,16 @@ export const FileManagerViews: Record = { }; export const FileEntryIconSize: Record< - FileManagerViewNames | "sub", + FileManagerViewNames | "detailsSub" | "sub", IconProps > = { + details: { + imgSize: 16, + }, + detailsSub: { + displaySize: 8, + imgSize: 16, + }, icon: { imgSize: 48, }, diff --git a/components/system/StartMenu/index.tsx b/components/system/StartMenu/index.tsx index a0c53917a2..70f5bfb594 100644 --- a/components/system/StartMenu/index.tsx +++ b/components/system/StartMenu/index.tsx @@ -110,7 +110,6 @@ const StartMenu: FC = ({ toggleStartMenu }) => { ; +export type Views = Record; + export type ClockSource = "local" | "ntp"; export type RecentFiles = [string, string, string][]; @@ -45,6 +48,7 @@ export type SessionData = { runHistory: string[]; sortOrders: SortOrders; themeName: ThemeName; + views: Views; wallpaperFit: WallpaperFit; wallpaperImage: string; windowStates: WindowStates; @@ -69,6 +73,7 @@ export type SessionContextState = SessionData & { ascending?: boolean ) => void; setThemeName: React.Dispatch>; + setViews: React.Dispatch>; setWallpaper: (image: string, fit?: WallpaperFit) => void; setWindowStates: React.Dispatch>; stackOrder: string[]; diff --git a/contexts/session/useSessionContextState.ts b/contexts/session/useSessionContextState.ts index b581395a72..4c8633ae24 100644 --- a/contexts/session/useSessionContextState.ts +++ b/contexts/session/useSessionContextState.ts @@ -10,6 +10,7 @@ import { type ApiError } from "browserfs/dist/node/core/api_error"; import { type SortBy } from "components/system/Files/FileManager/useSortBy"; import { useFileSystem } from "contexts/fileSystem"; import { + type Views, type IconPositions, type RecentFiles, type SessionContextState, @@ -52,6 +53,7 @@ const useSessionContextState = (): SessionContextState => { const [sortOrders, setSortOrders] = useState( Object.create(null) as SortOrders ); + const [views, setViews] = useState(Object.create(null) as Views); const [iconPositions, setIconPositions] = useState( Object.create(null) as IconPositions ); @@ -195,6 +197,7 @@ const useSessionContextState = (): SessionContextState => { runHistory, sortOrders, themeName, + views, wallpaperFit, wallpaperImage, windowStates, @@ -223,6 +226,7 @@ const useSessionContextState = (): SessionContextState => { sessionLoaded, sortOrders, themeName, + views, wallpaperFit, wallpaperImage, windowStates, @@ -261,6 +265,9 @@ const useSessionContextState = (): SessionContextState => { ) { setSortOrders(session.sortOrders); } + if (session.views && Object.keys(session.views).length > 0) { + setViews(session.views); + } if ( session.iconPositions && Object.keys(session.iconPositions).length > 0 @@ -361,12 +368,14 @@ const useSessionContextState = (): SessionContextState => { setRunHistory, setSortOrder, setThemeName, + setViews, setWallpaper, setWindowStates, sortOrders, stackOrder, themeName, updateRecentFiles, + views, wallpaperFit, wallpaperImage, windowStates, diff --git a/public/System/Icons/16x16/details_view.png b/public/System/Icons/16x16/details_view.png new file mode 100644 index 0000000000..69bb05005a Binary files /dev/null and b/public/System/Icons/16x16/details_view.png differ diff --git a/public/System/Icons/16x16/details_view.webp b/public/System/Icons/16x16/details_view.webp new file mode 100644 index 0000000000..61583119d0 Binary files /dev/null and b/public/System/Icons/16x16/details_view.webp differ diff --git a/public/System/Icons/16x16/icon_view.png b/public/System/Icons/16x16/icon_view.png new file mode 100644 index 0000000000..47a1d39b7e Binary files /dev/null and b/public/System/Icons/16x16/icon_view.png differ diff --git a/public/System/Icons/16x16/icon_view.webp b/public/System/Icons/16x16/icon_view.webp new file mode 100644 index 0000000000..aa90ac8da7 Binary files /dev/null and b/public/System/Icons/16x16/icon_view.webp differ diff --git a/public/System/Icons/32x32/details_view.png b/public/System/Icons/32x32/details_view.png new file mode 100644 index 0000000000..02bfea8b35 Binary files /dev/null and b/public/System/Icons/32x32/details_view.png differ diff --git a/public/System/Icons/32x32/details_view.webp b/public/System/Icons/32x32/details_view.webp new file mode 100644 index 0000000000..7abff781cb Binary files /dev/null and b/public/System/Icons/32x32/details_view.webp differ diff --git a/public/System/Icons/32x32/icon_view.png b/public/System/Icons/32x32/icon_view.png new file mode 100644 index 0000000000..d9219fbaae Binary files /dev/null and b/public/System/Icons/32x32/icon_view.png differ diff --git a/public/System/Icons/32x32/icon_view.webp b/public/System/Icons/32x32/icon_view.webp new file mode 100644 index 0000000000..dfafbb0d18 Binary files /dev/null and b/public/System/Icons/32x32/icon_view.webp differ diff --git a/public/System/Icons/48x48/details_view.png b/public/System/Icons/48x48/details_view.png new file mode 100644 index 0000000000..0afd82cd3d Binary files /dev/null and b/public/System/Icons/48x48/details_view.png differ diff --git a/public/System/Icons/48x48/details_view.webp b/public/System/Icons/48x48/details_view.webp new file mode 100644 index 0000000000..41ecbe5d29 Binary files /dev/null and b/public/System/Icons/48x48/details_view.webp differ diff --git a/public/System/Icons/48x48/icon_view.png b/public/System/Icons/48x48/icon_view.png new file mode 100644 index 0000000000..c4d6ae2d7c Binary files /dev/null and b/public/System/Icons/48x48/icon_view.png differ diff --git a/public/System/Icons/48x48/icon_view.webp b/public/System/Icons/48x48/icon_view.webp new file mode 100644 index 0000000000..7b8c81256b Binary files /dev/null and b/public/System/Icons/48x48/icon_view.webp differ diff --git a/styles/defaultTheme/sizes.ts b/styles/defaultTheme/sizes.ts index b2c0a98f29..bddcfe2b61 100644 --- a/styles/defaultTheme/sizes.ts +++ b/styles/defaultTheme/sizes.ts @@ -18,12 +18,17 @@ const sizes = { renameWidth: 75, }, fileExplorer: { + minimumStatusBarWidth: 247, navBarHeight: "35px", navInputHeight: 22, statusBarHeight: "23px", }, fileManager: { columnGap: "1px", + columnHeight: "25px", + columnMinWidth: 80, + columnResizeWidth: 6, + detailsStartPadding: 14, gridEntryHeight: "70px", gridEntryWidth: "74px", padding: "5px 0", diff --git a/utils/functions.ts b/utils/functions.ts index 55399fcf26..1762d6f25a 100644 --- a/utils/functions.ts +++ b/utils/functions.ts @@ -614,11 +614,22 @@ const bytesInMB = 1022976; // 1024 * 999 const bytesInGB = 1047527424; // 1024 * 1024 * 999 const bytesInTB = 1072668082176; // 1024 * 1024 * 1024 * 999 -const formatNumber = (number: number): string => { - const formattedNumber = new Intl.NumberFormat("en-US", { - maximumSignificantDigits: number < 1 ? 2 : 4, - minimumSignificantDigits: number < 1 ? 2 : 3, - }).format(Number(number.toFixed(4).slice(0, -2))); +const formatNumber = (number: number, roundUpNumber = false): string => { + const formattedNumber = new Intl.NumberFormat( + "en-US", + roundUpNumber + ? undefined + : { + maximumSignificantDigits: number < 1 ? 2 : 4, + minimumSignificantDigits: number < 1 ? 2 : 3, + } + ).format( + roundUpNumber + ? Math.ceil(Number(number)) + : Number(number.toFixed(4).slice(0, -2)) + ); + + if (roundUpNumber) return formattedNumber; const [integer, decimal] = formattedNumber.split("."); @@ -630,7 +641,14 @@ const formatNumber = (number: number): string => { return formattedNumber; }; -export const getFormattedSize = (size = 0): string => { +export const getFormattedSize = (size = 0, asKB = false): string => { + if (asKB) { + if (size === 0) return "0 KB"; + if (size <= bytesInKB) return "1 KB"; + + return `${formatNumber(size / bytesInKB, true)} KB`; + } + if (size === 1) return "1 byte"; if (size < bytesInKB) return `${size} bytes`; if (size < bytesInMB) return `${formatNumber(size / bytesInKB)} KB`;