mirror of
https://github.com/wavetermdev/waveterm
synced 2026-05-22 00:08:30 +00:00
fix bug with CodeEditor/monaco model, more preview refactoring (#2353)
the primary purpose of this PR is to fix a showstopper bug in the CodeEditor component by setting "path" to a stable UUID. the bug was that it started as empty string, so it created a shared model between all of the codeeditor components. now each will get their own monaco model. also took this opportunity to do more more preview view refactoring, splitting up code, and more tailwind migrations.
This commit is contained in:
parent
50cc08a769
commit
77bbf74ef9
13 changed files with 468 additions and 597 deletions
|
|
@ -162,7 +162,7 @@ export const resolveRemoteFile = async (filepath: string, resolveOpts: MarkdownR
|
|||
const baseDirUri = formatRemoteUri(resolveOpts.baseDir, resolveOpts.connName);
|
||||
const fileInfo = await RpcApi.FileJoinCommand(TabRpcClient, [baseDirUri, filepath]);
|
||||
const remoteUri = formatRemoteUri(fileInfo.path, resolveOpts.connName);
|
||||
console.log("markdown resolve", resolveOpts, filepath, "=>", baseDirUri, remoteUri);
|
||||
// console.log("markdown resolve", resolveOpts, filepath, "=>", baseDirUri, remoteUri);
|
||||
const usp = new URLSearchParams();
|
||||
usp.set("path", remoteUri);
|
||||
return getWebServerEndpoint() + "/wave/stream-file?" + usp.toString();
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
transformBlocks,
|
||||
} from "@/app/element/markdown-util";
|
||||
import remarkMermaidToTag from "@/app/element/remark-mermaid-to-tag";
|
||||
import { boundNumber, useAtomValueSafe } from "@/util/util";
|
||||
import { boundNumber, useAtomValueSafe, cn } from "@/util/util";
|
||||
import clsx from "clsx";
|
||||
import { Atom } from "jotai";
|
||||
import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from "overlayscrollbars-react";
|
||||
|
|
@ -297,6 +297,7 @@ type MarkdownProps = {
|
|||
showTocAtom?: Atom<boolean>;
|
||||
style?: React.CSSProperties;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
onClickExecute?: (cmd: string) => void;
|
||||
resolveOpts?: MarkdownResolveOpts;
|
||||
scrollable?: boolean;
|
||||
|
|
@ -311,6 +312,7 @@ const Markdown = ({
|
|||
showTocAtom,
|
||||
style,
|
||||
className,
|
||||
contentClassName,
|
||||
resolveOpts,
|
||||
fontSizeOverride,
|
||||
fixedFontSizeOverride,
|
||||
|
|
@ -383,19 +385,30 @@ const Markdown = ({
|
|||
};
|
||||
|
||||
const toc = useMemo(() => {
|
||||
if (showToc && tocRef.current.length > 0) {
|
||||
return tocRef.current.map((item) => {
|
||||
if (showToc) {
|
||||
if (tocRef.current.length > 0) {
|
||||
return tocRef.current.map((item) => {
|
||||
return (
|
||||
<a
|
||||
key={item.href}
|
||||
className="toc-item"
|
||||
style={{ "--indent-factor": item.depth } as React.CSSProperties}
|
||||
onClick={() => setFocusedHeading(item.href)}
|
||||
>
|
||||
{item.value}
|
||||
</a>
|
||||
);
|
||||
});
|
||||
} else {
|
||||
return (
|
||||
<a
|
||||
key={item.href}
|
||||
className="toc-item"
|
||||
style={{ "--indent-factor": item.depth } as React.CSSProperties}
|
||||
onClick={() => setFocusedHeading(item.href)}
|
||||
<div
|
||||
className="toc-item toc-empty text-secondary"
|
||||
style={{ "--indent-factor": 2 } as React.CSSProperties}
|
||||
>
|
||||
{item.value}
|
||||
</a>
|
||||
No sub-headings found
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [showToc, tocRef]);
|
||||
|
||||
|
|
@ -444,7 +457,7 @@ const Markdown = ({
|
|||
return (
|
||||
<OverlayScrollbarsComponent
|
||||
ref={contentsOsRef}
|
||||
className="content"
|
||||
className={cn("content", contentClassName)}
|
||||
options={{ scrollbars: { autoHide: "leave" } }}
|
||||
>
|
||||
<ReactMarkdown
|
||||
|
|
@ -460,7 +473,7 @@ const Markdown = ({
|
|||
|
||||
const NonScrollableMarkdown = () => {
|
||||
return (
|
||||
<div className="content non-scrollable">
|
||||
<div className={cn("content non-scrollable", contentClassName)}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={rehypePlugins}
|
||||
|
|
@ -483,9 +496,9 @@ const Markdown = ({
|
|||
<div className={clsx("markdown", className)} style={mergedStyle}>
|
||||
{scrollable ? <ScrollableMarkdown /> : <NonScrollableMarkdown />}
|
||||
{toc && (
|
||||
<OverlayScrollbarsComponent className="toc" options={{ scrollbars: { autoHide: "leave" } }}>
|
||||
<OverlayScrollbarsComponent className="toc mt-1" options={{ scrollbars: { autoHide: "leave" } }}>
|
||||
<div className="toc-inner">
|
||||
<h4>Table of Contents</h4>
|
||||
<h4 className="font-bold">Table of Contents</h4>
|
||||
{toc}
|
||||
</div>
|
||||
</OverlayScrollbarsComponent>
|
||||
|
|
|
|||
|
|
@ -8,9 +8,7 @@ import type * as MonacoTypes from "monaco-editor/esm/vs/editor/editor.api";
|
|||
import { configureMonacoYaml } from "monaco-yaml";
|
||||
import React, { useMemo, useRef } from "react";
|
||||
|
||||
import { RpcApi } from "@/app/store/wshclientapi";
|
||||
import { TabRpcClient } from "@/app/store/wshrpcutil";
|
||||
import { boundNumber, makeConnRoute } from "@/util/util";
|
||||
import { boundNumber } from "@/util/util";
|
||||
import editorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker";
|
||||
import cssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker";
|
||||
import htmlWorker from "monaco-editor/esm/vs/language/html/html.worker?worker";
|
||||
|
|
@ -19,7 +17,6 @@ import tsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker"
|
|||
import { SchemaEndpoints, getSchemaEndpointInfo } from "./schemaendpoints";
|
||||
import ymlWorker from "./yamlworker?worker";
|
||||
|
||||
|
||||
// there is a global monaco variable (TODO get the correct TS type)
|
||||
declare var monaco: Monaco;
|
||||
|
||||
|
|
@ -109,15 +106,13 @@ function defaultEditorOptions(): MonacoTypes.editor.IEditorOptions {
|
|||
interface CodeEditorProps {
|
||||
blockId: string;
|
||||
text: string;
|
||||
filename: string;
|
||||
fileinfo: FileInfo;
|
||||
readonly: boolean;
|
||||
language?: string;
|
||||
meta?: MetaType;
|
||||
onChange?: (text: string) => void;
|
||||
onMount?: (monacoPtr: MonacoTypes.editor.IStandaloneCodeEditor, monaco: Monaco) => () => void;
|
||||
}
|
||||
|
||||
export function CodeEditor({ blockId, text, language, filename, fileinfo, meta, onChange, onMount }: CodeEditorProps) {
|
||||
export function CodeEditor({ blockId, text, language, readonly, onChange, onMount }: CodeEditorProps) {
|
||||
const divRef = useRef<HTMLDivElement>(null);
|
||||
const unmountRef = useRef<() => void>(null);
|
||||
const minimapEnabled = useOverrideConfigAtom(blockId, "editor:minimapenabled") ?? false;
|
||||
|
|
@ -125,7 +120,7 @@ export function CodeEditor({ blockId, text, language, filename, fileinfo, meta,
|
|||
const wordWrap = useOverrideConfigAtom(blockId, "editor:wordwrap") ?? false;
|
||||
const fontSize = boundNumber(useOverrideConfigAtom(blockId, "editor:fontsize"), 6, 64);
|
||||
const theme = "wave-theme-dark";
|
||||
const [absPath, setAbsPath] = React.useState("");
|
||||
const editorPath = useRef(crypto.randomUUID()).current;
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
|
|
@ -136,24 +131,6 @@ export function CodeEditor({ blockId, text, language, filename, fileinfo, meta,
|
|||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const inner = async () => {
|
||||
try {
|
||||
const fileInfo = await RpcApi.RemoteFileJoinCommand(TabRpcClient, [filename], {
|
||||
route: makeConnRoute(meta.connection ?? ""),
|
||||
});
|
||||
setAbsPath(fileInfo.path);
|
||||
} catch (e) {
|
||||
setAbsPath(filename);
|
||||
}
|
||||
};
|
||||
inner();
|
||||
}, [filename]);
|
||||
|
||||
React.useEffect(() => {
|
||||
console.log("abspath is", absPath);
|
||||
}, [absPath]);
|
||||
|
||||
function handleEditorChange(text: string, ev: MonacoTypes.editor.IModelContentChangedEvent) {
|
||||
if (onChange) {
|
||||
onChange(text);
|
||||
|
|
@ -168,13 +145,13 @@ export function CodeEditor({ blockId, text, language, filename, fileinfo, meta,
|
|||
|
||||
const editorOpts = useMemo(() => {
|
||||
const opts = defaultEditorOptions();
|
||||
opts.readOnly = fileinfo.readonly;
|
||||
opts.readOnly = readonly;
|
||||
opts.minimap.enabled = minimapEnabled;
|
||||
opts.stickyScroll.enabled = stickyScrollEnabled;
|
||||
opts.wordWrap = wordWrap ? "on" : "off";
|
||||
opts.fontSize = fontSize;
|
||||
return opts;
|
||||
}, [minimapEnabled, stickyScrollEnabled, wordWrap, fontSize, fileinfo.readonly]);
|
||||
}, [minimapEnabled, stickyScrollEnabled, wordWrap, fontSize, readonly]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden items-center justify-center">
|
||||
|
|
@ -185,7 +162,7 @@ export function CodeEditor({ blockId, text, language, filename, fileinfo, meta,
|
|||
options={editorOpts}
|
||||
onChange={handleEditorChange}
|
||||
onMount={handleEditorOnMount}
|
||||
path={absPath}
|
||||
path={editorPath}
|
||||
language={language}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -90,16 +90,6 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 5px 5px 5px;
|
||||
.dir-table-body-search-display {
|
||||
display: flex;
|
||||
border-radius: 3px;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background-color: var(--warning-color);
|
||||
|
||||
.search-display-close-button {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.dir-table-body-scroll-box {
|
||||
position: relative;
|
||||
|
|
@ -185,52 +175,6 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dir-table-search-line {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.7rem;
|
||||
|
||||
.dir-table-search-box {
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dir-table-button {
|
||||
background-color: transparent;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
padding: 0.2rem;
|
||||
border-radius: 6px;
|
||||
|
||||
input {
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--highlight-bg-color);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
background-color: var(--highlight-bg-color);
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
background-color: var(--highlight-bg-color);
|
||||
}
|
||||
}
|
||||
|
||||
.entry-manager-overlay {
|
||||
|
|
|
|||
65
frontend/app/view/preview/entry-manager.tsx
Normal file
65
frontend/app/view/preview/entry-manager.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
// Copyright 2025, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { Button } from "@/app/element/button";
|
||||
import { Input } from "@/app/element/input";
|
||||
import React, { memo, useState } from "react";
|
||||
|
||||
export enum EntryManagerType {
|
||||
NewFile = "New File",
|
||||
NewDirectory = "New Folder",
|
||||
EditName = "Rename",
|
||||
}
|
||||
|
||||
export type EntryManagerOverlayProps = {
|
||||
forwardRef?: React.Ref<HTMLDivElement>;
|
||||
entryManagerType: EntryManagerType;
|
||||
startingValue?: string;
|
||||
onSave: (newValue: string) => void;
|
||||
onCancel?: () => void;
|
||||
style?: React.CSSProperties;
|
||||
getReferenceProps?: () => any;
|
||||
};
|
||||
|
||||
export const EntryManagerOverlay = memo(
|
||||
({
|
||||
entryManagerType,
|
||||
startingValue,
|
||||
onSave,
|
||||
onCancel,
|
||||
forwardRef,
|
||||
style,
|
||||
getReferenceProps,
|
||||
}: EntryManagerOverlayProps) => {
|
||||
const [value, setValue] = useState(startingValue);
|
||||
return (
|
||||
<div className="entry-manager-overlay" ref={forwardRef} style={style} {...(getReferenceProps?.() ?? {})}>
|
||||
<div className="entry-manager-type">{entryManagerType}</div>
|
||||
<div className="entry-manager-input">
|
||||
<Input
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
autoFocus={true}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onSave(value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="entry-manager-buttons">
|
||||
<Button className="vertical-padding-4" onClick={() => onSave(value)}>
|
||||
Save
|
||||
</Button>
|
||||
<Button className="vertical-padding-4 red outlined" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
EntryManagerOverlay.displayName = "EntryManagerOverlay";
|
||||
175
frontend/app/view/preview/preview-directory-utils.tsx
Normal file
175
frontend/app/view/preview/preview-directory-utils.tsx
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
// Copyright 2025, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { globalStore } from "@/app/store/global";
|
||||
import { RpcApi } from "@/app/store/wshclientapi";
|
||||
import { TabRpcClient } from "@/app/store/wshrpcutil";
|
||||
import { fireAndForget, isBlank } from "@/util/util";
|
||||
import { Column } from "@tanstack/react-table";
|
||||
import dayjs from "dayjs";
|
||||
import React from "react";
|
||||
import { type PreviewModel } from "./preview-model";
|
||||
|
||||
export const recursiveError = "recursive flag must be set for directory operations";
|
||||
export const overwriteError = "set overwrite flag to delete the existing file";
|
||||
export const mergeError = "set overwrite flag to delete the existing contents or set merge flag to merge the contents";
|
||||
|
||||
export const displaySuffixes = {
|
||||
B: "b",
|
||||
kB: "k",
|
||||
MB: "m",
|
||||
GB: "g",
|
||||
TB: "t",
|
||||
KiB: "k",
|
||||
MiB: "m",
|
||||
GiB: "g",
|
||||
TiB: "t",
|
||||
};
|
||||
|
||||
export function getBestUnit(bytes: number, si = false, sigfig = 3): string {
|
||||
if (bytes == null || !Number.isFinite(bytes) || bytes < 0) return "-";
|
||||
if (bytes === 0) return "0B";
|
||||
|
||||
const units = si ? ["kB", "MB", "GB", "TB"] : ["KiB", "MiB", "GiB", "TiB"];
|
||||
const divisor = si ? 1000 : 1024;
|
||||
|
||||
const idx = Math.min(Math.floor(Math.log(bytes) / Math.log(divisor)), units.length);
|
||||
const unit = idx === 0 ? "B" : units[idx - 1];
|
||||
const value = bytes / Math.pow(divisor, idx);
|
||||
|
||||
return `${parseFloat(value.toPrecision(sigfig))}${displaySuffixes[unit] ?? unit}`;
|
||||
}
|
||||
|
||||
export function getLastModifiedTime(unixMillis: number, column: Column<FileInfo, number>): string {
|
||||
const fileDatetime = dayjs(new Date(unixMillis));
|
||||
const nowDatetime = dayjs(new Date());
|
||||
|
||||
let datePortion: string;
|
||||
if (nowDatetime.isSame(fileDatetime, "date")) {
|
||||
datePortion = "Today";
|
||||
} else if (nowDatetime.subtract(1, "day").isSame(fileDatetime, "date")) {
|
||||
datePortion = "Yesterday";
|
||||
} else {
|
||||
datePortion = dayjs(fileDatetime).format("M/D/YY");
|
||||
}
|
||||
|
||||
if (column.getSize() > 120) {
|
||||
return `${datePortion}, ${dayjs(fileDatetime).format("h:mm A")}`;
|
||||
}
|
||||
return datePortion;
|
||||
}
|
||||
|
||||
const iconRegex = /^[a-z0-9- ]+$/;
|
||||
|
||||
export function isIconValid(icon: string): boolean {
|
||||
if (isBlank(icon)) {
|
||||
return false;
|
||||
}
|
||||
return icon.match(iconRegex) != null;
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
}
|
||||
|
||||
export function cleanMimetype(input: string): string {
|
||||
const truncated = input.split(";")[0];
|
||||
return truncated.trim();
|
||||
}
|
||||
|
||||
export function handleRename(
|
||||
model: PreviewModel,
|
||||
path: string,
|
||||
newPath: string,
|
||||
isDir: boolean,
|
||||
recursive: boolean,
|
||||
setErrorMsg: (msg: ErrorMsg) => void
|
||||
) {
|
||||
fireAndForget(async () => {
|
||||
try {
|
||||
let srcuri = await model.formatRemoteUri(path, globalStore.get);
|
||||
if (isDir) {
|
||||
srcuri += "/";
|
||||
}
|
||||
await RpcApi.FileMoveCommand(TabRpcClient, {
|
||||
srcuri,
|
||||
desturi: await model.formatRemoteUri(newPath, globalStore.get),
|
||||
opts: {
|
||||
recursive,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
const errorText = `${e}`;
|
||||
console.warn(`Rename failed: ${errorText}`);
|
||||
let errorMsg: ErrorMsg;
|
||||
if (errorText.includes(recursiveError) && !recursive) {
|
||||
errorMsg = {
|
||||
status: "Confirm Rename Directory",
|
||||
text: "Renaming a directory requires the recursive flag. Proceed?",
|
||||
level: "warning",
|
||||
buttons: [
|
||||
{
|
||||
text: "Rename Recursively",
|
||||
onClick: () => handleRename(model, path, newPath, isDir, true, setErrorMsg),
|
||||
},
|
||||
],
|
||||
};
|
||||
} else {
|
||||
errorMsg = {
|
||||
status: "Rename Failed",
|
||||
text: `${e}`,
|
||||
};
|
||||
}
|
||||
setErrorMsg(errorMsg);
|
||||
}
|
||||
model.refreshCallback();
|
||||
});
|
||||
}
|
||||
|
||||
export function handleFileDelete(
|
||||
model: PreviewModel,
|
||||
path: string,
|
||||
recursive: boolean,
|
||||
setErrorMsg: (msg: ErrorMsg) => void
|
||||
) {
|
||||
fireAndForget(async () => {
|
||||
const formattedPath = await model.formatRemoteUri(path, globalStore.get);
|
||||
try {
|
||||
await RpcApi.FileDeleteCommand(TabRpcClient, {
|
||||
path: formattedPath,
|
||||
recursive,
|
||||
});
|
||||
} catch (e) {
|
||||
const errorText = `${e}`;
|
||||
console.warn(`Delete failed: ${errorText}`);
|
||||
let errorMsg: ErrorMsg;
|
||||
if (errorText.includes(recursiveError) && !recursive) {
|
||||
errorMsg = {
|
||||
status: "Confirm Delete Directory",
|
||||
text: "Deleting a directory requires the recursive flag. Proceed?",
|
||||
level: "warning",
|
||||
buttons: [
|
||||
{
|
||||
text: "Delete Recursively",
|
||||
onClick: () => handleFileDelete(model, path, true, setErrorMsg),
|
||||
},
|
||||
],
|
||||
};
|
||||
} else {
|
||||
errorMsg = {
|
||||
status: "Delete Failed",
|
||||
text: `${e}`,
|
||||
};
|
||||
}
|
||||
setErrorMsg(errorMsg);
|
||||
}
|
||||
model.refreshCallback();
|
||||
});
|
||||
}
|
||||
|
|
@ -1,8 +1,6 @@
|
|||
// Copyright 2025, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { Button } from "@/app/element/button";
|
||||
import { Input } from "@/app/element/input";
|
||||
import { ContextMenuModel } from "@/app/store/contextmenu";
|
||||
import { atoms, getApi, globalStore } from "@/app/store/global";
|
||||
import { RpcApi } from "@/app/store/wshclientapi";
|
||||
|
|
@ -10,11 +8,11 @@ import { TabRpcClient } from "@/app/store/wshrpcutil";
|
|||
import { checkKeyPressed, isCharacterKeyEvent } from "@/util/keyutil";
|
||||
import { PLATFORM, PlatformMacOS } from "@/util/platformutil";
|
||||
import { addOpenMenuItems } from "@/util/previewutil";
|
||||
import { fireAndForget, isBlank } from "@/util/util";
|
||||
import { fireAndForget } from "@/util/util";
|
||||
import { formatRemoteUri } from "@/util/waveutil";
|
||||
import { offset, useDismiss, useFloating, useInteractions } from "@floating-ui/react";
|
||||
import {
|
||||
Column,
|
||||
Header,
|
||||
Row,
|
||||
RowData,
|
||||
Table,
|
||||
|
|
@ -25,21 +23,54 @@ import {
|
|||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import clsx from "clsx";
|
||||
import dayjs from "dayjs";
|
||||
import { PrimitiveAtom, atom, useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from "overlayscrollbars-react";
|
||||
import React, { Fragment, memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useDrag, useDrop } from "react-dnd";
|
||||
import { quote as shellQuote } from "shell-quote";
|
||||
import { debounce } from "throttle-debounce";
|
||||
import "./directorypreview.scss";
|
||||
import { EntryManagerOverlay, EntryManagerOverlayProps, EntryManagerType } from "./entry-manager";
|
||||
import {
|
||||
cleanMimetype,
|
||||
getBestUnit,
|
||||
getLastModifiedTime,
|
||||
getSortIcon,
|
||||
handleFileDelete,
|
||||
handleRename,
|
||||
isIconValid,
|
||||
mergeError,
|
||||
overwriteError,
|
||||
} from "./preview-directory-utils";
|
||||
import { type PreviewModel } from "./preview-model";
|
||||
|
||||
const PageJumpSize = 20;
|
||||
|
||||
const recursiveError = "recursive flag must be set for directory operations";
|
||||
const overwriteError = "set overwrite flag to delete the existing file";
|
||||
const mergeError = "set overwrite flag to delete the existing contents or set merge flag to merge the contents";
|
||||
interface DirectoryTableHeaderCellProps {
|
||||
header: Header<FileInfo, unknown>;
|
||||
}
|
||||
|
||||
function DirectoryTableHeaderCell({ header }: DirectoryTableHeaderCellProps) {
|
||||
return (
|
||||
<div
|
||||
className="dir-table-head-cell"
|
||||
key={header.id}
|
||||
style={{ width: `calc(var(--header-${header.id}-size) * 1px)` }}
|
||||
>
|
||||
<div className="dir-table-head-cell-content" 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>
|
||||
);
|
||||
}
|
||||
|
||||
declare module "@tanstack/react-table" {
|
||||
interface TableMeta<TData extends RowData> {
|
||||
|
|
@ -65,138 +96,6 @@ interface DirectoryTableProps {
|
|||
|
||||
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 === undefined || 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 getLastModifiedTime(unixMillis: number, column: Column<FileInfo, number>): string {
|
||||
const fileDatetime = dayjs(new Date(unixMillis));
|
||||
const nowDatetime = dayjs(new Date());
|
||||
|
||||
let datePortion: string;
|
||||
if (nowDatetime.isSame(fileDatetime, "date")) {
|
||||
datePortion = "Today";
|
||||
} else if (nowDatetime.subtract(1, "day").isSame(fileDatetime, "date")) {
|
||||
datePortion = "Yesterday";
|
||||
} else {
|
||||
datePortion = dayjs(fileDatetime).format("M/D/YY");
|
||||
}
|
||||
|
||||
if (column.getSize() > 120) {
|
||||
return `${datePortion}, ${dayjs(fileDatetime).format("h:mm A")}`;
|
||||
}
|
||||
return datePortion;
|
||||
}
|
||||
|
||||
const iconRegex = /^[a-z0-9- ]+$/;
|
||||
|
||||
function isIconValid(icon: string): boolean {
|
||||
if (isBlank(icon)) {
|
||||
return false;
|
||||
}
|
||||
return icon.match(iconRegex) != null;
|
||||
}
|
||||
|
||||
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 {
|
||||
const truncated = input.split(";")[0];
|
||||
return truncated.trim();
|
||||
}
|
||||
|
||||
enum EntryManagerType {
|
||||
NewFile = "New File",
|
||||
NewDirectory = "New Folder",
|
||||
EditName = "Rename",
|
||||
}
|
||||
|
||||
type EntryManagerOverlayProps = {
|
||||
forwardRef?: React.Ref<HTMLDivElement>;
|
||||
entryManagerType: EntryManagerType;
|
||||
startingValue?: string;
|
||||
onSave: (newValue: string) => void;
|
||||
onCancel?: () => void;
|
||||
style?: React.CSSProperties;
|
||||
getReferenceProps?: () => any;
|
||||
};
|
||||
|
||||
const EntryManagerOverlay = memo(
|
||||
({
|
||||
entryManagerType,
|
||||
startingValue,
|
||||
onSave,
|
||||
onCancel,
|
||||
forwardRef,
|
||||
style,
|
||||
getReferenceProps,
|
||||
}: EntryManagerOverlayProps) => {
|
||||
const [value, setValue] = useState(startingValue);
|
||||
return (
|
||||
<div className="entry-manager-overlay" ref={forwardRef} style={style} {...getReferenceProps()}>
|
||||
<div className="entry-manager-type">{entryManagerType}</div>
|
||||
<div className="entry-manager-input">
|
||||
<Input
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
autoFocus={true}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onSave(value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="entry-manager-buttons">
|
||||
<Button className="vertical-padding-4" onClick={() => onSave(value)}>
|
||||
Save
|
||||
</Button>
|
||||
<Button className="vertical-padding-4 red outlined" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
function DirectoryTable({
|
||||
model,
|
||||
data,
|
||||
|
|
@ -287,63 +186,26 @@ function DirectoryTable({
|
|||
|
||||
const setEntryManagerProps = useSetAtom(entryManagerOverlayPropsAtom);
|
||||
|
||||
const updateName = useCallback((path: string, isDir: boolean) => {
|
||||
const fileName = path.split("/").at(-1);
|
||||
setEntryManagerProps({
|
||||
entryManagerType: EntryManagerType.EditName,
|
||||
startingValue: fileName,
|
||||
onSave: (newName: string) => {
|
||||
let newPath: string;
|
||||
if (newName !== fileName) {
|
||||
const lastInstance = path.lastIndexOf(fileName);
|
||||
newPath = path.substring(0, lastInstance) + newName;
|
||||
console.log(`replacing ${fileName} with ${newName}: ${path}`);
|
||||
const handleRename = (recursive: boolean) =>
|
||||
fireAndForget(async () => {
|
||||
try {
|
||||
let srcuri = await model.formatRemoteUri(path, globalStore.get);
|
||||
if (isDir) {
|
||||
srcuri += "/";
|
||||
}
|
||||
await RpcApi.FileMoveCommand(TabRpcClient, {
|
||||
srcuri,
|
||||
desturi: await model.formatRemoteUri(newPath, globalStore.get),
|
||||
opts: {
|
||||
recursive,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
const errorText = `${e}`;
|
||||
console.warn(`Rename failed: ${errorText}`);
|
||||
let errorMsg: ErrorMsg;
|
||||
if (errorText.includes(recursiveError)) {
|
||||
errorMsg = {
|
||||
status: "Confirm Rename Directory",
|
||||
text: "Renaming a directory requires the recursive flag. Proceed?",
|
||||
level: "warning",
|
||||
buttons: [
|
||||
{
|
||||
text: "Rename Recursively",
|
||||
onClick: () => handleRename(true),
|
||||
},
|
||||
],
|
||||
};
|
||||
} else {
|
||||
errorMsg = {
|
||||
status: "Rename Failed",
|
||||
text: `${e}`,
|
||||
};
|
||||
}
|
||||
setErrorMsg(errorMsg);
|
||||
}
|
||||
model.refreshCallback();
|
||||
});
|
||||
handleRename(false);
|
||||
}
|
||||
setEntryManagerProps(undefined);
|
||||
},
|
||||
});
|
||||
}, []);
|
||||
const updateName = useCallback(
|
||||
(path: string, isDir: boolean) => {
|
||||
const fileName = path.split("/").at(-1);
|
||||
setEntryManagerProps({
|
||||
entryManagerType: EntryManagerType.EditName,
|
||||
startingValue: fileName,
|
||||
onSave: (newName: string) => {
|
||||
let newPath: string;
|
||||
if (newName !== fileName) {
|
||||
const lastInstance = path.lastIndexOf(fileName);
|
||||
newPath = path.substring(0, lastInstance) + newName;
|
||||
console.log(`replacing ${fileName} with ${newName}: ${path}`);
|
||||
handleRename(model, path, newPath, isDir, false, setErrorMsg);
|
||||
}
|
||||
setEntryManagerProps(undefined);
|
||||
},
|
||||
});
|
||||
},
|
||||
[model, setErrorMsg]
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
|
|
@ -421,6 +283,9 @@ function DirectoryTable({
|
|||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const TableComponent = table.getState().columnSizingInfo.isResizingColumn ? MemoizedTableBody : TableBody;
|
||||
|
||||
return (
|
||||
<OverlayScrollbarsComponent
|
||||
options={{ scrollbars: { autoHide: "leave" } }}
|
||||
|
|
@ -434,61 +299,24 @@ function DirectoryTable({
|
|||
{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)` }}
|
||||
>
|
||||
<div
|
||||
className="dir-table-head-cell-content"
|
||||
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>
|
||||
<DirectoryTableHeaderCell key={header.id} header={header} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{table.getState().columnSizingInfo.isResizingColumn ? (
|
||||
<MemoizedTableBody
|
||||
bodyRef={bodyRef}
|
||||
model={model}
|
||||
data={data}
|
||||
table={table}
|
||||
search={search}
|
||||
focusIndex={focusIndex}
|
||||
setFocusIndex={setFocusIndex}
|
||||
setSearch={setSearch}
|
||||
setSelectedPath={setSelectedPath}
|
||||
setRefreshVersion={setRefreshVersion}
|
||||
osRef={osRef.current}
|
||||
/>
|
||||
) : (
|
||||
<TableBody
|
||||
bodyRef={bodyRef}
|
||||
model={model}
|
||||
data={data}
|
||||
table={table}
|
||||
search={search}
|
||||
focusIndex={focusIndex}
|
||||
setFocusIndex={setFocusIndex}
|
||||
setSearch={setSearch}
|
||||
setSelectedPath={setSelectedPath}
|
||||
setRefreshVersion={setRefreshVersion}
|
||||
osRef={osRef.current}
|
||||
/>
|
||||
)}
|
||||
<TableComponent
|
||||
bodyRef={bodyRef}
|
||||
model={model}
|
||||
data={data}
|
||||
table={table}
|
||||
search={search}
|
||||
focusIndex={focusIndex}
|
||||
setFocusIndex={setFocusIndex}
|
||||
setSearch={setSearch}
|
||||
setSelectedPath={setSelectedPath}
|
||||
setRefreshVersion={setRefreshVersion}
|
||||
osRef={osRef.current}
|
||||
/>
|
||||
</OverlayScrollbarsComponent>
|
||||
);
|
||||
}
|
||||
|
|
@ -563,40 +391,6 @@ function TableBody({
|
|||
return;
|
||||
}
|
||||
const fileName = finfo.path.split("/").pop();
|
||||
const handleFileDelete = (recursive: boolean) =>
|
||||
fireAndForget(async () => {
|
||||
const path = await model.formatRemoteUri(finfo.path, globalStore.get);
|
||||
try {
|
||||
await RpcApi.FileDeleteCommand(TabRpcClient, {
|
||||
path,
|
||||
recursive,
|
||||
});
|
||||
} catch (e) {
|
||||
const errorText = `${e}`;
|
||||
console.warn(`Delete failed: ${errorText}`);
|
||||
let errorMsg: ErrorMsg;
|
||||
if (errorText.includes(recursiveError)) {
|
||||
errorMsg = {
|
||||
status: "Confirm Delete Directory",
|
||||
text: "Deleting a directory requires the recursive flag. Proceed?",
|
||||
level: "warning",
|
||||
buttons: [
|
||||
{
|
||||
text: "Delete Recursively",
|
||||
onClick: () => handleFileDelete(true),
|
||||
},
|
||||
],
|
||||
};
|
||||
} else {
|
||||
errorMsg = {
|
||||
status: "Delete Failed",
|
||||
text: `${e}`,
|
||||
};
|
||||
}
|
||||
setErrorMsg(errorMsg);
|
||||
}
|
||||
setRefreshVersion((current) => current + 1);
|
||||
});
|
||||
const menu: ContextMenuItem[] = [
|
||||
{
|
||||
label: "New File",
|
||||
|
|
@ -643,7 +437,7 @@ function TableBody({
|
|||
},
|
||||
{
|
||||
label: "Delete",
|
||||
click: () => handleFileDelete(false),
|
||||
click: () => handleFileDelete(model, finfo.path, false, setErrorMsg),
|
||||
}
|
||||
);
|
||||
ContextMenuModel.showContextMenu(menu, e);
|
||||
|
|
@ -654,11 +448,19 @@ function TableBody({
|
|||
return (
|
||||
<div className="dir-table-body" ref={bodyRef}>
|
||||
{search !== "" && (
|
||||
<div className="dir-table-body-search-display" ref={warningBoxRef}>
|
||||
<div className="flex rounded-[3px] py-1 px-2 bg-warning" ref={warningBoxRef}>
|
||||
<span>Searching for "{search}"</span>
|
||||
<div className="search-display-close-button dir-table-button" onClick={() => setSearch("")}>
|
||||
<div
|
||||
className="ml-auto bg-transparent flex justify-center items-center flex-col p-0.5 rounded-md hover:bg-hoverbg focus:bg-hoverbg focus-within:bg-hoverbg cursor-pointer"
|
||||
onClick={() => setSearch("")}
|
||||
>
|
||||
<i className="fa-solid fa-xmark" />
|
||||
<input type="text" value={search} onChange={() => {}} />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={() => {}}
|
||||
className="w-0 h-0 opacity-0 p-0 border-none pointer-events-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -16,8 +16,6 @@ function CodeEditPreview({ model }: SpecializedViewProps) {
|
|||
const fileContent = useAtomValue(model.fileContent);
|
||||
const setNewFileContent = useSetAtom(model.newFileContent);
|
||||
const fileInfo = useAtomValue(model.statFile);
|
||||
const fileName = fileInfo?.name;
|
||||
const blockMeta = useAtomValue(model.blockAtom)?.meta;
|
||||
|
||||
function codeEditKeyDownHandler(e: WaveKeyboardEvent): boolean {
|
||||
if (checkKeyPressed(e, "Cmd:e")) {
|
||||
|
|
@ -67,9 +65,7 @@ function CodeEditPreview({ model }: SpecializedViewProps) {
|
|||
<CodeEditor
|
||||
blockId={model.blockId}
|
||||
text={fileContent}
|
||||
filename={fileName}
|
||||
fileinfo={fileInfo}
|
||||
meta={blockMeta}
|
||||
readonly={fileInfo.readonly}
|
||||
onChange={(text) => setNewFileContent(text)}
|
||||
onMount={onMount}
|
||||
/>
|
||||
|
|
|
|||
84
frontend/app/view/preview/preview-error-overlay.tsx
Normal file
84
frontend/app/view/preview/preview-error-overlay.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
// Copyright 2025, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { Button } from "@/app/element/button";
|
||||
import { CopyButton } from "@/app/element/copybutton";
|
||||
import clsx from "clsx";
|
||||
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
|
||||
import { memo, useCallback } from "react";
|
||||
|
||||
export const ErrorOverlay = memo(({ errorMsg, resetOverlay }: { errorMsg: ErrorMsg; resetOverlay: () => void }) => {
|
||||
const showDismiss = errorMsg.showDismiss ?? true;
|
||||
const buttonClassName = "outlined grey font-size-11 vertical-padding-3 horizontal-padding-7";
|
||||
|
||||
let iconClass = "fa-solid fa-circle-exclamation text-error text-base";
|
||||
if (errorMsg.level == "warning") {
|
||||
iconClass = "fa-solid fa-triangle-exclamation text-warning text-base";
|
||||
}
|
||||
|
||||
const handleCopyToClipboard = useCallback(async () => {
|
||||
await navigator.clipboard.writeText(errorMsg.text);
|
||||
}, [errorMsg.text]);
|
||||
|
||||
return (
|
||||
<div className="absolute top-[0] left-1.5 right-1.5 z-[var(--zindex-block-mask-inner)] overflow-hidden bg-[var(--conn-status-overlay-bg-color)] backdrop-blur-[50px] rounded-md shadow-lg">
|
||||
<div className="flex flex-row justify-between p-2.5 pl-3 font-normal text-sm leading-normal font-sans text-secondary">
|
||||
<div
|
||||
className={clsx("flex flex-row items-center gap-3 grow min-w-0 shrink", {
|
||||
"items-start": true,
|
||||
})}
|
||||
>
|
||||
<i className={iconClass}></i>
|
||||
|
||||
<div className="flex flex-col items-start gap-1 grow w-full shrink min-w-0">
|
||||
<div className="max-w-full text-xs font-semibold leading-4 tracking-[0.11px] text-white overflow-hidden">
|
||||
{errorMsg.status}
|
||||
</div>
|
||||
|
||||
<OverlayScrollbarsComponent
|
||||
className="group text-xs font-normal leading-[15px] tracking-[0.11px] text-wrap max-h-20 rounded-lg py-1.5 pl-0 relative w-full"
|
||||
options={{ scrollbars: { autoHide: "leave" } }}
|
||||
>
|
||||
<CopyButton
|
||||
className="invisible group-hover:visible flex absolute top-0 right-1 rounded backdrop-blur-lg p-1 items-center justify-end gap-1"
|
||||
onClick={handleCopyToClipboard}
|
||||
title="Copy"
|
||||
/>
|
||||
<div>{errorMsg.text}</div>
|
||||
</OverlayScrollbarsComponent>
|
||||
{!!errorMsg.buttons && (
|
||||
<div className="flex flex-row gap-2">
|
||||
{errorMsg.buttons?.map((buttonDef) => (
|
||||
<Button
|
||||
className={buttonClassName}
|
||||
onClick={() => {
|
||||
buttonDef.onClick();
|
||||
resetOverlay();
|
||||
}}
|
||||
key={crypto.randomUUID()}
|
||||
>
|
||||
{buttonDef.text}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showDismiss && (
|
||||
<div className="flex items-start">
|
||||
<Button
|
||||
className={clsx(buttonClassName, "fa-xmark fa-solid")}
|
||||
onClick={() => {
|
||||
if (errorMsg.closeAction) {
|
||||
errorMsg.closeAction();
|
||||
}
|
||||
resetOverlay();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -19,13 +19,14 @@ function MarkdownPreview({ model }: SpecializedViewProps) {
|
|||
};
|
||||
}, [connName, fileInfo.dir]);
|
||||
return (
|
||||
<div className="view-preview view-preview-markdown">
|
||||
<div className="flex flex-row h-full overflow-auto items-start justify-start">
|
||||
<Markdown
|
||||
textAtom={model.fileContent}
|
||||
showTocAtom={model.markdownShowToc}
|
||||
resolveOpts={resolveOpts}
|
||||
fontSizeOverride={fontSizeOverride}
|
||||
fixedFontSizeOverride={fixedFontSizeOverride}
|
||||
contentClassName="pt-[5px] pr-[15px] pb-[10px] pl-[15px]"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,14 +13,14 @@ function ImageZoomControls() {
|
|||
const { zoomIn, zoomOut, resetTransform } = useControls();
|
||||
|
||||
return (
|
||||
<div className="tools">
|
||||
<Button onClick={() => zoomIn()} title="Zoom In">
|
||||
<div className="absolute flex flex-row z-[2] top-0 right-0 p-[5px] gap-1">
|
||||
<Button onClick={() => zoomIn()} title="Zoom In" className="py-1 px-[5px]">
|
||||
<i className="fa-sharp fa-plus" />
|
||||
</Button>
|
||||
<Button onClick={() => zoomOut()} title="Zoom Out">
|
||||
<Button onClick={() => zoomOut()} title="Zoom Out" className="py-1 px-[5px]">
|
||||
<i className="fa-sharp fa-minus" />
|
||||
</Button>
|
||||
<Button onClick={() => resetTransform()} title="Reset Zoom">
|
||||
<Button onClick={() => resetTransform()} title="Reset Zoom" className="py-1 px-[5px]">
|
||||
<i className="fa-sharp fa-rotate-left" />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -29,13 +29,13 @@ function ImageZoomControls() {
|
|||
|
||||
function StreamingImagePreview({ url }: { url: string }) {
|
||||
return (
|
||||
<div className="view-preview view-preview-image">
|
||||
<div className="flex flex-row h-full overflow-hidden items-center justify-center relative">
|
||||
<TransformWrapper initialScale={1} centerOnInit pinch={{ step: 10 }}>
|
||||
{({ zoomIn, zoomOut, resetTransform, ...rest }) => (
|
||||
<>
|
||||
<ImageZoomControls />
|
||||
<TransformComponent>
|
||||
<img src={url} />
|
||||
<TransformComponent wrapperClass="!h-full !w-full">
|
||||
<img src={url} className="z-[1]" />
|
||||
</TransformComponent>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -57,15 +57,15 @@ function StreamingPreview({ model }: SpecializedViewProps) {
|
|||
const streamingUrl = `${getWebServerEndpoint()}/wave/stream-file?${usp.toString()}`;
|
||||
if (fileInfo.mimetype === "application/pdf") {
|
||||
return (
|
||||
<div className="view-preview view-preview-pdf">
|
||||
<div className="flex flex-row h-full overflow-hidden items-center justify-center p-[5px]">
|
||||
<iframe src={streamingUrl} width="100%" height="100%" name="pdfview" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (fileInfo.mimetype.startsWith("video/")) {
|
||||
return (
|
||||
<div className="view-preview view-preview-video">
|
||||
<video controls>
|
||||
<div className="flex flex-row h-full overflow-hidden items-center justify-center">
|
||||
<video controls className="w-full h-full p-[10px] object-contain">
|
||||
<source src={streamingUrl} />
|
||||
</video>
|
||||
</div>
|
||||
|
|
@ -73,8 +73,8 @@ function StreamingPreview({ model }: SpecializedViewProps) {
|
|||
}
|
||||
if (fileInfo.mimetype.startsWith("audio/")) {
|
||||
return (
|
||||
<div className="view-preview view-preview-audio">
|
||||
<audio controls>
|
||||
<div className="flex flex-row h-full overflow-hidden items-center justify-center">
|
||||
<audio controls className="w-full h-full p-[10px] object-contain">
|
||||
<source src={streamingUrl} />
|
||||
</audio>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,106 +0,0 @@
|
|||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
.view-preview {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&.view-preview-markdown {
|
||||
align-items: start;
|
||||
justify-content: start;
|
||||
overflow: auto;
|
||||
|
||||
.markdown .content {
|
||||
padding: 5px 15px 10px 15px;
|
||||
}
|
||||
}
|
||||
|
||||
&.view-preview-text {
|
||||
align-items: start;
|
||||
justify-content: start;
|
||||
overflow: auto;
|
||||
|
||||
pre {
|
||||
font: var(--fixed-font);
|
||||
}
|
||||
}
|
||||
|
||||
&.view-preview-video,
|
||||
&.view-preview-audio {
|
||||
video,
|
||||
audio {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 10px;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
&.view-preview-image {
|
||||
position: relative;
|
||||
.tools {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
z-index: 2;
|
||||
top: 0;
|
||||
right: 0;
|
||||
padding: 5px;
|
||||
gap: 4px;
|
||||
button {
|
||||
padding: 4px 5px;
|
||||
}
|
||||
}
|
||||
img {
|
||||
z-index: 1;
|
||||
}
|
||||
.react-transform-wrapper {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
&.view-preview-pdf {
|
||||
padding: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.full-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.view-nav {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
padding: 0.2rem 0 0.2rem 0;
|
||||
|
||||
.view-nav-item {
|
||||
border-radius: 3px;
|
||||
padding: 0.2rem 0;
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--highlight-bg-color);
|
||||
}
|
||||
}
|
||||
|
||||
&.current-file {
|
||||
background-color: transparent;
|
||||
cursor: default;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.full-preview-content {
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
|
@ -1,25 +1,21 @@
|
|||
// Copyright 2025, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { Button } from "@/app/element/button";
|
||||
import { CopyButton } from "@/app/element/copybutton";
|
||||
import { CenteredDiv } from "@/app/element/quickelems";
|
||||
import { RpcApi } from "@/app/store/wshclientapi";
|
||||
import { TabRpcClient } from "@/app/store/wshrpcutil";
|
||||
import { BlockHeaderSuggestionControl } from "@/app/suggestion/suggestion";
|
||||
import { globalStore } from "@/store/global";
|
||||
import { isBlank, makeConnRoute } from "@/util/util";
|
||||
import clsx from "clsx";
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
|
||||
import { memo, useCallback, useEffect } from "react";
|
||||
import { memo, useEffect } from "react";
|
||||
import { CSVView } from "./csvview";
|
||||
import { DirectoryPreview } from "./preview-directory";
|
||||
import { CodeEditPreview } from "./preview-edit";
|
||||
import { ErrorOverlay } from "./preview-error-overlay";
|
||||
import { MarkdownPreview } from "./preview-markdown";
|
||||
import type { PreviewModel } from "./preview-model";
|
||||
import { StreamingPreview } from "./preview-streaming";
|
||||
import "./preview.scss";
|
||||
|
||||
export type SpecializedViewProps = {
|
||||
model: PreviewModel;
|
||||
|
|
@ -149,9 +145,9 @@ function PreviewView({
|
|||
|
||||
return (
|
||||
<>
|
||||
<div key="fullpreview" className="full-preview scrollbar-hide-until-hover">
|
||||
<div key="fullpreview" className="flex flex-col w-full overflow-hidden scrollbar-hide-until-hover">
|
||||
{errorMsg && <ErrorOverlay errorMsg={errorMsg} resetOverlay={() => setErrorMsg(null)} />}
|
||||
<div ref={contentRef} className="full-preview-content">
|
||||
<div ref={contentRef} className="flex-grow overflow-hidden">
|
||||
<SpecializedView parentRef={contentRef} model={model} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -168,80 +164,4 @@ function PreviewView({
|
|||
);
|
||||
}
|
||||
|
||||
const ErrorOverlay = memo(({ errorMsg, resetOverlay }: { errorMsg: ErrorMsg; resetOverlay: () => void }) => {
|
||||
const showDismiss = errorMsg.showDismiss ?? true;
|
||||
const buttonClassName = "outlined grey font-size-11 vertical-padding-3 horizontal-padding-7";
|
||||
|
||||
let iconClass = "fa-solid fa-circle-exclamation text-[var(--error-color)] text-base";
|
||||
if (errorMsg.level == "warning") {
|
||||
iconClass = "fa-solid fa-triangle-exclamation text-[var(--warning-color)] text-base";
|
||||
}
|
||||
|
||||
const handleCopyToClipboard = useCallback(async () => {
|
||||
await navigator.clipboard.writeText(errorMsg.text);
|
||||
}, [errorMsg.text]);
|
||||
|
||||
return (
|
||||
<div className="absolute top-[0] left-1.5 right-1.5 z-[var(--zindex-block-mask-inner)] overflow-hidden bg-[var(--conn-status-overlay-bg-color)] backdrop-blur-[50px] rounded-md shadow-lg">
|
||||
<div className="flex flex-row justify-between p-2.5 pl-3 font-[var(--base-font)] text-[var(--secondary-text-color)]">
|
||||
<div
|
||||
className={clsx("flex flex-row items-center gap-3 grow min-w-0 shrink", {
|
||||
"items-start": true,
|
||||
})}
|
||||
>
|
||||
<i className={iconClass}></i>
|
||||
|
||||
<div className="flex flex-col items-start gap-1 grow w-full shrink min-w-0">
|
||||
<div className="max-w-full text-xs font-semibold leading-4 tracking-[0.11px] text-white overflow-hidden">
|
||||
{errorMsg.status}
|
||||
</div>
|
||||
|
||||
<OverlayScrollbarsComponent
|
||||
className="group text-xs font-normal leading-[15px] tracking-[0.11px] text-wrap max-h-20 rounded-lg py-1.5 pl-0 relative w-full"
|
||||
options={{ scrollbars: { autoHide: "leave" } }}
|
||||
>
|
||||
<CopyButton
|
||||
className="invisible group-hover:visible flex absolute top-0 right-1 rounded backdrop-blur-lg p-1 items-center justify-end gap-1"
|
||||
onClick={handleCopyToClipboard}
|
||||
title="Copy"
|
||||
/>
|
||||
<div>{errorMsg.text}</div>
|
||||
</OverlayScrollbarsComponent>
|
||||
{!!errorMsg.buttons && (
|
||||
<div className="flex flex-row gap-2">
|
||||
{errorMsg.buttons?.map((buttonDef) => (
|
||||
<Button
|
||||
className={buttonClassName}
|
||||
onClick={() => {
|
||||
buttonDef.onClick();
|
||||
resetOverlay();
|
||||
}}
|
||||
key={crypto.randomUUID()}
|
||||
>
|
||||
{buttonDef.text}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showDismiss && (
|
||||
<div className="flex items-start">
|
||||
<Button
|
||||
className={clsx(buttonClassName, "fa-xmark fa-solid")}
|
||||
onClick={() => {
|
||||
if (errorMsg.closeAction) {
|
||||
errorMsg.closeAction();
|
||||
}
|
||||
resetOverlay();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export { PreviewView };
|
||||
|
|
|
|||
Loading…
Reference in a new issue