waveterm/frontend/app/view/directorypreview.tsx
Mike Sawka 4be8a1e37e
move codeedit to directory. new useLongClick hook. show quick navig… (#105)
move codeedit to directory. new useLongClick hook. show quick navigation for directoryview on longclick of folder icon. lots more generic
stuff for header
new fitaddon for xtermjs
more fixes for xtermjs scrollbars
2024-07-08 23:13:12 -07:00

566 lines
20 KiB
TypeScript

// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import * as services from "@/store/services";
import * as keyutil from "@/util/keyutil";
import * as util from "@/util/util";
import type { PreviewModel } from "@/view/preview";
import {
Row,
Table,
createColumnHelper,
flexRender,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import clsx from "clsx";
import dayjs from "dayjs";
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[];
search: string;
focusIndex: number;
setFocusIndex: (_: number) => void;
setFileName: (_: string) => void;
setSearch: (_: string) => void;
setSelectedPath: (_: string) => void;
setRefreshVersion: React.Dispatch<React.SetStateAction<number>>;
}
const columnHelper = createColumnHelper<FileInfo>();
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): string {
let fileDatetime = dayjs(new Date(unixMillis));
let nowDatetime = dayjs(new Date());
if (nowDatetime.year() != fileDatetime.year()) {
return dayjs(fileDatetime).format("M/D/YY");
} else if (nowDatetime.month() != fileDatetime.month()) {
return dayjs(fileDatetime).format("MMM D");
} else {
return dayjs(fileDatetime).format("MMM D h:mm A");
}
}
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 <i className="fa-solid fa-chevron-up dir-table-head-direction"></i>;
case "desc":
return <i className="fa-solid fa-chevron-down dir-table-head-direction"></i>;
default:
return null;
}
}
function cleanMimetype(input: string): string {
if (input == "") {
return "-";
}
const truncated = input.split(";")[0];
return truncated.trim();
}
function DirectoryTable({
data,
search,
focusIndex,
setFocusIndex,
setFileName,
setSearch,
setSelectedPath,
setRefreshVersion,
}: 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) => (
<i
className={getIconFromMimeType(info.getValue() ?? "")}
style={{ color: getIconColor(info.getValue() ?? "") }}
></i>
),
header: () => <span></span>,
id: "logo",
size: 25,
enableSorting: false,
}),
columnHelper.accessor("name", {
cell: (info) => <span className="dir-table-name">{info.getValue()}</span>,
header: () => <span>Name</span>,
sortingFn: "alphanumeric",
}),
columnHelper.accessor("modestr", {
cell: (info) => <span className="dir-table-modestr">{info.getValue()}</span>,
header: () => <span>Permissions</span>,
size: 91,
sortingFn: "alphanumeric",
}),
columnHelper.accessor("modtime", {
cell: (info) => <span className="dir-table-lastmod">{getLastModifiedTime(info.getValue())}</span>,
header: () => <span>Last Modified</span>,
size: 185,
sortingFn: "datetime",
}),
columnHelper.accessor("size", {
cell: (info) => <span className="dir-table-size">{getBestUnit(info.getValue())}</span>,
header: () => <span className="dir-table-head-size">Size</span>,
size: 55,
sortingFn: "auto",
}),
columnHelper.accessor("mimetype", {
cell: (info) => <span className="dir-table-type">{cleanMimetype(info.getValue() ?? "")}</span>,
header: () => <span>Type</span>,
sortingFn: "alphanumeric",
}),
columnHelper.accessor("path", {}),
],
[settings]
);
const table = useReactTable({
data,
columns,
columnResizeMode: "onChange",
getSortedRowModel: getSortedRowModel(),
getCoreRowModel: getCoreRowModel(),
initialState: {
sorting: [
{
id: "name",
desc: false,
},
],
columnVisibility: {
path: false,
},
rowPinning: {
top: [],
bottom: [],
},
},
enableMultiSort: false,
enableSortingRemoval: false,
});
React.useEffect(() => {
setSelectedPath((table.getSortedRowModel()?.flatRows[focusIndex]?.getValue("path") as string) ?? null);
}, [table, focusIndex, data]);
React.useEffect(() => {
let rows = table.getRowModel()?.flatRows;
for (const row of rows) {
if (row.getValue("name") == "..") {
row.pin("top");
return;
}
}
}, [data]);
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 (
<div className="dir-table" style={{ ...columnSizeVars }}>
<div className="dir-table-head">
{table.getHeaderGroups().map((headerGroup) => (
<div className="dir-table-head-row" key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<div className="dir-table-head-cell">
<div
className="dir-table-head-cell-content"
key={header.id}
style={{ width: `calc(var(--header-${header.id}-size) * 1px)` }}
onClick={() => header.column.toggleSorting()}
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
{getSortIcon(header.column.getIsSorted())}
</div>
<div className="dir-table-head-resize-box">
<div
className="dir-table-head-resize"
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
/>
</div>
</div>
))}
</div>
))}
</div>
{table.getState().columnSizingInfo.isResizingColumn ? (
<MemoizedTableBody
data={data}
table={table}
search={search}
focusIndex={focusIndex}
setFileName={setFileName}
setFocusIndex={setFocusIndex}
setSearch={setSearch}
setSelectedPath={setSelectedPath}
setRefreshVersion={setRefreshVersion}
/>
) : (
<TableBody
data={data}
table={table}
search={search}
focusIndex={focusIndex}
setFileName={setFileName}
setFocusIndex={setFocusIndex}
setSearch={setSearch}
setSelectedPath={setSelectedPath}
setRefreshVersion={setRefreshVersion}
/>
)}
</div>
);
}
interface TableBodyProps {
data: Array<FileInfo>;
table: Table<FileInfo>;
search: string;
focusIndex: number;
setFocusIndex: (_: number) => void;
setFileName: (_: string) => void;
setSearch: (_: string) => void;
setSelectedPath: (_: string) => void;
setRefreshVersion: React.Dispatch<React.SetStateAction<number>>;
}
function TableBody({
data,
table,
search,
focusIndex,
setFocusIndex,
setFileName,
setSearch,
setSelectedPath,
setRefreshVersion,
}: TableBodyProps) {
const dummyLineRef = React.useRef<HTMLDivElement>(null);
const parentRef = React.useRef<HTMLDivElement>(null);
const warningBoxRef = React.useRef<HTMLDivElement>(null);
const [bodyHeight, setBodyHeight] = React.useState(0);
const [containerHeight, setContainerHeight] = React.useState(0);
React.useEffect(() => {
if (parentRef.current == null) {
return;
}
const resizeObserver = new ResizeObserver(() => {
setContainerHeight(parentRef.current.getBoundingClientRect().height); // 17 is height of breadcrumb
});
resizeObserver.observe(parentRef.current);
return () => resizeObserver.disconnect();
}, []);
React.useEffect(() => {
if (dummyLineRef.current && data && parentRef.current) {
const rowHeight = dummyLineRef.current.offsetHeight;
const fullTBodyHeight = rowHeight * data.length;
const warningBoxHeight = warningBoxRef.current?.offsetHeight ?? 0;
const maxHeight = containerHeight - 1; // i don't know why, but the -1 makes the resize work
const maxHeightLessHeader = maxHeight - warningBoxHeight;
const tbodyHeight = Math.min(maxHeightLessHeader, fullTBodyHeight);
setBodyHeight(tbodyHeight);
}
}, [data, containerHeight]);
const handleFileContextMenu = React.useCallback(
(e: React.MouseEvent<HTMLDivElement>, 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
setRefreshVersion((current) => current + 1);
},
});
menu.push({
label: "Download File",
click: async () => {
getApi().downloadFile(path);
},
});
ContextMenuModel.showContextMenu(menu, e);
},
[setRefreshVersion]
);
const displayRow = React.useCallback(
(row: Row<FileInfo>, idx: number) => (
<div
className={clsx("dir-table-body-row", { focused: focusIndex === idx })}
key={row.id}
onDoubleClick={() => {
const newFileName = row.getValue("path") as string;
setFileName(newFileName);
setSearch("");
}}
onClick={() => setFocusIndex(idx)}
onContextMenu={(e) => handleFileContextMenu(e, row.getValue("path") as string)}
>
{row.getVisibleCells().map((cell) => {
return (
<div
className={clsx("dir-table-body-cell", "col-" + cell.column.id)}
key={cell.id}
style={{ width: `calc(var(--col-${cell.column.id}-size) * 1px)` }}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</div>
);
})}
</div>
),
[setSearch, setFileName, handleFileContextMenu, setFocusIndex, focusIndex]
);
return (
<div className="dir-table-body" ref={parentRef}>
{search == "" || (
<div className="dir-table-body-search-display" ref={warningBoxRef}>
<span>Searching for "{search}"</span>
<div className="search-display-close-button dir-table-button" onClick={() => setSearch("")}>
<i className="fa-solid fa-xmark" />
<input type="text" value={search} onChange={() => {}}></input>
</div>
</div>
)}
<div className="dir-table-body-scroll-box" style={{ height: bodyHeight }}>
<div className="dummy dir-table-body-row" ref={dummyLineRef}>
<div className="dir-table-body-cell">dummy-data</div>
</div>
{table.getTopRows().map(displayRow)}
{table.getCenterRows().map((row, idx) => displayRow(row, idx + table.getTopRows().length))}
</div>
</div>
);
}
const MemoizedTableBody = React.memo(
TableBody,
(prev, next) => prev.table.options.data == next.table.options.data
) as typeof TableBody;
interface DirectoryPreviewProps {
fileNameAtom: jotai.WritableAtom<string, [string], void>;
model: PreviewModel;
}
function DirectoryPreview({ fileNameAtom, model }: DirectoryPreviewProps) {
const [searchText, setSearchText] = React.useState("");
const [focusIndex, setFocusIndex] = React.useState(0);
const [content, setContent] = React.useState<FileInfo[]>([]);
const [fileName, setFileName] = jotai.useAtom(fileNameAtom);
const hideHiddenFiles = jotai.useAtomValue(model.showHiddenFiles);
const [selectedPath, setSelectedPath] = React.useState("");
const [refreshVersion, setRefreshVersion] = jotai.useAtom(model.refreshVersion);
React.useEffect(() => {
model.refreshCallback = () => {
setRefreshVersion((refreshVersion) => refreshVersion + 1);
};
return () => {
model.refreshCallback = null;
};
}, [setRefreshVersion]);
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) => {
if (hideHiddenFiles && fileInfo.name.startsWith(".") && fileInfo.name != "..") {
return false;
}
return fileInfo.name.toLowerCase().includes(searchText);
});
setContent(filtered);
};
getContent();
}, [fileName, searchText, hideHiddenFiles, refreshVersion]);
const handleKeyDown = React.useCallback(
(waveEvent: WaveKeyboardEvent): boolean => {
if (keyutil.checkKeyPressed(waveEvent, "Escape")) {
setSearchText("");
return;
}
if (keyutil.checkKeyPressed(waveEvent, "ArrowUp")) {
setFocusIndex((idx) => Math.max(idx - 1, 0));
return true;
}
if (keyutil.checkKeyPressed(waveEvent, "ArrowDown")) {
setFocusIndex((idx) => Math.min(idx + 1, content.length - 1));
return true;
}
if (keyutil.checkKeyPressed(waveEvent, "Enter")) {
setFileName(selectedPath);
setSearchText("");
return true;
}
},
[content, focusIndex, selectedPath]
);
return (
<div
className="dir-table-container"
onChangeCapture={(e) => {
const event = e as React.ChangeEvent<HTMLInputElement>;
setSearchText(event.target.value.toLowerCase());
}}
onKeyDownCapture={(e) => keyutil.keydownWrapper(handleKeyDown)(e)}
onFocusCapture={() => document.getSelection().collapseToEnd()}
>
<div className="dir-table-search-line">
<input
type="text"
className="dir-table-search-box"
onChange={() => {}} //for nuisance warnings
maxLength={400}
autoFocus={true}
value={searchText}
/>
</div>
<DirectoryTable
data={content}
search={searchText}
focusIndex={focusIndex}
setFileName={setFileName}
setFocusIndex={setFocusIndex}
setSearch={setSearchText}
setSelectedPath={setSelectedPath}
setRefreshVersion={setRefreshVersion}
/>
</div>
);
}
export { DirectoryPreview };