diff --git a/frontend/app/block/block.tsx b/frontend/app/block/block.tsx
index 4d849de9d1..1aa507f4f8 100644
--- a/frontend/app/block/block.tsx
+++ b/frontend/app/block/block.tsx
@@ -10,7 +10,7 @@ import {
SubBlockProps,
} from "@/app/block/blocktypes";
import { LauncherViewModel } from "@/app/view/launcher/launcher";
-import { PreviewModel } from "@/app/view/preview/preview";
+import { PreviewModel } from "@/app/view/preview/preview-model";
import { SysinfoViewModel } from "@/app/view/sysinfo/sysinfo";
import { VDomModel } from "@/app/view/vdom/vdom-model";
import { ErrorBoundary } from "@/element/errorboundary";
diff --git a/frontend/app/view/preview/directorypreview.tsx b/frontend/app/view/preview/preview-directory.tsx
similarity index 99%
rename from frontend/app/view/preview/directorypreview.tsx
rename to frontend/app/view/preview/preview-directory.tsx
index be8201546e..d671d7137c 100644
--- a/frontend/app/view/preview/directorypreview.tsx
+++ b/frontend/app/view/preview/preview-directory.tsx
@@ -7,7 +7,6 @@ import { ContextMenuModel } from "@/app/store/contextmenu";
import { atoms, getApi, globalStore } from "@/app/store/global";
import { RpcApi } from "@/app/store/wshclientapi";
import { TabRpcClient } from "@/app/store/wshrpcutil";
-import { type PreviewModel } from "@/app/view/preview/preview";
import { checkKeyPressed, isCharacterKeyEvent } from "@/util/keyutil";
import { PLATFORM, PlatformMacOS } from "@/util/platformutil";
import { addOpenMenuItems } from "@/util/previewutil";
@@ -34,6 +33,7 @@ import { useDrag, useDrop } from "react-dnd";
import { quote as shellQuote } from "shell-quote";
import { debounce } from "throttle-debounce";
import "./directorypreview.scss";
+import { type PreviewModel } from "./preview-model";
const PageJumpSize = 20;
@@ -527,12 +527,12 @@ function TableBody({
if (focusIndex === null || !bodyRef.current || !osRef) {
return;
}
-
+
const rowElement = bodyRef.current.querySelector(`[data-rowindex="${focusIndex}"]`) as HTMLDivElement;
if (!rowElement) {
return;
}
-
+
const viewport = osRef.osInstance().elements().viewport;
const viewportHeight = viewport.offsetHeight;
const rowRect = rowElement.getBoundingClientRect();
@@ -540,7 +540,7 @@ function TableBody({
const viewportScrollTop = viewport.scrollTop;
const rowTopRelativeToViewport = rowRect.top - parentRect.top + viewport.scrollTop;
const rowBottomRelativeToViewport = rowRect.bottom - parentRect.top + viewport.scrollTop;
-
+
if (rowTopRelativeToViewport - 30 < viewportScrollTop) {
// Row is above the visible area
let topVal = rowTopRelativeToViewport - 30;
@@ -675,7 +675,7 @@ function TableBody({
setSearch={setSearch}
idx={idx}
handleFileContextMenu={handleFileContextMenu}
- key={idx}
+ key={"top-" + idx}
/>
))}
{table.getCenterRows().map((row, idx) => (
@@ -687,7 +687,7 @@ function TableBody({
setSearch={setSearch}
idx={idx + table.getTopRows().length}
handleFileContextMenu={handleFileContextMenu}
- key={idx}
+ key={"center" + idx}
/>
))}
@@ -732,9 +732,12 @@ const TableRow = React.forwardRef(function ({
[dragItem]
);
- const dragRef = useCallback((node: HTMLDivElement | null) => {
- drag(node);
- }, [drag]);
+ const dragRef = useCallback(
+ (node: HTMLDivElement | null) => {
+ drag(node);
+ },
+ [drag]
+ );
return (
model.setEditMode(false));
+ return true;
+ }
+ if (checkKeyPressed(e, "Cmd:s") || checkKeyPressed(e, "Ctrl:s")) {
+ fireAndForget(model.handleFileSave.bind(model));
+ return true;
+ }
+ if (checkKeyPressed(e, "Cmd:r")) {
+ fireAndForget(model.handleFileRevert.bind(model));
+ return true;
+ }
+ return false;
+ }
+
+ useEffect(() => {
+ model.codeEditKeyDownHandler = codeEditKeyDownHandler;
+ return () => {
+ model.codeEditKeyDownHandler = null;
+ model.monacoRef.current = null;
+ };
+ }, []);
+
+ function onMount(editor: MonacoTypes.editor.IStandaloneCodeEditor, monaco: Monaco): () => void {
+ model.monacoRef.current = editor;
+
+ editor.onKeyDown((e: MonacoTypes.IKeyboardEvent) => {
+ const waveEvent = adaptFromReactOrNativeKeyEvent(e.browserEvent);
+ const handled = tryReinjectKey(waveEvent);
+ if (handled) {
+ e.stopPropagation();
+ e.preventDefault();
+ }
+ });
+
+ const isFocused = globalStore.get(model.nodeModel.isFocused);
+ if (isFocused) {
+ editor.focus();
+ }
+
+ return null;
+ }
+
+ return (
+
setNewFileContent(text)}
+ onMount={onMount}
+ />
+ );
+}
+
+export { CodeEditPreview };
diff --git a/frontend/app/view/preview/preview-markdown.tsx b/frontend/app/view/preview/preview-markdown.tsx
new file mode 100644
index 0000000000..87e8a336df
--- /dev/null
+++ b/frontend/app/view/preview/preview-markdown.tsx
@@ -0,0 +1,34 @@
+// Copyright 2025, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import { Markdown } from "@/element/markdown";
+import { getOverrideConfigAtom } from "@/store/global";
+import { useAtomValue } from "jotai";
+import { useMemo } from "react";
+import type { SpecializedViewProps } from "./preview";
+
+function MarkdownPreview({ model }: SpecializedViewProps) {
+ const connName = useAtomValue(model.connection);
+ const fileInfo = useAtomValue(model.statFile);
+ const fontSizeOverride = useAtomValue(getOverrideConfigAtom(model.blockId, "markdown:fontsize"));
+ const fixedFontSizeOverride = useAtomValue(getOverrideConfigAtom(model.blockId, "markdown:fixedfontsize"));
+ const resolveOpts: MarkdownResolveOpts = useMemo(() => {
+ return {
+ connName: connName,
+ baseDir: fileInfo.dir,
+ };
+ }, [connName, fileInfo.dir]);
+ return (
+
+
+
+ );
+}
+
+export { MarkdownPreview };
diff --git a/frontend/app/view/preview/preview-model.tsx b/frontend/app/view/preview/preview-model.tsx
new file mode 100644
index 0000000000..aa1491df7b
--- /dev/null
+++ b/frontend/app/view/preview/preview-model.tsx
@@ -0,0 +1,842 @@
+// Copyright 2025, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import { BlockNodeModel } from "@/app/block/blocktypes";
+import { ContextMenuModel } from "@/app/store/contextmenu";
+import { RpcApi } from "@/app/store/wshclientapi";
+import { TabRpcClient } from "@/app/store/wshrpcutil";
+import { getConnStatusAtom, getOverrideConfigAtom, getSettingsKeyAtom, globalStore, refocusNode } from "@/store/global";
+import * as services from "@/store/services";
+import * as WOS from "@/store/wos";
+import { goHistory, goHistoryBack, goHistoryForward } from "@/util/historyutil";
+import { checkKeyPressed } from "@/util/keyutil";
+import { addOpenMenuItems } from "@/util/previewutil";
+import { base64ToString, fireAndForget, isBlank, jotaiLoadableValue, stringToBase64 } from "@/util/util";
+import { formatRemoteUri } from "@/util/waveutil";
+import clsx from "clsx";
+import { Atom, atom, Getter, PrimitiveAtom, WritableAtom } from "jotai";
+import { loadable } from "jotai/utils";
+import type * as MonacoTypes from "monaco-editor/esm/vs/editor/editor.api";
+import { createRef } from "react";
+import { PreviewView } from "./preview";
+
+// TODO drive this using config
+const BOOKMARKS: { label: string; path: string }[] = [
+ { label: "Home", path: "~" },
+ { label: "Desktop", path: "~/Desktop" },
+ { label: "Downloads", path: "~/Downloads" },
+ { label: "Documents", path: "~/Documents" },
+ { label: "Root", path: "/" },
+];
+
+const MaxFileSize = 1024 * 1024 * 10; // 10MB
+const MaxCSVSize = 1024 * 1024 * 1; // 1MB
+
+const textApplicationMimetypes = [
+ "application/sql",
+ "application/x-php",
+ "application/x-pem-file",
+ "application/x-httpd-php",
+ "application/liquid",
+ "application/graphql",
+ "application/javascript",
+ "application/typescript",
+ "application/x-javascript",
+ "application/x-typescript",
+ "application/dart",
+ "application/vnd.dart",
+ "application/x-ruby",
+ "application/sql",
+ "application/wasm",
+ "application/x-latex",
+ "application/x-sh",
+ "application/x-python",
+ "application/x-awk",
+];
+
+function isTextFile(mimeType: string): boolean {
+ if (mimeType == null) {
+ return false;
+ }
+ return (
+ mimeType.startsWith("text/") ||
+ textApplicationMimetypes.includes(mimeType) ||
+ (mimeType.startsWith("application/") &&
+ (mimeType.includes("json") || mimeType.includes("yaml") || mimeType.includes("toml"))) ||
+ mimeType.includes("xml")
+ );
+}
+
+function isStreamingType(mimeType: string): boolean {
+ if (mimeType == null) {
+ return false;
+ }
+ return (
+ mimeType.startsWith("application/pdf") ||
+ mimeType.startsWith("video/") ||
+ mimeType.startsWith("audio/") ||
+ mimeType.startsWith("image/")
+ );
+}
+
+function iconForFile(mimeType: string): string {
+ if (mimeType == null) {
+ mimeType = "unknown";
+ }
+ if (mimeType == "application/pdf") {
+ return "file-pdf";
+ } else if (mimeType.startsWith("image/")) {
+ return "image";
+ } else if (mimeType.startsWith("video/")) {
+ return "film";
+ } else if (mimeType.startsWith("audio/")) {
+ return "headphones";
+ } else if (mimeType.startsWith("text/markdown")) {
+ return "file-lines";
+ } else if (mimeType == "text/csv") {
+ return "file-csv";
+ } else if (
+ mimeType.startsWith("text/") ||
+ mimeType == "application/sql" ||
+ (mimeType.startsWith("application/") &&
+ (mimeType.includes("json") || mimeType.includes("yaml") || mimeType.includes("toml")))
+ ) {
+ return "file-code";
+ } else {
+ return "file";
+ }
+}
+
+export class PreviewModel implements ViewModel {
+ viewType: string;
+ blockId: string;
+ nodeModel: BlockNodeModel;
+ noPadding?: Atom;
+ blockAtom: Atom;
+ viewIcon: Atom;
+ viewName: Atom;
+ viewText: Atom;
+ preIconButton: Atom;
+ endIconButtons: Atom;
+ previewTextRef: React.RefObject;
+ editMode: Atom;
+ canPreview: PrimitiveAtom;
+ specializedView: Atom>;
+ loadableSpecializedView: Atom>;
+ manageConnection: Atom;
+ connStatus: Atom;
+ filterOutNowsh?: Atom;
+
+ metaFilePath: Atom;
+ statFilePath: Atom>;
+ loadableFileInfo: Atom>;
+ connection: Atom>;
+ connectionImmediate: Atom;
+ statFile: Atom>;
+ fullFile: Atom>;
+ fileMimeType: Atom>;
+ fileMimeTypeLoadable: Atom>;
+ fileContentSaved: PrimitiveAtom;
+ fileContent: WritableAtom, [string], void>;
+ newFileContent: PrimitiveAtom;
+ connectionError: PrimitiveAtom;
+ errorMsgAtom: PrimitiveAtom;
+
+ openFileModal: PrimitiveAtom;
+ openFileModalDelay: PrimitiveAtom;
+ openFileError: PrimitiveAtom;
+ openFileModalGiveFocusRef: React.MutableRefObject<() => boolean>;
+
+ markdownShowToc: PrimitiveAtom;
+
+ monacoRef: React.MutableRefObject;
+
+ showHiddenFiles: PrimitiveAtom;
+ refreshVersion: PrimitiveAtom;
+ refreshCallback: () => void;
+ directoryKeyDownHandler: (waveEvent: WaveKeyboardEvent) => boolean;
+ codeEditKeyDownHandler: (waveEvent: WaveKeyboardEvent) => boolean;
+
+ showS3 = atom(true);
+
+ constructor(blockId: string, nodeModel: BlockNodeModel) {
+ this.viewType = "preview";
+ this.blockId = blockId;
+ this.nodeModel = nodeModel;
+ let showHiddenFiles = globalStore.get(getSettingsKeyAtom("preview:showhiddenfiles")) ?? true;
+ this.showHiddenFiles = atom(showHiddenFiles);
+ this.refreshVersion = atom(0);
+ this.previewTextRef = createRef();
+ this.openFileModal = atom(false);
+ this.openFileModalDelay = atom(false);
+ this.openFileError = atom(null) as PrimitiveAtom;
+ this.openFileModalGiveFocusRef = createRef();
+ this.manageConnection = atom(true);
+ this.blockAtom = WOS.getWaveObjectAtom(`block:${blockId}`);
+ this.markdownShowToc = atom(false);
+ this.filterOutNowsh = atom(true);
+ this.monacoRef = createRef();
+ this.connectionError = atom("");
+ this.errorMsgAtom = atom(null) as PrimitiveAtom;
+ this.viewIcon = atom((get) => {
+ const blockData = get(this.blockAtom);
+ if (blockData?.meta?.icon) {
+ return blockData.meta.icon;
+ }
+ const connStatus = get(this.connStatus);
+ if (connStatus?.status != "connected") {
+ return null;
+ }
+ const mimeTypeLoadable = get(this.fileMimeTypeLoadable);
+ const mimeType = jotaiLoadableValue(mimeTypeLoadable, "");
+ if (mimeType == "directory") {
+ return {
+ elemtype: "iconbutton",
+ icon: "folder-open",
+ longClick: (e: React.MouseEvent) => {
+ const menuItems: ContextMenuItem[] = BOOKMARKS.map((bookmark) => ({
+ label: `Go to ${bookmark.label} (${bookmark.path})`,
+ click: () => this.goHistory(bookmark.path),
+ }));
+ ContextMenuModel.showContextMenu(menuItems, e);
+ },
+ };
+ }
+ return iconForFile(mimeType);
+ });
+ this.editMode = atom((get) => {
+ const blockData = get(this.blockAtom);
+ return blockData?.meta?.edit ?? false;
+ });
+ this.viewName = atom("Preview");
+ this.viewText = atom((get) => {
+ let headerPath = get(this.metaFilePath);
+ const connStatus = get(this.connStatus);
+ if (connStatus?.status != "connected") {
+ return [
+ {
+ elemtype: "text",
+ text: headerPath,
+ className: "preview-filename",
+ },
+ ];
+ }
+ const loadableSV = get(this.loadableSpecializedView);
+ const isCeView = loadableSV.state == "hasData" && loadableSV.data.specializedView == "codeedit";
+ const loadableFileInfo = get(this.loadableFileInfo);
+ if (loadableFileInfo.state == "hasData") {
+ headerPath = loadableFileInfo.data?.path;
+ if (headerPath == "~") {
+ headerPath = `~ (${loadableFileInfo.data?.dir + "/" + loadableFileInfo.data?.name})`;
+ }
+ }
+ if (!isBlank(headerPath) && headerPath != "/" && headerPath.endsWith("/")) {
+ headerPath = headerPath.slice(0, -1);
+ }
+ const viewTextChildren: HeaderElem[] = [
+ {
+ elemtype: "text",
+ text: headerPath,
+ ref: this.previewTextRef,
+ className: "preview-filename",
+ onClick: () => this.toggleOpenFileModal(),
+ },
+ ];
+ let saveClassName = "grey";
+ if (get(this.newFileContent) !== null) {
+ saveClassName = "green";
+ }
+ if (isCeView) {
+ const fileInfo = globalStore.get(this.loadableFileInfo);
+ if (fileInfo.state != "hasData") {
+ viewTextChildren.push({
+ elemtype: "textbutton",
+ text: "Loading ...",
+ className: clsx(
+ `grey warning border-radius-4 vertical-padding-2 horizontal-padding-10 font-size-11 font-weight-500`
+ ),
+ onClick: () => {},
+ });
+ } else if (fileInfo.data.readonly) {
+ viewTextChildren.push({
+ elemtype: "textbutton",
+ text: "Read Only",
+ className: clsx(
+ `yellow warning border-radius-4 vertical-padding-2 horizontal-padding-10 font-size-11 font-weight-500`
+ ),
+ onClick: () => {},
+ });
+ } else {
+ viewTextChildren.push({
+ elemtype: "textbutton",
+ text: "Save",
+ className: clsx(
+ `${saveClassName} warning border-radius-4 vertical-padding-2 horizontal-padding-10 font-size-11 font-weight-500`
+ ),
+ onClick: () => fireAndForget(this.handleFileSave.bind(this)),
+ });
+ }
+ if (get(this.canPreview)) {
+ viewTextChildren.push({
+ elemtype: "textbutton",
+ text: "Preview",
+ className:
+ "grey border-radius-4 vertical-padding-2 horizontal-padding-10 font-size-11 font-weight-500",
+ onClick: () => fireAndForget(() => this.setEditMode(false)),
+ });
+ }
+ } else if (get(this.canPreview)) {
+ viewTextChildren.push({
+ elemtype: "textbutton",
+ text: "Edit",
+ className:
+ "grey border-radius-4 vertical-padding-2 horizontal-padding-10 font-size-11 font-weight-500",
+ onClick: () => fireAndForget(() => this.setEditMode(true)),
+ });
+ }
+ return [
+ {
+ elemtype: "div",
+ children: viewTextChildren,
+ },
+ ] as HeaderElem[];
+ });
+ this.preIconButton = atom((get) => {
+ const connStatus = get(this.connStatus);
+ if (connStatus?.status != "connected") {
+ return null;
+ }
+ const mimeType = jotaiLoadableValue(get(this.fileMimeTypeLoadable), "");
+ const metaPath = get(this.metaFilePath);
+ if (mimeType == "directory" && metaPath == "/") {
+ return null;
+ }
+ return {
+ elemtype: "iconbutton",
+ icon: "chevron-left",
+ click: this.goParentDirectory.bind(this),
+ };
+ });
+ this.endIconButtons = atom((get) => {
+ const connStatus = get(this.connStatus);
+ if (connStatus?.status != "connected") {
+ return null;
+ }
+ const mimeType = jotaiLoadableValue(get(this.fileMimeTypeLoadable), "");
+ const loadableSV = get(this.loadableSpecializedView);
+ const isCeView = loadableSV.state == "hasData" && loadableSV.data.specializedView == "codeedit";
+ if (mimeType == "directory") {
+ const showHiddenFiles = get(this.showHiddenFiles);
+ return [
+ {
+ elemtype: "iconbutton",
+ icon: showHiddenFiles ? "eye" : "eye-slash",
+ click: () => {
+ globalStore.set(this.showHiddenFiles, (prev) => !prev);
+ },
+ },
+ {
+ elemtype: "iconbutton",
+ icon: "arrows-rotate",
+ click: () => this.refreshCallback?.(),
+ },
+ ] as IconButtonDecl[];
+ } else if (!isCeView && mimeType?.startsWith("text/markdown")) {
+ return [
+ {
+ elemtype: "iconbutton",
+ icon: "book",
+ title: "Table of Contents",
+ click: () => this.markdownShowTocToggle(),
+ },
+ ] as IconButtonDecl[];
+ }
+ return null;
+ });
+ this.metaFilePath = atom((get) => {
+ const file = get(this.blockAtom)?.meta?.file;
+ if (isBlank(file)) {
+ return "~";
+ }
+ return file;
+ });
+ this.statFilePath = atom>(async (get) => {
+ const fileInfo = await get(this.statFile);
+ return fileInfo?.path;
+ });
+ this.connection = atom>(async (get) => {
+ const connName = get(this.blockAtom)?.meta?.connection;
+ try {
+ await RpcApi.ConnEnsureCommand(TabRpcClient, { connname: connName }, { timeout: 60000 });
+ globalStore.set(this.connectionError, "");
+ } catch (e) {
+ globalStore.set(this.connectionError, e as string);
+ }
+ return connName;
+ });
+ this.connectionImmediate = atom((get) => {
+ return get(this.blockAtom)?.meta?.connection;
+ });
+ this.statFile = atom>(async (get) => {
+ const fileName = get(this.metaFilePath);
+ const path = await this.formatRemoteUri(fileName, get);
+ if (fileName == null) {
+ return null;
+ }
+ try {
+ const statFile = await RpcApi.FileInfoCommand(TabRpcClient, {
+ info: {
+ path,
+ },
+ });
+ return statFile;
+ } catch (e) {
+ const errorStatus: ErrorMsg = {
+ status: "File Read Failed",
+ text: `${e}`,
+ };
+ globalStore.set(this.errorMsgAtom, errorStatus);
+ }
+ });
+ this.fileMimeType = atom>(async (get) => {
+ const fileInfo = await get(this.statFile);
+ return fileInfo?.mimetype;
+ });
+ this.fileMimeTypeLoadable = loadable(this.fileMimeType);
+ this.newFileContent = atom(null) as PrimitiveAtom;
+ this.goParentDirectory = this.goParentDirectory.bind(this);
+
+ const fullFileAtom = atom>(async (get) => {
+ const fileName = get(this.metaFilePath);
+ const path = await this.formatRemoteUri(fileName, get);
+ if (fileName == null) {
+ return null;
+ }
+ try {
+ const file = await RpcApi.FileReadCommand(TabRpcClient, {
+ info: {
+ path,
+ },
+ });
+ return file;
+ } catch (e) {
+ const errorStatus: ErrorMsg = {
+ status: "File Read Failed",
+ text: `${e}`,
+ };
+ globalStore.set(this.errorMsgAtom, errorStatus);
+ }
+ });
+
+ this.fileContentSaved = atom(null) as PrimitiveAtom;
+ const fileContentAtom = atom(
+ async (get) => {
+ const newContent = get(this.newFileContent);
+ if (newContent != null) {
+ return newContent;
+ }
+ const savedContent = get(this.fileContentSaved);
+ if (savedContent != null) {
+ return savedContent;
+ }
+ const fullFile = await get(fullFileAtom);
+ return base64ToString(fullFile?.data64);
+ },
+ (_, set, update: string) => {
+ set(this.fileContentSaved, update);
+ }
+ );
+
+ this.fullFile = fullFileAtom;
+ this.fileContent = fileContentAtom;
+
+ this.specializedView = atom>(async (get) => {
+ return this.getSpecializedView(get);
+ });
+ this.loadableSpecializedView = loadable(this.specializedView);
+ this.canPreview = atom(false);
+ this.loadableFileInfo = loadable(this.statFile);
+ this.connStatus = atom((get) => {
+ const blockData = get(this.blockAtom);
+ const connName = blockData?.meta?.connection;
+ const connAtom = getConnStatusAtom(connName);
+ return get(connAtom);
+ });
+
+ this.noPadding = atom(true);
+ }
+
+ markdownShowTocToggle() {
+ globalStore.set(this.markdownShowToc, !globalStore.get(this.markdownShowToc));
+ }
+
+ get viewComponent(): ViewComponent {
+ return PreviewView;
+ }
+
+ async getSpecializedView(getFn: Getter): Promise<{ specializedView?: string; errorStr?: string }> {
+ const mimeType = await getFn(this.fileMimeType);
+ const fileInfo = await getFn(this.statFile);
+ const fileName = fileInfo?.name;
+ const connErr = getFn(this.connectionError);
+ const editMode = getFn(this.editMode);
+ const genErr = getFn(this.errorMsgAtom);
+
+ if (!fileInfo) {
+ return { errorStr: `Load Error: ${genErr?.text}` };
+ }
+ if (connErr != "") {
+ return { errorStr: `Connection Error: ${connErr}` };
+ }
+ if (fileInfo?.notfound) {
+ return { specializedView: "codeedit" };
+ }
+ if (mimeType == null) {
+ return { errorStr: `Unable to determine mimetype for: ${fileInfo.path}` };
+ }
+ if (isStreamingType(mimeType)) {
+ return { specializedView: "streaming" };
+ }
+ if (!fileInfo) {
+ const fileNameStr = fileName ? " " + JSON.stringify(fileName) : "";
+ return { errorStr: "File Not Found" + fileNameStr };
+ }
+ if (fileInfo.size > MaxFileSize) {
+ return { errorStr: "File Too Large to Preview (10 MB Max)" };
+ }
+ if (mimeType == "text/csv" && fileInfo.size > MaxCSVSize) {
+ return { errorStr: "CSV File Too Large to Preview (1 MB Max)" };
+ }
+ if (mimeType == "directory") {
+ return { specializedView: "directory" };
+ }
+ if (mimeType == "text/csv") {
+ if (editMode) {
+ return { specializedView: "codeedit" };
+ }
+ return { specializedView: "csv" };
+ }
+ if (mimeType.startsWith("text/markdown")) {
+ if (editMode) {
+ return { specializedView: "codeedit" };
+ }
+ return { specializedView: "markdown" };
+ }
+ if (isTextFile(mimeType) || fileInfo.size == 0) {
+ return { specializedView: "codeedit" };
+ }
+ return { errorStr: `Preview (${mimeType})` };
+ }
+
+ updateOpenFileModalAndError(isOpen, errorMsg = null) {
+ globalStore.set(this.openFileModal, isOpen);
+ globalStore.set(this.openFileError, errorMsg);
+ if (isOpen) {
+ globalStore.set(this.openFileModalDelay, true);
+ } else {
+ const delayVal = globalStore.get(this.openFileModalDelay);
+ if (delayVal) {
+ setTimeout(() => {
+ globalStore.set(this.openFileModalDelay, false);
+ }, 200);
+ }
+ }
+ }
+
+ toggleOpenFileModal() {
+ const modalOpen = globalStore.get(this.openFileModal);
+ const delayVal = globalStore.get(this.openFileModalDelay);
+ if (!modalOpen && delayVal) {
+ return;
+ }
+ this.updateOpenFileModalAndError(!modalOpen);
+ }
+
+ async goHistory(newPath: string) {
+ let fileName = globalStore.get(this.metaFilePath);
+ if (fileName == null) {
+ fileName = "";
+ }
+ const blockMeta = globalStore.get(this.blockAtom)?.meta;
+ const updateMeta = goHistory("file", fileName, newPath, blockMeta);
+ if (updateMeta == null) {
+ return;
+ }
+ const blockOref = WOS.makeORef("block", this.blockId);
+ await services.ObjectService.UpdateObjectMeta(blockOref, updateMeta);
+
+ // Clear the saved file buffers
+ globalStore.set(this.fileContentSaved, null);
+ globalStore.set(this.newFileContent, null);
+ }
+
+ async goParentDirectory({ fileInfo = null }: { fileInfo?: FileInfo | null }) {
+ // optional parameter needed for recursive case
+ const defaultFileInfo = await globalStore.get(this.statFile);
+ if (fileInfo === null) {
+ fileInfo = defaultFileInfo;
+ }
+ if (fileInfo == null) {
+ this.updateOpenFileModalAndError(false);
+ return true;
+ }
+ try {
+ this.updateOpenFileModalAndError(false);
+ await this.goHistory(fileInfo.dir);
+ refocusNode(this.blockId);
+ } catch (e) {
+ globalStore.set(this.openFileError, e.message);
+ console.error("Error opening file", fileInfo.dir, e);
+ }
+ }
+
+ async goHistoryBack() {
+ const blockMeta = globalStore.get(this.blockAtom)?.meta;
+ const curPath = globalStore.get(this.metaFilePath);
+ const updateMeta = goHistoryBack("file", curPath, blockMeta, true);
+ if (updateMeta == null) {
+ return;
+ }
+ updateMeta.edit = false;
+ const blockOref = WOS.makeORef("block", this.blockId);
+ await services.ObjectService.UpdateObjectMeta(blockOref, updateMeta);
+ }
+
+ async goHistoryForward() {
+ const blockMeta = globalStore.get(this.blockAtom)?.meta;
+ const curPath = globalStore.get(this.metaFilePath);
+ const updateMeta = goHistoryForward("file", curPath, blockMeta);
+ if (updateMeta == null) {
+ return;
+ }
+ updateMeta.edit = false;
+ const blockOref = WOS.makeORef("block", this.blockId);
+ await services.ObjectService.UpdateObjectMeta(blockOref, updateMeta);
+ }
+
+ async setEditMode(edit: boolean) {
+ const blockMeta = globalStore.get(this.blockAtom)?.meta;
+ const blockOref = WOS.makeORef("block", this.blockId);
+ await services.ObjectService.UpdateObjectMeta(blockOref, { ...blockMeta, edit });
+ }
+
+ async handleFileSave() {
+ const filePath = await globalStore.get(this.statFilePath);
+ if (filePath == null) {
+ return;
+ }
+ const newFileContent = globalStore.get(this.newFileContent);
+ if (newFileContent == null) {
+ console.log("not saving file, newFileContent is null");
+ return;
+ }
+ try {
+ await RpcApi.FileWriteCommand(TabRpcClient, {
+ info: {
+ path: await this.formatRemoteUri(filePath, globalStore.get),
+ },
+ data64: stringToBase64(newFileContent),
+ });
+ globalStore.set(this.fileContent, newFileContent);
+ globalStore.set(this.newFileContent, null);
+ console.log("saved file", filePath);
+ } catch (e) {
+ const errorStatus: ErrorMsg = {
+ status: "Save Failed",
+ text: `${e}`,
+ };
+ globalStore.set(this.errorMsgAtom, errorStatus);
+ }
+ }
+
+ async handleFileRevert() {
+ const fileContent = await globalStore.get(this.fileContent);
+ this.monacoRef.current?.setValue(fileContent);
+ globalStore.set(this.newFileContent, null);
+ }
+
+ async handleOpenFile(filePath: string) {
+ const conn = globalStore.get(this.connectionImmediate);
+ if (!isBlank(conn) && conn.startsWith("aws:")) {
+ if (!isBlank(filePath) && filePath != "/" && filePath.startsWith("/")) {
+ filePath = filePath.substring(1);
+ }
+ }
+ const fileInfo = await globalStore.get(this.statFile);
+ this.updateOpenFileModalAndError(false);
+ if (fileInfo == null) {
+ return true;
+ }
+ try {
+ this.goHistory(filePath);
+ refocusNode(this.blockId);
+ } catch (e) {
+ globalStore.set(this.openFileError, e.message);
+ console.error("Error opening file", filePath, e);
+ }
+ }
+
+ isSpecializedView(sv: string): boolean {
+ const loadableSV = globalStore.get(this.loadableSpecializedView);
+ return loadableSV.state == "hasData" && loadableSV.data.specializedView == sv;
+ }
+
+ getSettingsMenuItems(): ContextMenuItem[] {
+ const defaultFontSize = globalStore.get(getSettingsKeyAtom("editor:fontsize")) ?? 12;
+ const blockData = globalStore.get(this.blockAtom);
+ const overrideFontSize = blockData?.meta?.["editor:fontsize"];
+ const menuItems: ContextMenuItem[] = [];
+ menuItems.push({
+ label: "Copy Full Path",
+ click: () =>
+ fireAndForget(async () => {
+ const filePath = await globalStore.get(this.statFilePath);
+ if (filePath == null) {
+ return;
+ }
+ const conn = await globalStore.get(this.connection);
+ if (conn) {
+ // remote path
+ await navigator.clipboard.writeText(formatRemoteUri(filePath, conn));
+ } else {
+ // local path
+ await navigator.clipboard.writeText(filePath);
+ }
+ }),
+ });
+ menuItems.push({
+ label: "Copy File Name",
+ click: () =>
+ fireAndForget(async () => {
+ const fileInfo = await globalStore.get(this.statFile);
+ if (fileInfo == null || fileInfo.name == null) {
+ return;
+ }
+ await navigator.clipboard.writeText(fileInfo.name);
+ }),
+ });
+ menuItems.push({ type: "separator" });
+ const fontSizeSubMenu: ContextMenuItem[] = [6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18].map(
+ (fontSize: number) => {
+ return {
+ label: fontSize.toString() + "px",
+ type: "checkbox",
+ checked: overrideFontSize == fontSize,
+ click: () => {
+ RpcApi.SetMetaCommand(TabRpcClient, {
+ oref: WOS.makeORef("block", this.blockId),
+ meta: { "editor:fontsize": fontSize },
+ });
+ },
+ };
+ }
+ );
+ fontSizeSubMenu.unshift({
+ label: "Default (" + defaultFontSize + "px)",
+ type: "checkbox",
+ checked: overrideFontSize == null,
+ click: () => {
+ RpcApi.SetMetaCommand(TabRpcClient, {
+ oref: WOS.makeORef("block", this.blockId),
+ meta: { "editor:fontsize": null },
+ });
+ },
+ });
+ menuItems.push({
+ label: "Editor Font Size",
+ submenu: fontSizeSubMenu,
+ });
+ const finfo = jotaiLoadableValue(globalStore.get(this.loadableFileInfo), null);
+ addOpenMenuItems(menuItems, globalStore.get(this.connectionImmediate), finfo);
+ const loadableSV = globalStore.get(this.loadableSpecializedView);
+ const wordWrapAtom = getOverrideConfigAtom(this.blockId, "editor:wordwrap");
+ const wordWrap = globalStore.get(wordWrapAtom) ?? false;
+ if (loadableSV.state == "hasData") {
+ if (loadableSV.data.specializedView == "codeedit") {
+ if (globalStore.get(this.newFileContent) != null) {
+ menuItems.push({ type: "separator" });
+ menuItems.push({
+ label: "Save File",
+ click: () => fireAndForget(this.handleFileSave.bind(this)),
+ });
+ menuItems.push({
+ label: "Revert File",
+ click: () => fireAndForget(this.handleFileRevert.bind(this)),
+ });
+ }
+ menuItems.push({ type: "separator" });
+ menuItems.push({
+ label: "Word Wrap",
+ type: "checkbox",
+ checked: wordWrap,
+ click: () =>
+ fireAndForget(async () => {
+ const blockOref = WOS.makeORef("block", this.blockId);
+ await services.ObjectService.UpdateObjectMeta(blockOref, {
+ "editor:wordwrap": !wordWrap,
+ });
+ }),
+ });
+ }
+ }
+ return menuItems;
+ }
+
+ giveFocus(): boolean {
+ const openModalOpen = globalStore.get(this.openFileModal);
+ if (openModalOpen) {
+ this.openFileModalGiveFocusRef.current?.();
+ return true;
+ }
+ if (this.monacoRef.current) {
+ this.monacoRef.current.focus();
+ return true;
+ }
+ return false;
+ }
+
+ keyDownHandler(e: WaveKeyboardEvent): boolean {
+ if (checkKeyPressed(e, "Cmd:ArrowLeft")) {
+ fireAndForget(this.goHistoryBack.bind(this));
+ return true;
+ }
+ if (checkKeyPressed(e, "Cmd:ArrowRight")) {
+ fireAndForget(this.goHistoryForward.bind(this));
+ return true;
+ }
+ if (checkKeyPressed(e, "Cmd:ArrowUp")) {
+ // handle up directory
+ fireAndForget(() => this.goParentDirectory({}));
+ return true;
+ }
+ if (checkKeyPressed(e, "Cmd:o")) {
+ this.toggleOpenFileModal();
+ return true;
+ }
+ const canPreview = globalStore.get(this.canPreview);
+ if (canPreview) {
+ if (checkKeyPressed(e, "Cmd:e")) {
+ const editMode = globalStore.get(this.editMode);
+ fireAndForget(() => this.setEditMode(!editMode));
+ return true;
+ }
+ }
+ if (this.directoryKeyDownHandler) {
+ const handled = this.directoryKeyDownHandler(e);
+ if (handled) {
+ return true;
+ }
+ }
+ if (this.codeEditKeyDownHandler) {
+ const handled = this.codeEditKeyDownHandler(e);
+ if (handled) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ async formatRemoteUri(path: string, get: Getter): Promise {
+ return formatRemoteUri(path, await get(this.connection));
+ }
+}
diff --git a/frontend/app/view/preview/preview-streaming.tsx b/frontend/app/view/preview/preview-streaming.tsx
new file mode 100644
index 0000000000..d76bef4289
--- /dev/null
+++ b/frontend/app/view/preview/preview-streaming.tsx
@@ -0,0 +1,89 @@
+// Copyright 2025, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import { Button } from "@/app/element/button";
+import { CenteredDiv } from "@/app/element/quickelems";
+import { getWebServerEndpoint } from "@/util/endpoints";
+import { formatRemoteUri } from "@/util/waveutil";
+import { useAtomValue } from "jotai";
+import { TransformComponent, TransformWrapper, useControls } from "react-zoom-pan-pinch";
+import type { SpecializedViewProps } from "./preview";
+
+function ImageZoomControls() {
+ const { zoomIn, zoomOut, resetTransform } = useControls();
+
+ return (
+
+
+
+
+
+ );
+}
+
+function StreamingImagePreview({ url }: { url: string }) {
+ return (
+
+
+ {({ zoomIn, zoomOut, resetTransform, ...rest }) => (
+ <>
+
+
+
+
+ >
+ )}
+
+
+ );
+}
+
+function StreamingPreview({ model }: SpecializedViewProps) {
+ const conn = useAtomValue(model.connection);
+ const fileInfo = useAtomValue(model.statFile);
+ const filePath = fileInfo.path;
+ const remotePath = formatRemoteUri(filePath, conn);
+ const usp = new URLSearchParams();
+ usp.set("path", remotePath);
+ if (conn != null) {
+ usp.set("connection", conn);
+ }
+ const streamingUrl = `${getWebServerEndpoint()}/wave/stream-file?${usp.toString()}`;
+ if (fileInfo.mimetype === "application/pdf") {
+ return (
+
+
+
+ );
+ }
+ if (fileInfo.mimetype.startsWith("video/")) {
+ return (
+
+
+
+ );
+ }
+ if (fileInfo.mimetype.startsWith("audio/")) {
+ return (
+
+ );
+ }
+ if (fileInfo.mimetype.startsWith("image/")) {
+ return ;
+ }
+ return Preview Not Supported;
+}
+
+export { StreamingPreview };
diff --git a/frontend/app/view/preview/preview.tsx b/frontend/app/view/preview/preview.tsx
index 563e0a36db..85f18715cc 100644
--- a/frontend/app/view/preview/preview.tsx
+++ b/frontend/app/view/preview/preview.tsx
@@ -1,51 +1,27 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
-import { BlockNodeModel } from "@/app/block/blocktypes";
import { Button } from "@/app/element/button";
import { CopyButton } from "@/app/element/copybutton";
import { CenteredDiv } from "@/app/element/quickelems";
-import { ContextMenuModel } from "@/app/store/contextmenu";
-import { tryReinjectKey } from "@/app/store/keymodel";
import { RpcApi } from "@/app/store/wshclientapi";
import { TabRpcClient } from "@/app/store/wshrpcutil";
import { BlockHeaderSuggestionControl } from "@/app/suggestion/suggestion";
-import { CodeEditor } from "@/app/view/codeeditor/codeeditor";
-import { Markdown } from "@/element/markdown";
-import { getConnStatusAtom, getOverrideConfigAtom, getSettingsKeyAtom, globalStore, refocusNode } from "@/store/global";
-import * as services from "@/store/services";
-import * as WOS from "@/store/wos";
-import { getWebServerEndpoint } from "@/util/endpoints";
-import { goHistory, goHistoryBack, goHistoryForward } from "@/util/historyutil";
-import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil";
-import { addOpenMenuItems } from "@/util/previewutil";
-import { base64ToString, fireAndForget, isBlank, jotaiLoadableValue, makeConnRoute, stringToBase64 } from "@/util/util";
-import { formatRemoteUri } from "@/util/waveutil";
-import { Monaco } from "@monaco-editor/react";
+import { globalStore } from "@/store/global";
+import { isBlank, makeConnRoute } from "@/util/util";
import clsx from "clsx";
-import { Atom, atom, Getter, PrimitiveAtom, useAtom, useAtomValue, useSetAtom, WritableAtom } from "jotai";
-import { loadable } from "jotai/utils";
-import type * as MonacoTypes from "monaco-editor/esm/vs/editor/editor.api";
+import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
-import { createRef, memo, useCallback, useEffect, useMemo } from "react";
-import { TransformComponent, TransformWrapper, useControls } from "react-zoom-pan-pinch";
+import { memo, useCallback, useEffect } from "react";
import { CSVView } from "./csvview";
-import { DirectoryPreview } from "./directorypreview";
+import { DirectoryPreview } from "./preview-directory";
+import { CodeEditPreview } from "./preview-edit";
+import { MarkdownPreview } from "./preview-markdown";
+import type { PreviewModel } from "./preview-model";
+import { StreamingPreview } from "./preview-streaming";
import "./preview.scss";
-const MaxFileSize = 1024 * 1024 * 10; // 10MB
-const MaxCSVSize = 1024 * 1024 * 1; // 1MB
-
-// TODO drive this using config
-const BOOKMARKS: { label: string; path: string }[] = [
- { label: "Home", path: "~" },
- { label: "Desktop", path: "~/Desktop" },
- { label: "Downloads", path: "~/Downloads" },
- { label: "Documents", path: "~/Documents" },
- { label: "Root", path: "/" },
-];
-
-type SpecializedViewProps = {
+export type SpecializedViewProps = {
model: PreviewModel;
parentRef: React.RefObject;
};
@@ -58,41 +34,6 @@ const SpecializedViewMap: { [view: string]: ({ model }: SpecializedViewProps) =>
directory: DirectoryPreview,
};
-const textApplicationMimetypes = [
- "application/sql",
- "application/x-php",
- "application/x-pem-file",
- "application/x-httpd-php",
- "application/liquid",
- "application/graphql",
- "application/javascript",
- "application/typescript",
- "application/x-javascript",
- "application/x-typescript",
- "application/dart",
- "application/vnd.dart",
- "application/x-ruby",
- "application/sql",
- "application/wasm",
- "application/x-latex",
- "application/x-sh",
- "application/x-python",
- "application/x-awk",
-];
-
-function isTextFile(mimeType: string): boolean {
- if (mimeType == null) {
- return false;
- }
- return (
- mimeType.startsWith("text/") ||
- textApplicationMimetypes.includes(mimeType) ||
- (mimeType.startsWith("application/") &&
- (mimeType.includes("json") || mimeType.includes("yaml") || mimeType.includes("toml"))) ||
- mimeType.includes("xml")
- );
-}
-
function canPreview(mimeType: string): boolean {
if (mimeType == null) {
return false;
@@ -100,950 +41,12 @@ function canPreview(mimeType: string): boolean {
return mimeType.startsWith("text/markdown") || mimeType.startsWith("text/csv");
}
-function isStreamingType(mimeType: string): boolean {
- if (mimeType == null) {
- return false;
- }
- return (
- mimeType.startsWith("application/pdf") ||
- mimeType.startsWith("video/") ||
- mimeType.startsWith("audio/") ||
- mimeType.startsWith("image/")
- );
-}
-export class PreviewModel implements ViewModel {
- viewType: string;
- blockId: string;
- nodeModel: BlockNodeModel;
- noPadding?: Atom;
- blockAtom: Atom;
- viewIcon: Atom;
- viewName: Atom;
- viewText: Atom;
- preIconButton: Atom;
- endIconButtons: Atom;
- previewTextRef: React.RefObject;
- editMode: Atom;
- canPreview: PrimitiveAtom;
- specializedView: Atom>;
- loadableSpecializedView: Atom>;
- manageConnection: Atom;
- connStatus: Atom;
- filterOutNowsh?: Atom;
-
- metaFilePath: Atom;
- statFilePath: Atom>;
- loadableFileInfo: Atom>;
- connection: Atom>;
- connectionImmediate: Atom;
- statFile: Atom>;
- fullFile: Atom>;
- fileMimeType: Atom>;
- fileMimeTypeLoadable: Atom>;
- fileContentSaved: PrimitiveAtom;
- fileContent: WritableAtom, [string], void>;
- newFileContent: PrimitiveAtom;
- connectionError: PrimitiveAtom;
- errorMsgAtom: PrimitiveAtom;
-
- openFileModal: PrimitiveAtom;
- openFileModalDelay: PrimitiveAtom;
- openFileError: PrimitiveAtom;
- openFileModalGiveFocusRef: React.MutableRefObject<() => boolean>;
-
- markdownShowToc: PrimitiveAtom;
-
- monacoRef: React.MutableRefObject;
-
- showHiddenFiles: PrimitiveAtom;
- refreshVersion: PrimitiveAtom;
- refreshCallback: () => void;
- directoryKeyDownHandler: (waveEvent: WaveKeyboardEvent) => boolean;
- codeEditKeyDownHandler: (waveEvent: WaveKeyboardEvent) => boolean;
-
- showS3 = atom(true);
-
- constructor(blockId: string, nodeModel: BlockNodeModel) {
- this.viewType = "preview";
- this.blockId = blockId;
- this.nodeModel = nodeModel;
- let showHiddenFiles = globalStore.get(getSettingsKeyAtom("preview:showhiddenfiles")) ?? true;
- this.showHiddenFiles = atom(showHiddenFiles);
- this.refreshVersion = atom(0);
- this.previewTextRef = createRef();
- this.openFileModal = atom(false);
- this.openFileModalDelay = atom(false);
- this.openFileError = atom(null) as PrimitiveAtom;
- this.openFileModalGiveFocusRef = createRef();
- this.manageConnection = atom(true);
- this.blockAtom = WOS.getWaveObjectAtom(`block:${blockId}`);
- this.markdownShowToc = atom(false);
- this.filterOutNowsh = atom(true);
- this.monacoRef = createRef();
- this.connectionError = atom("");
- this.errorMsgAtom = atom(null) as PrimitiveAtom;
- this.viewIcon = atom((get) => {
- const blockData = get(this.blockAtom);
- if (blockData?.meta?.icon) {
- return blockData.meta.icon;
- }
- const connStatus = get(this.connStatus);
- if (connStatus?.status != "connected") {
- return null;
- }
- const mimeTypeLoadable = get(this.fileMimeTypeLoadable);
- const mimeType = jotaiLoadableValue(mimeTypeLoadable, "");
- if (mimeType == "directory") {
- return {
- elemtype: "iconbutton",
- icon: "folder-open",
- longClick: (e: React.MouseEvent) => {
- const menuItems: ContextMenuItem[] = BOOKMARKS.map((bookmark) => ({
- label: `Go to ${bookmark.label} (${bookmark.path})`,
- click: () => this.goHistory(bookmark.path),
- }));
- ContextMenuModel.showContextMenu(menuItems, e);
- },
- };
- }
- return iconForFile(mimeType);
- });
- this.editMode = atom((get) => {
- const blockData = get(this.blockAtom);
- return blockData?.meta?.edit ?? false;
- });
- this.viewName = atom("Preview");
- this.viewText = atom((get) => {
- let headerPath = get(this.metaFilePath);
- const connStatus = get(this.connStatus);
- if (connStatus?.status != "connected") {
- return [
- {
- elemtype: "text",
- text: headerPath,
- className: "preview-filename",
- },
- ];
- }
- const loadableSV = get(this.loadableSpecializedView);
- const isCeView = loadableSV.state == "hasData" && loadableSV.data.specializedView == "codeedit";
- const loadableFileInfo = get(this.loadableFileInfo);
- if (loadableFileInfo.state == "hasData") {
- headerPath = loadableFileInfo.data?.path;
- if (headerPath == "~") {
- headerPath = `~ (${loadableFileInfo.data?.dir + "/" + loadableFileInfo.data?.name})`;
- }
- }
- if (!isBlank(headerPath) && headerPath != "/" && headerPath.endsWith("/")) {
- headerPath = headerPath.slice(0, -1);
- }
- const viewTextChildren: HeaderElem[] = [
- {
- elemtype: "text",
- text: headerPath,
- ref: this.previewTextRef,
- className: "preview-filename",
- onClick: () => this.toggleOpenFileModal(),
- },
- ];
- let saveClassName = "grey";
- if (get(this.newFileContent) !== null) {
- saveClassName = "green";
- }
- if (isCeView) {
- const fileInfo = globalStore.get(this.loadableFileInfo);
- if (fileInfo.state != "hasData") {
- viewTextChildren.push({
- elemtype: "textbutton",
- text: "Loading ...",
- className: clsx(
- `grey warning border-radius-4 vertical-padding-2 horizontal-padding-10 font-size-11 font-weight-500`
- ),
- onClick: () => {},
- });
- } else if (fileInfo.data.readonly) {
- viewTextChildren.push({
- elemtype: "textbutton",
- text: "Read Only",
- className: clsx(
- `yellow warning border-radius-4 vertical-padding-2 horizontal-padding-10 font-size-11 font-weight-500`
- ),
- onClick: () => {},
- });
- } else {
- viewTextChildren.push({
- elemtype: "textbutton",
- text: "Save",
- className: clsx(
- `${saveClassName} warning border-radius-4 vertical-padding-2 horizontal-padding-10 font-size-11 font-weight-500`
- ),
- onClick: () => fireAndForget(this.handleFileSave.bind(this)),
- });
- }
- if (get(this.canPreview)) {
- viewTextChildren.push({
- elemtype: "textbutton",
- text: "Preview",
- className:
- "grey border-radius-4 vertical-padding-2 horizontal-padding-10 font-size-11 font-weight-500",
- onClick: () => fireAndForget(() => this.setEditMode(false)),
- });
- }
- } else if (get(this.canPreview)) {
- viewTextChildren.push({
- elemtype: "textbutton",
- text: "Edit",
- className:
- "grey border-radius-4 vertical-padding-2 horizontal-padding-10 font-size-11 font-weight-500",
- onClick: () => fireAndForget(() => this.setEditMode(true)),
- });
- }
- return [
- {
- elemtype: "div",
- children: viewTextChildren,
- },
- ] as HeaderElem[];
- });
- this.preIconButton = atom((get) => {
- const connStatus = get(this.connStatus);
- if (connStatus?.status != "connected") {
- return null;
- }
- const mimeType = jotaiLoadableValue(get(this.fileMimeTypeLoadable), "");
- const metaPath = get(this.metaFilePath);
- if (mimeType == "directory" && metaPath == "/") {
- return null;
- }
- return {
- elemtype: "iconbutton",
- icon: "chevron-left",
- click: this.goParentDirectory.bind(this),
- };
- });
- this.endIconButtons = atom((get) => {
- const connStatus = get(this.connStatus);
- if (connStatus?.status != "connected") {
- return null;
- }
- const mimeType = jotaiLoadableValue(get(this.fileMimeTypeLoadable), "");
- const loadableSV = get(this.loadableSpecializedView);
- const isCeView = loadableSV.state == "hasData" && loadableSV.data.specializedView == "codeedit";
- if (mimeType == "directory") {
- const showHiddenFiles = get(this.showHiddenFiles);
- return [
- {
- elemtype: "iconbutton",
- icon: showHiddenFiles ? "eye" : "eye-slash",
- click: () => {
- globalStore.set(this.showHiddenFiles, (prev) => !prev);
- },
- },
- {
- elemtype: "iconbutton",
- icon: "arrows-rotate",
- click: () => this.refreshCallback?.(),
- },
- ] as IconButtonDecl[];
- } else if (!isCeView && mimeType?.startsWith("text/markdown")) {
- return [
- {
- elemtype: "iconbutton",
- icon: "book",
- title: "Table of Contents",
- click: () => this.markdownShowTocToggle(),
- },
- ] as IconButtonDecl[];
- }
- return null;
- });
- this.metaFilePath = atom((get) => {
- const file = get(this.blockAtom)?.meta?.file;
- if (isBlank(file)) {
- return "~";
- }
- return file;
- });
- this.statFilePath = atom>(async (get) => {
- const fileInfo = await get(this.statFile);
- return fileInfo?.path;
- });
- this.connection = atom>(async (get) => {
- const connName = get(this.blockAtom)?.meta?.connection;
- try {
- await RpcApi.ConnEnsureCommand(TabRpcClient, { connname: connName }, { timeout: 60000 });
- globalStore.set(this.connectionError, "");
- } catch (e) {
- globalStore.set(this.connectionError, e as string);
- }
- return connName;
- });
- this.connectionImmediate = atom((get) => {
- return get(this.blockAtom)?.meta?.connection;
- });
- this.statFile = atom>(async (get) => {
- const fileName = get(this.metaFilePath);
- const path = await this.formatRemoteUri(fileName, get);
- if (fileName == null) {
- return null;
- }
- try {
- const statFile = await RpcApi.FileInfoCommand(TabRpcClient, {
- info: {
- path,
- },
- });
- return statFile;
- } catch (e) {
- const errorStatus: ErrorMsg = {
- status: "File Read Failed",
- text: `${e}`,
- };
- globalStore.set(this.errorMsgAtom, errorStatus);
- }
- });
- this.fileMimeType = atom>(async (get) => {
- const fileInfo = await get(this.statFile);
- return fileInfo?.mimetype;
- });
- this.fileMimeTypeLoadable = loadable(this.fileMimeType);
- this.newFileContent = atom(null) as PrimitiveAtom;
- this.goParentDirectory = this.goParentDirectory.bind(this);
-
- const fullFileAtom = atom>(async (get) => {
- const fileName = get(this.metaFilePath);
- const path = await this.formatRemoteUri(fileName, get);
- if (fileName == null) {
- return null;
- }
- try {
- const file = await RpcApi.FileReadCommand(TabRpcClient, {
- info: {
- path,
- },
- });
- return file;
- } catch (e) {
- const errorStatus: ErrorMsg = {
- status: "File Read Failed",
- text: `${e}`,
- };
- globalStore.set(this.errorMsgAtom, errorStatus);
- }
- });
-
- this.fileContentSaved = atom(null) as PrimitiveAtom;
- const fileContentAtom = atom(
- async (get) => {
- const newContent = get(this.newFileContent);
- if (newContent != null) {
- return newContent;
- }
- const savedContent = get(this.fileContentSaved);
- if (savedContent != null) {
- return savedContent;
- }
- const fullFile = await get(fullFileAtom);
- return base64ToString(fullFile?.data64);
- },
- (_, set, update: string) => {
- set(this.fileContentSaved, update);
- }
- );
-
- this.fullFile = fullFileAtom;
- this.fileContent = fileContentAtom;
-
- this.specializedView = atom>(async (get) => {
- return this.getSpecializedView(get);
- });
- this.loadableSpecializedView = loadable(this.specializedView);
- this.canPreview = atom(false);
- this.loadableFileInfo = loadable(this.statFile);
- this.connStatus = atom((get) => {
- const blockData = get(this.blockAtom);
- const connName = blockData?.meta?.connection;
- const connAtom = getConnStatusAtom(connName);
- return get(connAtom);
- });
-
- this.noPadding = atom(true);
- }
-
- markdownShowTocToggle() {
- globalStore.set(this.markdownShowToc, !globalStore.get(this.markdownShowToc));
- }
-
- get viewComponent(): ViewComponent {
- return PreviewView;
- }
-
- async getSpecializedView(getFn: Getter): Promise<{ specializedView?: string; errorStr?: string }> {
- const mimeType = await getFn(this.fileMimeType);
- const fileInfo = await getFn(this.statFile);
- const fileName = fileInfo?.name;
- const connErr = getFn(this.connectionError);
- const editMode = getFn(this.editMode);
- const genErr = getFn(this.errorMsgAtom);
-
- if (!fileInfo) {
- return { errorStr: `Load Error: ${genErr?.text}` };
- }
- if (connErr != "") {
- return { errorStr: `Connection Error: ${connErr}` };
- }
- if (fileInfo?.notfound) {
- return { specializedView: "codeedit" };
- }
- if (mimeType == null) {
- return { errorStr: `Unable to determine mimetype for: ${fileInfo.path}` };
- }
- if (isStreamingType(mimeType)) {
- return { specializedView: "streaming" };
- }
- if (!fileInfo) {
- const fileNameStr = fileName ? " " + JSON.stringify(fileName) : "";
- return { errorStr: "File Not Found" + fileNameStr };
- }
- if (fileInfo.size > MaxFileSize) {
- return { errorStr: "File Too Large to Preiview (10 MB Max)" };
- }
- if (mimeType == "text/csv" && fileInfo.size > MaxCSVSize) {
- return { errorStr: "CSV File Too Large to Preiview (1 MB Max)" };
- }
- if (mimeType == "directory") {
- return { specializedView: "directory" };
- }
- if (mimeType == "text/csv") {
- if (editMode) {
- return { specializedView: "codeedit" };
- }
- return { specializedView: "csv" };
- }
- if (mimeType.startsWith("text/markdown")) {
- if (editMode) {
- return { specializedView: "codeedit" };
- }
- return { specializedView: "markdown" };
- }
- if (isTextFile(mimeType) || fileInfo.size == 0) {
- return { specializedView: "codeedit" };
- }
- return { errorStr: `Preview (${mimeType})` };
- }
-
- updateOpenFileModalAndError(isOpen, errorMsg = null) {
- globalStore.set(this.openFileModal, isOpen);
- globalStore.set(this.openFileError, errorMsg);
- if (isOpen) {
- globalStore.set(this.openFileModalDelay, true);
- } else {
- const delayVal = globalStore.get(this.openFileModalDelay);
- if (delayVal) {
- setTimeout(() => {
- globalStore.set(this.openFileModalDelay, false);
- }, 200);
- }
- }
- }
-
- toggleOpenFileModal() {
- const modalOpen = globalStore.get(this.openFileModal);
- const delayVal = globalStore.get(this.openFileModalDelay);
- if (!modalOpen && delayVal) {
- return;
- }
- this.updateOpenFileModalAndError(!modalOpen);
- }
-
- async goHistory(newPath: string) {
- let fileName = globalStore.get(this.metaFilePath);
- if (fileName == null) {
- fileName = "";
- }
- const blockMeta = globalStore.get(this.blockAtom)?.meta;
- const updateMeta = goHistory("file", fileName, newPath, blockMeta);
- if (updateMeta == null) {
- return;
- }
- const blockOref = WOS.makeORef("block", this.blockId);
- await services.ObjectService.UpdateObjectMeta(blockOref, updateMeta);
-
- // Clear the saved file buffers
- globalStore.set(this.fileContentSaved, null);
- globalStore.set(this.newFileContent, null);
- }
-
- async goParentDirectory({ fileInfo = null }: { fileInfo?: FileInfo | null }) {
- // optional parameter needed for recursive case
- const defaultFileInfo = await globalStore.get(this.statFile);
- if (fileInfo === null) {
- fileInfo = defaultFileInfo;
- }
- if (fileInfo == null) {
- this.updateOpenFileModalAndError(false);
- return true;
- }
- try {
- this.updateOpenFileModalAndError(false);
- await this.goHistory(fileInfo.dir);
- refocusNode(this.blockId);
- } catch (e) {
- globalStore.set(this.openFileError, e.message);
- console.error("Error opening file", fileInfo.dir, e);
- }
- }
-
- async goHistoryBack() {
- const blockMeta = globalStore.get(this.blockAtom)?.meta;
- const curPath = globalStore.get(this.metaFilePath);
- const updateMeta = goHistoryBack("file", curPath, blockMeta, true);
- if (updateMeta == null) {
- return;
- }
- updateMeta.edit = false;
- const blockOref = WOS.makeORef("block", this.blockId);
- await services.ObjectService.UpdateObjectMeta(blockOref, updateMeta);
- }
-
- async goHistoryForward() {
- const blockMeta = globalStore.get(this.blockAtom)?.meta;
- const curPath = globalStore.get(this.metaFilePath);
- const updateMeta = goHistoryForward("file", curPath, blockMeta);
- if (updateMeta == null) {
- return;
- }
- updateMeta.edit = false;
- const blockOref = WOS.makeORef("block", this.blockId);
- await services.ObjectService.UpdateObjectMeta(blockOref, updateMeta);
- }
-
- async setEditMode(edit: boolean) {
- const blockMeta = globalStore.get(this.blockAtom)?.meta;
- const blockOref = WOS.makeORef("block", this.blockId);
- await services.ObjectService.UpdateObjectMeta(blockOref, { ...blockMeta, edit });
- }
-
- async handleFileSave() {
- const filePath = await globalStore.get(this.statFilePath);
- if (filePath == null) {
- return;
- }
- const newFileContent = globalStore.get(this.newFileContent);
- if (newFileContent == null) {
- console.log("not saving file, newFileContent is null");
- return;
- }
- try {
- await RpcApi.FileWriteCommand(TabRpcClient, {
- info: {
- path: await this.formatRemoteUri(filePath, globalStore.get),
- },
- data64: stringToBase64(newFileContent),
- });
- globalStore.set(this.fileContent, newFileContent);
- globalStore.set(this.newFileContent, null);
- console.log("saved file", filePath);
- } catch (e) {
- const errorStatus: ErrorMsg = {
- status: "Save Failed",
- text: `${e}`,
- };
- globalStore.set(this.errorMsgAtom, errorStatus);
- }
- }
-
- async handleFileRevert() {
- const fileContent = await globalStore.get(this.fileContent);
- this.monacoRef.current?.setValue(fileContent);
- globalStore.set(this.newFileContent, null);
- }
-
- async handleOpenFile(filePath: string) {
- const conn = globalStore.get(this.connectionImmediate);
- if (!isBlank(conn) && conn.startsWith("aws:")) {
- if (!isBlank(filePath) && filePath != "/" && filePath.startsWith("/")) {
- filePath = filePath.substring(1);
- }
- }
- const fileInfo = await globalStore.get(this.statFile);
- this.updateOpenFileModalAndError(false);
- if (fileInfo == null) {
- return true;
- }
- try {
- this.goHistory(filePath);
- refocusNode(this.blockId);
- } catch (e) {
- globalStore.set(this.openFileError, e.message);
- console.error("Error opening file", filePath, e);
- }
- }
-
- isSpecializedView(sv: string): boolean {
- const loadableSV = globalStore.get(this.loadableSpecializedView);
- return loadableSV.state == "hasData" && loadableSV.data.specializedView == sv;
- }
-
- getSettingsMenuItems(): ContextMenuItem[] {
- const defaultFontSize = globalStore.get(getSettingsKeyAtom("editor:fontsize")) ?? 12;
- const blockData = globalStore.get(this.blockAtom);
- const overrideFontSize = blockData?.meta?.["editor:fontsize"];
- const menuItems: ContextMenuItem[] = [];
- menuItems.push({
- label: "Copy Full Path",
- click: () =>
- fireAndForget(async () => {
- const filePath = await globalStore.get(this.statFilePath);
- if (filePath == null) {
- return;
- }
- const conn = await globalStore.get(this.connection);
- if (conn) {
- // remote path
- await navigator.clipboard.writeText(formatRemoteUri(filePath, conn));
- } else {
- // local path
- await navigator.clipboard.writeText(filePath);
- }
- }),
- });
- menuItems.push({
- label: "Copy File Name",
- click: () =>
- fireAndForget(async () => {
- const fileInfo = await globalStore.get(this.statFile);
- if (fileInfo == null || fileInfo.name == null) {
- return;
- }
- await navigator.clipboard.writeText(fileInfo.name);
- }),
- });
- menuItems.push({ type: "separator" });
- const fontSizeSubMenu: ContextMenuItem[] = [6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18].map(
- (fontSize: number) => {
- return {
- label: fontSize.toString() + "px",
- type: "checkbox",
- checked: overrideFontSize == fontSize,
- click: () => {
- RpcApi.SetMetaCommand(TabRpcClient, {
- oref: WOS.makeORef("block", this.blockId),
- meta: { "editor:fontsize": fontSize },
- });
- },
- };
- }
- );
- fontSizeSubMenu.unshift({
- label: "Default (" + defaultFontSize + "px)",
- type: "checkbox",
- checked: overrideFontSize == null,
- click: () => {
- RpcApi.SetMetaCommand(TabRpcClient, {
- oref: WOS.makeORef("block", this.blockId),
- meta: { "editor:fontsize": null },
- });
- },
- });
- menuItems.push({
- label: "Editor Font Size",
- submenu: fontSizeSubMenu,
- });
- const finfo = jotaiLoadableValue(globalStore.get(this.loadableFileInfo), null);
- addOpenMenuItems(menuItems, globalStore.get(this.connectionImmediate), finfo);
- const loadableSV = globalStore.get(this.loadableSpecializedView);
- const wordWrapAtom = getOverrideConfigAtom(this.blockId, "editor:wordwrap");
- const wordWrap = globalStore.get(wordWrapAtom) ?? false;
- if (loadableSV.state == "hasData") {
- if (loadableSV.data.specializedView == "codeedit") {
- if (globalStore.get(this.newFileContent) != null) {
- menuItems.push({ type: "separator" });
- menuItems.push({
- label: "Save File",
- click: () => fireAndForget(this.handleFileSave.bind(this)),
- });
- menuItems.push({
- label: "Revert File",
- click: () => fireAndForget(this.handleFileRevert.bind(this)),
- });
- }
- menuItems.push({ type: "separator" });
- menuItems.push({
- label: "Word Wrap",
- type: "checkbox",
- checked: wordWrap,
- click: () =>
- fireAndForget(async () => {
- const blockOref = WOS.makeORef("block", this.blockId);
- await services.ObjectService.UpdateObjectMeta(blockOref, {
- "editor:wordwrap": !wordWrap,
- });
- }),
- });
- }
- }
- return menuItems;
- }
-
- giveFocus(): boolean {
- const openModalOpen = globalStore.get(this.openFileModal);
- if (openModalOpen) {
- this.openFileModalGiveFocusRef.current?.();
- return true;
- }
- if (this.monacoRef.current) {
- this.monacoRef.current.focus();
- return true;
- }
- return false;
- }
-
- keyDownHandler(e: WaveKeyboardEvent): boolean {
- if (checkKeyPressed(e, "Cmd:ArrowLeft")) {
- fireAndForget(this.goHistoryBack.bind(this));
- return true;
- }
- if (checkKeyPressed(e, "Cmd:ArrowRight")) {
- fireAndForget(this.goHistoryForward.bind(this));
- return true;
- }
- if (checkKeyPressed(e, "Cmd:ArrowUp")) {
- // handle up directory
- fireAndForget(() => this.goParentDirectory({}));
- return true;
- }
- if (checkKeyPressed(e, "Cmd:o")) {
- this.toggleOpenFileModal();
- return true;
- }
- const canPreview = globalStore.get(this.canPreview);
- if (canPreview) {
- if (checkKeyPressed(e, "Cmd:e")) {
- const editMode = globalStore.get(this.editMode);
- fireAndForget(() => this.setEditMode(!editMode));
- return true;
- }
- }
- if (this.directoryKeyDownHandler) {
- const handled = this.directoryKeyDownHandler(e);
- if (handled) {
- return true;
- }
- }
- if (this.codeEditKeyDownHandler) {
- const handled = this.codeEditKeyDownHandler(e);
- if (handled) {
- return true;
- }
- }
- return false;
- }
-
- async formatRemoteUri(path: string, get: Getter): Promise {
- return formatRemoteUri(path, await get(this.connection));
- }
-}
-
-function MarkdownPreview({ model }: SpecializedViewProps) {
- const connName = useAtomValue(model.connection);
- const fileInfo = useAtomValue(model.statFile);
- const fontSizeOverride = useAtomValue(getOverrideConfigAtom(model.blockId, "markdown:fontsize"));
- const fixedFontSizeOverride = useAtomValue(getOverrideConfigAtom(model.blockId, "markdown:fixedfontsize"));
- const resolveOpts: MarkdownResolveOpts = useMemo(() => {
- return {
- connName: connName,
- baseDir: fileInfo.dir,
- };
- }, [connName, fileInfo.dir]);
- return (
-
-
-
- );
-}
-
-function ImageZooomControls() {
- const { zoomIn, zoomOut, resetTransform } = useControls();
-
- return (
-
-
-
-
-
- );
-}
-
-function StreamingImagePreview({ url }: { url: string }) {
- return (
-
-
- {({ zoomIn, zoomOut, resetTransform, ...rest }) => (
- <>
-
-
-
-
- >
- )}
-
-
- );
-}
-
-function StreamingPreview({ model }: SpecializedViewProps) {
- const conn = useAtomValue(model.connection);
- const fileInfo = useAtomValue(model.statFile);
- const filePath = fileInfo.path;
- const remotePath = formatRemoteUri(filePath, conn);
- const usp = new URLSearchParams();
- usp.set("path", remotePath);
- if (conn != null) {
- usp.set("connection", conn);
- }
- const streamingUrl = `${getWebServerEndpoint()}/wave/stream-file?${usp.toString()}`;
- if (fileInfo.mimetype === "application/pdf") {
- return (
-
-
-
- );
- }
- if (fileInfo.mimetype.startsWith("video/")) {
- return (
-
-
-
- );
- }
- if (fileInfo.mimetype.startsWith("audio/")) {
- return (
-
- );
- }
- if (fileInfo.mimetype.startsWith("image/")) {
- return ;
- }
- return Preview Not Supported;
-}
-
-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")) {
- fireAndForget(() => model.setEditMode(false));
- return true;
- }
- if (checkKeyPressed(e, "Cmd:s") || checkKeyPressed(e, "Ctrl:s")) {
- fireAndForget(model.handleFileSave.bind(model));
- return true;
- }
- if (checkKeyPressed(e, "Cmd:r")) {
- fireAndForget(model.handleFileRevert.bind(model));
- return true;
- }
- return false;
- }
-
- useEffect(() => {
- model.codeEditKeyDownHandler = codeEditKeyDownHandler;
- return () => {
- model.codeEditKeyDownHandler = null;
- model.monacoRef.current = null;
- };
- }, []);
-
- function onMount(editor: MonacoTypes.editor.IStandaloneCodeEditor, monaco: Monaco): () => void {
- model.monacoRef.current = editor;
-
- editor.onKeyDown((e: MonacoTypes.IKeyboardEvent) => {
- const waveEvent = adaptFromReactOrNativeKeyEvent(e.browserEvent);
- const handled = tryReinjectKey(waveEvent);
- if (handled) {
- e.stopPropagation();
- e.preventDefault();
- }
- });
-
- const isFocused = globalStore.get(model.nodeModel.isFocused);
- if (isFocused) {
- editor.focus();
- }
-
- return null;
- }
-
- return (
- setNewFileContent(text)}
- onMount={onMount}
- />
- );
-}
-
function CSVViewPreview({ model, parentRef }: SpecializedViewProps) {
const fileContent = useAtomValue(model.fileContent);
const fileName = useAtomValue(model.statFilePath);
return ;
}
-function iconForFile(mimeType: string): string {
- if (mimeType == null) {
- mimeType = "unknown";
- }
- if (mimeType == "application/pdf") {
- return "file-pdf";
- } else if (mimeType.startsWith("image/")) {
- return "image";
- } else if (mimeType.startsWith("video/")) {
- return "film";
- } else if (mimeType.startsWith("audio/")) {
- return "headphones";
- } else if (mimeType.startsWith("text/markdown")) {
- return "file-lines";
- } else if (mimeType == "text/csv") {
- return "file-csv";
- } else if (
- mimeType.startsWith("text/") ||
- mimeType == "application/sql" ||
- (mimeType.startsWith("application/") &&
- (mimeType.includes("json") || mimeType.includes("yaml") || mimeType.includes("toml")))
- ) {
- return "file-code";
- } else {
- return "file";
- }
-}
-
const SpecializedView = memo(({ parentRef, model }: SpecializedViewProps) => {
const specializedView = useAtomValue(model.specializedView);
const mimeType = useAtomValue(model.fileMimeType);
@@ -1059,7 +62,7 @@ const SpecializedView = memo(({ parentRef, model }: SpecializedViewProps) => {
}
const SpecializedViewComponent = SpecializedViewMap[specializedView.specializedView];
if (!SpecializedViewComponent) {
- return Invalid Specialzied View Component ({specializedView.specializedView});
+ return Invalid Specialized View Component ({specializedView.specializedView});
}
return ;
});