diff --git a/frontend/app/view/csvview.less b/frontend/app/view/csvview.less new file mode 100644 index 00000000..dce70410 --- /dev/null +++ b/frontend/app/view/csvview.less @@ -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; +} diff --git a/frontend/app/view/csvview.tsx b/frontend/app/view/csvview.tsx new file mode 100644 index 00000000..f80c5c66 --- /dev/null +++ b/frontend/app/view/csvview.tsx @@ -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; + content: string; + filename: string; + readonly: boolean; +} + +interface State { + content: string | null; + showReadonly: boolean; + tbodyHeight: number; +} + +const columnHelper = createColumnHelper(); + +const CSVView = ({ parentRef, filename, content }: CSVViewProps) => { + const csvCacheRef = useRef(new Map()); + const rowRef = useRef<(HTMLTableRowElement | null)[]>([]); + const headerRef = useRef(null); + const probeRef = useRef(null); + const tbodyRef = useRef(null); + const [state, setState] = useState({ + 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(() => { + 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 ( +
+ + + + + + +
dummy data
+ + + {table.getHeaderGroups().map((headerGroup, index) => ( + + {headerGroup.headers.map((header, index) => ( + + ))} + + ))} + + + {table.getRowModel().rows.map((row, index) => ( + (rowRef.current[index] = el)} id={row.id} tabIndex={index}> + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
+ {header.isPlaceholder ? null : ( +
+ {flexRender(header.column.columnDef.header, header.getContext())} + {header.column.getIsSorted() === "asc" ? ( + + ) : header.column.getIsSorted() === "desc" ? ( + + ) : null} +
+ )} +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+ ); +}; + +export { CSVView }; diff --git a/frontend/app/view/preview.tsx b/frontend/app/view/preview.tsx index 5aae0856..953409dc 100644 --- a/frontend/app/view/preview.tsx +++ b/frontend/app/view/preview.tsx @@ -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 ; } +function CSVViewPreview({ + parentRef, + contentAtom, + filename, + readonly, +}: { + parentRef: React.MutableRefObject; + contentAtom: jotai.Atom>; + filename: string; + readonly: boolean; +}) { + const fileContent = jotai.useAtomValue(contentAtom); + return ; +} + 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(null); const blockAtom = WOS.getWaveObjectAtom(`block:${blockId}`); const fileNameAtom: jotai.WritableAtom = useBlockCache(blockId, "preview:filename", () => jotai.atom( @@ -220,6 +238,10 @@ function PreviewView({ blockId }: { blockId: string }) { specializedView = File Too Large to Preview; } else if (mimeType === "text/markdown") { specializedView = ; + } else if (mimeType === "text/csv") { + specializedView = ( + + ); } else if ( mimeType.startsWith("text/") || (mimeType.startsWith("application/") && @@ -243,7 +265,7 @@ function PreviewView({ blockId }: { blockId: string }) { }, 10); return ( -
+
{specializedView}
diff --git a/frontend/app/workspace/workspace.tsx b/frontend/app/workspace/workspace.tsx index 306f879d..6f2109b0 100644 --- a/frontend/app/workspace/workspace.tsx +++ b/frontend/app/workspace/workspace.tsx @@ -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 (
diff --git a/package.json b/package.json index e12c8d6a..2a41f8d7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/yarn.lock b/yarn.lock index beb84cc7..7fc1d946 100644 --- a/yarn.lock +++ b/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"