Merge pull request #5 from wavetermdev/sylvie/preview-dir

Preview Directory
This commit is contained in:
Mike Sawka 2024-05-21 16:03:42 -07:00 committed by GitHub
commit 9e7e7af04a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 480 additions and 249 deletions

View File

@ -0,0 +1,49 @@
.dir-table {
overflow: auto;
width: 100%;
border: 2px solid var(--border-color);
border-radius: 3px;
.dir-table-head {
.dir-table-head-row {
display: flex;
border-bottom: 2px solid var(--border-color);
padding: 4px 0;
.dir-table-head-cell {
position: relative;
padding: 2px 4px;
.dir-table-head-resize {
position: absolute;
top: 0;
right: 0;
height: 100%;
cursor: col-resize;
user-select: none;
touch-action: none;
width: 2px;
background: linear-gradient(var(--border-color), var(--border-color)) no-repeat center/2px 100%;
}
&:last-child {
.dir-table-head-resize {
width: 0;
}
}
}
}
}
.dir-table-body {
.dir-table-body-row {
display: flex;
border-radius: 3px;
&:focus {
background-color: var(--accent-color);
}
.dir-table-body-cell {
overflow: hidden;
padding: 0.25rem;
}
}
}
}

View File

@ -0,0 +1,109 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import React from "react";
import { createColumnHelper, flexRender, getCoreRowModel, useReactTable, Table } from "@tanstack/react-table";
import { FileInfo } from "@/bindings/fileservice";
import "./directorytable.less";
interface DirectoryTableProps {
data: FileInfo[];
}
const columnHelper = createColumnHelper<FileInfo>();
const defaultColumns = [
columnHelper.accessor("path", {
cell: (info) => info.getValue(),
header: () => <span>Name</span>,
}),
columnHelper.accessor("size", {
cell: (info) => info.getValue(),
header: () => <span>Size</span>,
}),
columnHelper.accessor("mimetype", {
cell: (info) => info.getValue(),
header: () => <span>Mimetype</span>,
}),
];
function DirectoryTable<T, U>({ data }: DirectoryTableProps) {
const [columns] = React.useState<typeof defaultColumns>(() => [...defaultColumns]);
const table = useReactTable({
data,
columns,
columnResizeMode: "onChange",
getCoreRowModel: getCoreRowModel(),
});
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"
key={header.id}
style={{ width: `calc(var(--header-${header.id}-size) * 1px)` }}
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
<div
className="dir-table-head-resize"
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
/>
</div>
))}
</div>
))}
</div>
{table.getState().columnSizingInfo.isResizingColumn ? (
<MemoizedTableBody table={table} />
) : (
<TableBody table={table} />
)}
</div>
);
}
function TableBody({ table }: { table: Table<FileInfo> }) {
return (
<div className="dir-table-body">
{table.getRowModel().rows.map((row) => (
<div className="dir-table-body-row" key={row.id} tabIndex={0}>
{row.getVisibleCells().map((cell) => (
<div
className="dir-table-body-cell"
key={cell.id}
style={{ width: `calc(var(--col-${cell.column.id}-size) * 1px)` }}
>
{cell.renderValue<any>()}
</div>
))}
</div>
))}
</div>
);
}
const MemoizedTableBody = React.memo(
TableBody,
(prev, next) => prev.table.options.data == next.table.options.data
) as typeof TableBody;
export { DirectoryTable };

View File

