mirror of
https://github.com/wavetermdev/backup.git
synced 2026-08-05 13:57:07 -07:00
CSV view (#73)
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
.csv-view {
|
||||
opacity: 0; /* Start with an opacity of 0, meaning it's invisible */
|
||||
|
||||
.ellipsis() {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.select-none {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
table.probe {
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--scrollbar-thumb-hover-color);
|
||||
|
||||
thead {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow-y: scroll;
|
||||
|
||||
tr {
|
||||
border-bottom: 1px solid var(--scrollbar-thumb-hover-color);
|
||||
|
||||
th {
|
||||
color: var(--app-text-color);
|
||||
border-right: 1px solid var(--scrollbar-thumb-hover-color);
|
||||
border-bottom: none;
|
||||
padding: 2px 10px;
|
||||
flex-basis: 100%;
|
||||
flex-grow: 2;
|
||||
display: block;
|
||||
text-align: left;
|
||||
position: relative;
|
||||
|
||||
.inner {
|
||||
text-align: left;
|
||||
padding-right: 15px;
|
||||
position: relative;
|
||||
.ellipsis();
|
||||
|
||||
.sort-icon {
|
||||
position: absolute;
|
||||
right: 0px;
|
||||
top: 2px;
|
||||
width: 9px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tbody {
|
||||
display: block;
|
||||
position: relative;
|
||||
overflow-y: scroll;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
tr {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
|
||||
td {
|
||||
border-right: 1px solid var(--scrollbar-thumb-hover-color);
|
||||
border-left: 1px solid var(--scrollbar-thumb-hover-color);
|
||||
padding: 3px 10px;
|
||||
flex-basis: 100%;
|
||||
flex-grow: 2;
|
||||
display: block;
|
||||
text-align: left;
|
||||
.ellipsis();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.csv-view.show {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { useTableNav } from "@table-nav/react";
|
||||
import {
|
||||
createColumnHelper,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { clsx } from "clsx";
|
||||
import Papa from "papaparse";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import "./csvview.less";
|
||||
|
||||
const MAX_DATA_SIZE = 10 * 1024 * 1024; // 10MB in bytes
|
||||
|
||||
type CSVRow = {
|
||||
[key: string]: string | number;
|
||||
};
|
||||
|
||||
interface CSVViewProps {
|
||||
parentRef: React.MutableRefObject<HTMLDivElement>;
|
||||
content: string;
|
||||
filename: string;
|
||||
readonly: boolean;
|
||||
}
|
||||
|
||||
interface State {
|
||||
content: string | null;
|
||||
showReadonly: boolean;
|
||||
tbodyHeight: number;
|
||||
}
|
||||
|
||||
const columnHelper = createColumnHelper<any>();
|
||||
|
||||
const CSVView = ({ parentRef, filename, content }: CSVViewProps) => {
|
||||
const csvCacheRef = useRef(new Map<string, string>());
|
||||
const rowRef = useRef<(HTMLTableRowElement | null)[]>([]);
|
||||
const headerRef = useRef<HTMLTableRowElement | null>(null);
|
||||
const probeRef = useRef<HTMLTableRowElement | null>(null);
|
||||
const tbodyRef = useRef<HTMLTableSectionElement | null>(null);
|
||||
const [state, setState] = useState<State>({
|
||||
content,
|
||||
showReadonly: true,
|
||||
tbodyHeight: 0,
|
||||
});
|
||||
const [tableLoaded, setTableLoaded] = useState(false);
|
||||
const [maxHeight, setMaxHeight] = useState(0);
|
||||
const { listeners } = useTableNav();
|
||||
|
||||
const cacheKey = `${filename}`;
|
||||
csvCacheRef.current.set(cacheKey, content);
|
||||
|
||||
// Parse the CSV data
|
||||
const parsedData = useMemo<CSVRow[]>(() => {
|
||||
if (!state.content) return [];
|
||||
|
||||
// Trim the content and then check for headers based on the first row's content.
|
||||
const trimmedContent = state.content.trim();
|
||||
const firstRow = trimmedContent.split("\n")[0];
|
||||
|
||||
// This checks if the first row starts with a letter or a quote
|
||||
const hasHeaders = !!firstRow.match(/^[a-zA-Z"]/);
|
||||
|
||||
const results = Papa.parse(trimmedContent, { header: hasHeaders });
|
||||
|
||||
// Check for non-header CSVs
|
||||
if (!hasHeaders && Array.isArray(results.data) && Array.isArray(results.data[0])) {
|
||||
const dataArray = results.data as string[][]; // Asserting the type
|
||||
const headers = Array.from({ length: dataArray[0].length }, (_, i) => `Column ${i + 1}`);
|
||||
results.data = dataArray.map((row) => {
|
||||
const newRow: CSVRow = {};
|
||||
row.forEach((value, index) => {
|
||||
newRow[headers[index]] = value;
|
||||
});
|
||||
return newRow;
|
||||
});
|
||||
}
|
||||
|
||||
return results.data.map((row) => {
|
||||
return Object.fromEntries(
|
||||
Object.entries(row as CSVRow).map(([key, value]) => {
|
||||
if (typeof value === "string") {
|
||||
const numberValue = parseFloat(value);
|
||||
if (!isNaN(numberValue) && String(numberValue) === value) {
|
||||
return [key, numberValue];
|
||||
}
|
||||
}
|
||||
return [key, value];
|
||||
})
|
||||
) as CSVRow;
|
||||
});
|
||||
}, [state.content]);
|
||||
|
||||
// Column Definitions
|
||||
const columns = useMemo(() => {
|
||||
if (parsedData.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const headers = Object.keys(parsedData[0]);
|
||||
return headers.map((header) =>
|
||||
columnHelper.accessor(header, {
|
||||
header: () => header,
|
||||
cell: (info) => info.renderValue(),
|
||||
})
|
||||
);
|
||||
}, [parsedData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (probeRef.current && headerRef.current && parsedData.length && parentRef.current) {
|
||||
const rowHeight = probeRef.current.offsetHeight;
|
||||
const fullTBodyHeight = rowHeight * parsedData.length;
|
||||
const headerHeight = headerRef.current.offsetHeight;
|
||||
const maxHeight = parentRef.current.getBoundingClientRect().height - 32; // 32 is the border plus the breadcrumb height
|
||||
const maxHeightLessHeader = maxHeight - headerHeight;
|
||||
const tbodyHeight = Math.min(maxHeightLessHeader, fullTBodyHeight);
|
||||
|
||||
setState((prevState) => ({ ...prevState, tbodyHeight }));
|
||||
}
|
||||
}, [maxHeight, parsedData]);
|
||||
|
||||
// Makes sure rows are rendered before setting the renderer as loaded
|
||||
useEffect(() => {
|
||||
let tid: NodeJS.Timeout;
|
||||
|
||||
if (rowRef.current.length === parsedData.length) {
|
||||
tid = setTimeout(() => {
|
||||
setTableLoaded(true);
|
||||
}, 50); // Delay a bit to make sure the rows are rendered
|
||||
}
|
||||
|
||||
return () => clearTimeout(tid);
|
||||
}, [rowRef, parsedData]);
|
||||
|
||||
const table = useReactTable({
|
||||
manualPagination: true,
|
||||
data: parsedData,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={clsx("csv-view", { show: tableLoaded })} style={{ height: "auto" }}>
|
||||
<table className="probe">
|
||||
<tbody>
|
||||
<tr ref={probeRef}>
|
||||
<td>dummy data</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table {...listeners}>
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup, index) => (
|
||||
<tr key={headerGroup.id} ref={headerRef} id={headerGroup.id} tabIndex={index}>
|
||||
{headerGroup.headers.map((header, index) => (
|
||||
<th
|
||||
key={header.id}
|
||||
colSpan={header.colSpan}
|
||||
id={header.id}
|
||||
tabIndex={index}
|
||||
style={{ width: header.getSize() }}
|
||||
>
|
||||
{header.isPlaceholder ? null : (
|
||||
<div
|
||||
{...{
|
||||
className: header.column.getCanSort()
|
||||
? "inner cursor-pointer select-none"
|
||||
: "",
|
||||
onClick: header.column.getToggleSortingHandler(),
|
||||
}}
|
||||
>
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
{header.column.getIsSorted() === "asc" ? (
|
||||
<i className="sort-icon fa-sharp fa-solid fa-sort-up"></i>
|
||||
) : header.column.getIsSorted() === "desc" ? (
|
||||
<i className="sort-icon fa-sharp fa-solid fa-sort-down"></i>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody style={{ height: `${state.tbodyHeight}px` }} ref={tbodyRef}>
|
||||
{table.getRowModel().rows.map((row, index) => (
|
||||
<tr key={row.id} ref={(el) => (rowRef.current[index] = el)} id={row.id} tabIndex={index}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td key={cell.id} id={cell.id} tabIndex={index}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { CSVView };
|
||||
@@ -8,8 +8,10 @@ import * as WOS from "@/store/wos";
|
||||
import * as util from "@/util/util";
|
||||
import clsx from "clsx";
|
||||
import * as jotai from "jotai";
|
||||
import { useRef } from "react";
|
||||
import { CenteredDiv } from "../element/quickelems";
|
||||
import { CodeEdit } from "./codeedit";
|
||||
import { CSVView } from "./csvview";
|
||||
import { DirectoryPreview } from "./directorypreview";
|
||||
|
||||
import "./view.less";
|
||||
@@ -121,6 +123,21 @@ function CodeEditPreview({
|
||||
return <CodeEdit readonly={true} text={fileContent} filename={filename} />;
|
||||
}
|
||||
|
||||
function CSVViewPreview({
|
||||
parentRef,
|
||||
contentAtom,
|
||||
filename,
|
||||
readonly,
|
||||
}: {
|
||||
parentRef: React.MutableRefObject<HTMLDivElement>;
|
||||
contentAtom: jotai.Atom<Promise<string>>;
|
||||
filename: string;
|
||||
readonly: boolean;
|
||||
}) {
|
||||
const fileContent = jotai.useAtomValue(contentAtom);
|
||||
return <CSVView parentRef={parentRef} readonly={true} content={fileContent} filename={filename} />;
|
||||
}
|
||||
|
||||
function iconForFile(mimeType: string, fileName: string): string {
|
||||
if (mimeType == "application/pdf") {
|
||||
return "file-pdf";
|
||||
@@ -149,6 +166,7 @@ function iconForFile(mimeType: string, fileName: string): string {
|
||||
}
|
||||
|
||||
function PreviewView({ blockId }: { blockId: string }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const blockAtom = WOS.getWaveObjectAtom<Block>(`block:${blockId}`);
|
||||
const fileNameAtom: jotai.WritableAtom<string, [string], void> = useBlockCache(blockId, "preview:filename", () =>
|
||||
jotai.atom<string, [string], void>(
|
||||
@@ -220,6 +238,10 @@ function PreviewView({ blockId }: { blockId: string }) {
|
||||
specializedView = <CenteredDiv>File Too Large to Preview</CenteredDiv>;
|
||||
} else if (mimeType === "text/markdown") {
|
||||
specializedView = <MarkdownPreview contentAtom={fileContentAtom} />;
|
||||
} else if (mimeType === "text/csv") {
|
||||
specializedView = (
|
||||
<CSVViewPreview parentRef={ref} contentAtom={fileContentAtom} filename={fileName} readonly={true} />
|
||||
);
|
||||
} else if (
|
||||
mimeType.startsWith("text/") ||
|
||||
(mimeType.startsWith("application/") &&
|
||||
@@ -243,7 +265,7 @@ function PreviewView({ blockId }: { blockId: string }) {
|
||||
}, 10);
|
||||
|
||||
return (
|
||||
<div className="full-preview">
|
||||
<div ref={ref} className="full-preview">
|
||||
<DirNav cwdAtom={fileNameAtom} />
|
||||
{specializedView}
|
||||
</div>
|
||||
|
||||
@@ -98,7 +98,6 @@ function WorkspaceElem() {
|
||||
const windowData = jotai.useAtomValue(atoms.waveWindow);
|
||||
const activeTabId = windowData?.activetabid;
|
||||
const ws = jotai.useAtomValue(atoms.workspace);
|
||||
console.log("ws", ws);
|
||||
return (
|
||||
<div className="workspace">
|
||||
<TabBar workspace={ws} />
|
||||
|
||||
Reference in New Issue
Block a user