// Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import * as services from "@/store/services"; import * as util from "@/util/util"; import { Table, createColumnHelper, flexRender, getCoreRowModel, getSortedRowModel, useReactTable, } from "@tanstack/react-table"; import clsx from "clsx"; import * as jotai from "jotai"; import React from "react"; import { ContextMenuModel } from "../store/contextmenu"; import { atoms, createBlock, getApi } from "../store/global"; import "./directorypreview.less"; interface DirectoryTableProps { data: FileInfo[]; cwd: string; focusIndex: number; enter: boolean; setFocusIndex: (_: number) => void; setFileName: (_: string) => void; } const columnHelper = createColumnHelper(); const displaySuffixes = { B: "b", kB: "k", MB: "m", GB: "g", TB: "t", KiB: "k", MiB: "m", GiB: "g", TiB: "t", }; function getBestUnit(bytes: number, si: boolean = false, sigfig: number = 3): string { if (bytes < 0) { return "-"; } const units = si ? ["kB", "MB", "GB", "TB"] : ["KiB", "MiB", "GiB", "TiB"]; const divisor = si ? 1000 : 1024; let currentUnit = "B"; let currentValue = bytes; let idx = 0; while (currentValue > divisor && idx < units.length - 1) { currentUnit = units[idx]; currentValue /= divisor; idx += 1; } return `${parseFloat(currentValue.toPrecision(sigfig))}${displaySuffixes[currentUnit]}`; } function getSpecificUnit(bytes: number, suffix: string): string { if (bytes < 0) { return "-"; } const divisors = new Map([ ["B", 1], ["kB", 1e3], ["MB", 1e6], ["GB", 1e9], ["TB", 1e12], ["KiB", 0x400], ["MiB", 0x400 ** 2], ["GiB", 0x400 ** 3], ["TiB", 0x400 ** 4], ]); const divisor: number = divisors[suffix] ?? 1; return `${bytes / divisor} ${displaySuffixes[suffix]}`; } function getLastModifiedTime( unixMillis: number, locale: Intl.LocalesArgument, options: DateTimeFormatConfigType ): string { if (locale === "C") { locale = "lookup"; } return new Date(unixMillis).toLocaleString(locale, options); //todo use config } const iconRegex = /^[a-z0-9- ]+$/; function isIconValid(icon: string): boolean { if (util.isBlank(icon)) { return false; } return icon.match(iconRegex) != null; } function getIconClass(icon: string): string { if (!isIconValid(icon)) { return "fa fa-solid fa-question fa-fw"; } return `fa fa-solid fa-${icon} fa-fw`; } function getSortIcon(sortType: string | boolean): React.ReactNode { switch (sortType) { case "asc": return ; case "desc": return ; default: return null; } } function handleFileContextMenu(e: React.MouseEvent, path: string) { e.preventDefault(); e.stopPropagation(); let menu: ContextMenuItem[] = []; menu.push({ label: "Open in New Block", click: async () => { const blockDef = { view: "preview", meta: { file: path }, }; await createBlock(blockDef); }, }); menu.push({ label: "Delete File", click: async () => { await services.FileService.DeleteFile(path).catch((e) => console.log(e)); //todo these errors need a popup }, }); menu.push({ label: "Download File", click: async () => { getApi().downloadFile(path); }, }); ContextMenuModel.showContextMenu(menu, e); } function cleanMimetype(input: string): string { const truncated = input.split(";")[0]; return truncated.trim(); } function DirectoryTable({ data, cwd, focusIndex, enter, setFocusIndex, setFileName }: DirectoryTableProps) { let settings = jotai.useAtomValue(atoms.settingsConfigAtom); const getIconFromMimeType = React.useCallback( (mimeType: string): string => { while (mimeType.length > 0) { let icon = settings.mimetypes[mimeType]?.icon ?? null; if (isIconValid(icon)) { return `fa fa-solid fa-${icon} fa-fw`; } mimeType = mimeType.slice(0, -1); } return "fa fa-solid fa-file fa-fw"; }, [settings.mimetypes] ); const getIconColor = React.useCallback( (mimeType: string): string => { let iconColor = settings.mimetypes[mimeType]?.color ?? "inherit"; return iconColor; }, [settings.mimetypes] ); const columns = React.useMemo( () => [ columnHelper.accessor("mimetype", { cell: (info) => ( ), header: () => , id: "logo", size: 25, enableSorting: false, }), columnHelper.accessor("path", { cell: (info) => {info.getValue()}, header: () => Name, sortingFn: "alphanumeric", }), columnHelper.accessor("modestr", { cell: (info) => {info.getValue()}, header: () => Permissions, size: 91, sortingFn: "alphanumeric", }), columnHelper.accessor("modtime", { cell: (info) => ( {getLastModifiedTime(info.getValue(), settings.datetime.locale, settings.datetime.format)} ), header: () => Last Modified, size: 185, sortingFn: "datetime", }), columnHelper.accessor("size", { cell: (info) => {getBestUnit(info.getValue())}, header: () => Size, size: 55, sortingFn: "auto", }), columnHelper.accessor("mimetype", { cell: (info) => {cleanMimetype(info.getValue() ?? "")}, header: () => Type, sortingFn: "alphanumeric", }), ], [settings] ); const table = useReactTable({ data, columns, columnResizeMode: "onChange", getSortedRowModel: getSortedRowModel(), getCoreRowModel: getCoreRowModel(), initialState: { sorting: [ { id: "path", desc: false, }, ], }, enableMultiSort: false, enableSortingRemoval: false, }); const columnSizeVars = React.useMemo(() => { const headers = table.getFlatHeaders(); const colSizes: { [key: string]: number } = {}; for (let i = 0; i < headers.length; i++) { const header = headers[i]!; colSizes[`--header-${header.id}-size`] = header.getSize(); colSizes[`--col-${header.column.id}-size`] = header.column.getSize(); } return colSizes; }, [table.getState().columnSizingInfo]); return (
{table.getHeaderGroups().map((headerGroup) => (
{headerGroup.headers.map((header) => (
header.column.toggleSorting()} > {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} {getSortIcon(header.column.getIsSorted())}
))}
))}
{table.getState().columnSizingInfo.isResizingColumn ? ( ) : ( )}
); } interface TableBodyProps { table: Table; cwd: string; focusIndex: number; enter: boolean; setFocusIndex: (_: number) => void; setFileName: (_: string) => void; } function TableBody({ table, cwd, focusIndex, enter, setFocusIndex, setFileName }: TableBodyProps) { let [refresh, setRefresh] = React.useState(false); React.useEffect(() => { const selected = (table.getSortedRowModel()?.flatRows[focusIndex]?.getValue("path") as string) ?? null; if (selected != null) { console.log("yipee"); const fullPath = cwd.concat("/", selected); setFileName(fullPath); } }, [enter]); table.getRow; return (
{table.getRowModel().rows.map((row, idx) => (
{ const newFileName = row.getValue("path") as string; const fullPath = cwd.concat("/", newFileName); setFileName(fullPath); }} onClick={() => setFocusIndex(idx)} onContextMenu={(e) => handleFileContextMenu(e, cwd.concat("/", row.getValue("path") as string))} > {row.getVisibleCells().map((cell) => { return (
{flexRender(cell.column.columnDef.cell, cell.getContext())}
); })}
))}
); } const MemoizedTableBody = React.memo( TableBody, (prev, next) => prev.table.options.data == next.table.options.data ) as typeof TableBody; interface DirectoryPreviewProps { fileNameAtom: jotai.WritableAtom; } function DirectoryPreview({ fileNameAtom }: DirectoryPreviewProps) { const [searchText, setSearchText] = React.useState(""); let [focusIndex, setFocusIndex] = React.useState(0); const [content, setContent] = React.useState([]); let [fileName, setFileName] = jotai.useAtom(fileNameAtom); const [enter, setEnter] = React.useState(false); React.useEffect(() => { const getContent = async () => { const file = await services.FileService.ReadFile(fileName); const serializedContent = util.base64ToString(file?.data64); let content: FileInfo[] = JSON.parse(serializedContent); let filtered = content.filter((fileInfo) => { return fileInfo.path.toLowerCase().includes(searchText); }); setContent(filtered); }; getContent(); }, [fileName, searchText]); const handleKeyDown = React.useCallback( (e) => { switch (e.key) { case "Escape": //todo: escape block focus break; case "ArrowUp": e.preventDefault(); setFocusIndex((idx) => Math.max(idx - 1, 0)); break; case "ArrowDown": e.preventDefault(); setFocusIndex((idx) => Math.min(idx + 1, content.length - 1)); break; case "Enter": e.preventDefault(); console.log("enter thinks focus Index is ", focusIndex); let newFileName = content[focusIndex].path; console.log( "enter thinks contents are", content.slice(0, focusIndex + 1).map((fi) => fi.path) ); setEnter((current) => !current); /* const fullPath = fileName.concat("/", newFileName); setFileName(fullPath); */ break; default: } }, [content, focusIndex, setEnter] ); React.useEffect(() => { console.log(focusIndex); }, [focusIndex]); React.useEffect(() => { document.addEventListener("keydown", handleKeyDown); return () => { document.removeEventListener("keydown", handleKeyDown); }; }, [handleKeyDown]); return ( <>
setSearchText(e.target.value.toLowerCase())} maxLength={400} autoFocus={true} />
); } export { DirectoryPreview };