@ -1,3 +1,6 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
.modal-container {
position: absolute;
top: 0;

View File

@ -1,3 +1,6 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import React from "react";
import { Button } from "@/element/button";

View File

@ -8,6 +8,7 @@ import { Markdown } from "@/element/markdown";
import { FileService, FileInfo, FullFile } from "@/bindings/fileservice";
import * as util from "@/util/util";
import { CenteredDiv } from "../element/quickelems";
import { DirectoryTable } from "@/element/directorytable";
import "./view.less";
@ -53,6 +54,12 @@ function StreamingPreview({ fileInfo }: { fileInfo: FileInfo }) {
return <CenteredDiv>Preview Not Supported</CenteredDiv>;
}
function DirectoryPreview({ contentAtom }: { contentAtom: jotai.Atom<Promise<string>> }) {
const contentText = jotai.useAtomValue(contentAtom);
let content: FileInfo[] = JSON.parse(contentText);
return <DirectoryTable data={content} />;
}
function PreviewView({ blockId }: { blockId: string }) {
const blockDataAtom: jotai.Atom<BlockData> = blockDataMap.get(blockId);
const fileNameAtom = useBlockAtom(blockId, "preview:filename", () =>
@ -118,6 +125,9 @@ function PreviewView({ blockId }: { blockId: string }) {
</div>
);
}
if (mimeType === "directory") {
return <DirectoryPreview contentAtom={fileContentAtom} />;
}
return (
<div className="view-preview">
<div>Preview ({mimeType})</div>

View File

@ -63,4 +63,8 @@
object-fit: contain;
}
}
&.view-preview-directory {
flex-direction: column;
align-items: start;
}
}

View File

@ -92,6 +92,9 @@ function Widgets() {
<div className="widget" onClick={() => clickPreview("build/appicon.png")}>
<i className="fa fa-solid fa-files fa-fw" />
</div>
<div className="widget" onClick={() => clickPreview("~")}>
<i className="fa fa-solid fa-files fa-fw" />
</div>
<div className="widget" onClick={() => clickPlot()}>
<i className="fa fa-solid fa-chart-simple fa-fw" />
</div>

View File

@ -20,6 +20,7 @@
},
"dependencies": {
"@observablehq/plot": "^0.6.14",
"@tanstack/react-table": "^8.17.3",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"base64-js": "^1.5.1",

View File

@ -5,6 +5,7 @@ package fileservice
import (
"encoding/base64"
"encoding/json"
"fmt"
"os"
"path/filepath"
@ -41,9 +42,9 @@ func (fs *FileService) StatFile(path string) (*FileInfo, error) {
if err != nil {
return nil, fmt.Errorf("cannot stat file %q: %w", path, err)
}
mimeType := utilfn.DetectMimeType(path)
mimeType := utilfn.DetectMimeType(cleanedPath)
return &FileInfo{
Path: wavebase.ReplaceHomeDir(path),
Path: cleanedPath,
Size: finfo.Size(),
Mode: finfo.Mode(),
ModTime: finfo.ModTime().UnixMilli(),
@ -63,6 +64,35 @@ func (fs *FileService) ReadFile(path string) (*FullFile, error) {
if finfo.Size > MaxFileSize {
return nil, fmt.Errorf("file %q is too large to read, use /wave/stream-file", path)
}
if finfo.IsDir {
innerFilesEntries, err := os.ReadDir(finfo.Path)
if err != nil {
return nil, fmt.Errorf("unable to parse directory %s", finfo.Path)
}
var innerFilesInfo []FileInfo
for _, innerFileEntry := range innerFilesEntries {
innerFileInfoInt, _ := innerFileEntry.Info()
innerFileInfo := FileInfo{
Path: innerFileInfoInt.Name(),
Size: innerFileInfoInt.Size(),
Mode: innerFileInfoInt.Mode(),
ModTime: innerFileInfoInt.ModTime().UnixMilli(),
IsDir: innerFileInfoInt.IsDir(),
MimeType: "",
}
innerFilesInfo = append(innerFilesInfo, innerFileInfo)
}
filesSerialized, err := json.Marshal(innerFilesInfo)
if err != nil {
return nil, fmt.Errorf("unable to serialize files %s", finfo.Path)
}
return &FullFile{
Info: finfo,
Data64: base64.StdEncoding.EncodeToString(filesSerialized),
}, nil
}
cleanedPath := filepath.Clean(wavebase.ExpandHomeDir(path))
barr, err := os.ReadFile(cleanedPath)
if err != nil {

View File

@ -634,6 +634,13 @@ func DetectMimeType(path string) string {
if mimeType := mime.TypeByExtension(ext); mimeType != "" {
return mimeType
}
stats, err := os.Stat(path)
if err != nil {
return ""
}
if stats.IsDir() {
return "directory"
}
fd, err := os.Open(path)
if err != nil {
return ""

506
yarn.lock

File diff suppressed because it is too large Load Diff