mirror of
https://github.com/wavetermdev/waveterm.git
synced 2024-12-21 16:38:23 +01:00
CSV view (#73)
This commit is contained in:
parent
4714b88be7
commit
edb8eb25b8
99
frontend/app/view/csvview.less
Normal file
99
frontend/app/view/csvview.less
Normal file
@ -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;
|
||||
}
|
205
frontend/app/view/csvview.tsx
Normal file
205
frontend/app/view/csvview.tsx
Normal file
@ -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} />
|
||||
|
@ -25,6 +25,7 @@
|
||||
"@storybook/react-vite": "^8.1.9",
|
||||
"@storybook/test": "^8.1.9",
|
||||
"@types/node": "^20.12.12",
|
||||
"@types/papaparse": "^5",
|
||||
"@types/react": "^18.3.2",
|
||||
"@types/throttle-debounce": "^5",
|
||||
"@types/uuid": "^9.0.8",
|
||||
@ -53,6 +54,8 @@
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"@observablehq/plot": "^0.6.14",
|
||||
"@react-hook/resize-observer": "^2.0.1",
|
||||
"@table-nav/core": "^0.0.7",
|
||||
"@table-nav/react": "^0.0.7",
|
||||
"@tanstack/react-table": "^8.17.3",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-serialize": "^0.13.0",
|
||||
@ -66,6 +69,7 @@
|
||||
"jotai": "^2.8.0",
|
||||
"monaco-editor": "^0.49.0",
|
||||
"overlayscrollbars": "^2.8.3",
|
||||
"papaparse": "^5.4.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
|
36
yarn.lock
36
yarn.lock
@ -3889,6 +3889,22 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@table-nav/core@npm:^0.0.7":
|
||||
version: 0.0.7
|
||||
resolution: "@table-nav/core@npm:0.0.7"
|
||||
checksum: 10c0/75955f8ed2c0beef56bfe9dcf9ee0f24d593a49eaa364edbf0d32023242cb39172855d390838c8e818dbed5aa3bf0891151d973bc203fd2f18b3e5072daf97aa
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@table-nav/react@npm:^0.0.7":
|
||||
version: 0.0.7
|
||||
resolution: "@table-nav/react@npm:0.0.7"
|
||||
peerDependencies:
|
||||
"@table-nav/core": ^0.0.7
|
||||
checksum: 10c0/a03baf6fb38bd92260823f15f8309f31fdffec72d2ef43e4d8c808c0aa2081e9e4147f675e961270fa676bcc8361388c08d9dfbaad14458d5cd2b22e76de39f0
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tanstack/react-table@npm:^8.17.3":
|
||||
version: 8.17.3
|
||||
resolution: "@tanstack/react-table@npm:8.17.3"
|
||||
@ -4323,6 +4339,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/papaparse@npm:^5":
|
||||
version: 5.3.14
|
||||
resolution: "@types/papaparse@npm:5.3.14"
|
||||
dependencies:
|
||||
"@types/node": "npm:*"
|
||||
checksum: 10c0/feb4d215903b67442feaa9836a6a5771e78dc6a9da24781e399c6f891622fa82245cd783ab2613c5be43e4a2d6a94da52325538e4485af258166864576ecd0d8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/pretty-hrtime@npm:^1.0.0":
|
||||
version: 1.0.3
|
||||
resolution: "@types/pretty-hrtime@npm:1.0.3"
|
||||
@ -10413,6 +10438,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"papaparse@npm:^5.4.1":
|
||||
version: 5.4.1
|
||||
resolution: "papaparse@npm:5.4.1"
|
||||
checksum: 10c0/201f37c4813453fed5bfb4c01816696b099d2db9ff1e8fb610acc4771fdde91d2a22b6094721edb0fedb21ca3c46f04263f68be4beb3e35b8c72278f0cedc7b7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"parent-module@npm:^1.0.0":
|
||||
version: 1.0.1
|
||||
resolution: "parent-module@npm:1.0.1"
|
||||
@ -12288,8 +12320,11 @@ __metadata:
|
||||
"@storybook/react": "npm:^8.1.9"
|
||||
"@storybook/react-vite": "npm:^8.1.9"
|
||||
"@storybook/test": "npm:^8.1.9"
|
||||
"@table-nav/core": "npm:^0.0.7"
|
||||
"@table-nav/react": "npm:^0.0.7"
|
||||
"@tanstack/react-table": "npm:^8.17.3"
|
||||
"@types/node": "npm:^20.12.12"
|
||||
"@types/papaparse": "npm:^5"
|
||||
"@types/react": "npm:^18.3.2"
|
||||
"@types/throttle-debounce": "npm:^5"
|
||||
"@types/uuid": "npm:^9.0.8"
|
||||
@ -12311,6 +12346,7 @@ __metadata:
|
||||
less: "npm:^4.2.0"
|
||||
monaco-editor: "npm:^0.49.0"
|
||||
overlayscrollbars: "npm:^2.8.3"
|
||||
papaparse: "npm:^5.4.1"
|
||||
prettier: "npm:^3.2.5"
|
||||
prettier-plugin-jsdoc: "npm:^1.3.0"
|
||||
prettier-plugin-organize-imports: "npm:^3.2.4"
|
||||
|
Loading…
Reference in New Issue
Block a user