// Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { BlockNodeModel } from "@/app/block/blocktypes"; import { CenteredDiv } from "@/app/element/quickelems"; import { TypeAheadModal } from "@/app/modals/typeaheadmodal"; 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 { CodeEditor } from "@/app/view/codeeditor/codeeditor"; import { Markdown } from "@/element/markdown"; import { atoms, createBlock, 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, keydownWrapper } from "@/util/keyutil"; import { base64ToString, fireAndForget, isBlank, jotaiLoadableValue, makeConnRoute, stringToBase64 } from "@/util/util"; import { Monaco } from "@monaco-editor/react"; import clsx from "clsx"; import { Atom, atom, Getter, PrimitiveAtom, useAtomValue, useSetAtom, WritableAtom } from "jotai"; import { loadable } from "jotai/utils"; import type * as MonacoTypes from "monaco-editor/esm/vs/editor/editor.api"; import { createRef, memo, useCallback, useEffect, useMemo, useState } from "react"; import { CSVView } from "./csvview"; import { DirectoryPreview } from "./directorypreview"; import "./preview.scss"; const MaxFileSize = 1024 * 1024 * 10; // 10MB const MaxCSVSize = 1024 * 1024 * 1; // 1MB type SpecializedViewProps = { model: PreviewModel; parentRef: React.RefObject; }; const SpecializedViewMap: { [view: string]: ({ model }: SpecializedViewProps) => React.JSX.Element } = { streaming: StreamingPreview, markdown: MarkdownPreview, codeedit: CodeEditPreview, csv: CSVViewPreview, directory: DirectoryPreview, }; const textApplicationMimetypes = [ "application/sql", "application/pem-certificate-chain", "application/x-php", "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; } 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; 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>; normFilePath: Atom>; loadableStatFilePath: Atom>; loadableFileInfo: Atom>; connection: Atom; statFile: Atom>; fullFile: Atom>; fileMimeType: Atom>; fileMimeTypeLoadable: Atom>; fileContentSaved: PrimitiveAtom; fileContent: WritableAtom, [string], void>; newFileContent: PrimitiveAtom; openFileModal: 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; 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.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.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[] = []; menuItems.push({ label: "Go to Home", click: () => this.goHistory("~"), }); menuItems.push({ label: "Go to Desktop", click: () => this.goHistory("~/Desktop"), }); menuItems.push({ label: "Go to Downloads", click: () => this.goHistory("~/Downloads"), }); menuItems.push({ label: "Go to Documents", click: () => this.goHistory("~/Documents"), }); menuItems.push({ label: "Go to Root", click: () => this.goHistory("/"), }); 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})`; } } const viewTextChildren: HeaderElem[] = [ { elemtype: "text", text: headerPath, ref: this.previewTextRef, className: "preview-filename", onClick: () => this.updateOpenFileModalAndError(true), }, ]; let saveClassName = "grey"; if (get(this.newFileContent) !== null) { saveClassName = "green"; } if (isCeView) { 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); const settings = get(atoms.settingsAtom); 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.normFilePath = atom>(async (get) => { const fileInfo = await get(this.statFile); if (fileInfo == null) { return null; } if (fileInfo.isdir) { return fileInfo.dir + "/"; } return fileInfo.dir + "/" + fileInfo.name; }); this.loadableStatFilePath = loadable(this.statFilePath); this.connection = atom((get) => { return get(this.blockAtom)?.meta?.connection; }); this.statFile = atom>(async (get) => { const fileName = get(this.metaFilePath); if (fileName == null) { return null; } const conn = get(this.connection) ?? ""; const statFile = await services.FileService.StatFile(conn, fileName); return statFile; }); 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); if (fileName == null) { return null; } const conn = get(this.connection) ?? ""; const file = await services.FileService.ReadFile(conn, fileName); return file; }); this.fileContentSaved = atom(null) as PrimitiveAtom; const fileContentAtom = atom( async (get) => { const _ = get(this.metaFilePath); 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); }, (get, 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); }); } markdownShowTocToggle() { globalStore.set(this.markdownShowToc, !globalStore.get(this.markdownShowToc)); } async getSpecializedView(getFn: Getter): Promise<{ specializedView?: string; errorStr?: string }> { const mimeType = await getFn(this.fileMimeType); const fileInfo = await getFn(this.statFile); const fileName = await getFn(this.statFilePath); const editMode = getFn(this.editMode); const parentFileInfo = await this.getParentInfo(fileInfo); console.log(parentFileInfo); if (parentFileInfo?.notfound ?? false) { return { errorStr: `Parent Directory Not Found: ${fileInfo.path}` }; } 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); } 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 getParentInfo(fileInfo: FileInfo): Promise { const conn = globalStore.get(this.connection); try { const parentFileInfo = await RpcApi.RemoteFileJoinCommand(TabRpcClient, [fileInfo.path, ".."], { route: makeConnRoute(conn), }); return parentFileInfo; } catch { return undefined; } } 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; } const conn = globalStore.get(this.connection); try { const newFileInfo = await RpcApi.RemoteFileJoinCommand(TabRpcClient, [fileInfo.path, ".."], { route: makeConnRoute(conn), }); if (newFileInfo.path != "" && newFileInfo.notfound) { console.log("does not exist, ", newFileInfo.path); this.goParentDirectory({ fileInfo: newFileInfo }); return; } console.log(newFileInfo.path); this.updateOpenFileModalAndError(false); await this.goHistory(newFileInfo.path); 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; } const conn = globalStore.get(this.connection) ?? ""; try { await services.FileService.SaveFile(conn, filePath, stringToBase64(newFileContent)); globalStore.set(this.fileContent, newFileContent); globalStore.set(this.newFileContent, null); console.log("saved file", filePath); } catch (error) { console.error("Error saving file:", error); } } async handleFileRevert() { const fileContent = await globalStore.get(this.fileContent); this.monacoRef.current?.setValue(fileContent); globalStore.set(this.newFileContent, null); } async handleOpenFile(filePath: string) { const fileInfo = await globalStore.get(this.statFile); if (fileInfo == null) { this.updateOpenFileModalAndError(false); return true; } const conn = globalStore.get(this.connection); try { const newFileInfo = await RpcApi.RemoteFileJoinCommand(TabRpcClient, [fileInfo.dir, filePath], { route: makeConnRoute(conn), }); this.updateOpenFileModalAndError(false); this.goHistory(newFileInfo.path); refocusNode(this.blockId); } catch (e) { globalStore.set(this.openFileError, e.message); console.error("Error opening file", fileInfo.dir, filePath, e); } } isSpecializedView(sv: string): boolean { const loadableSV = globalStore.get(this.loadableSpecializedView); return loadableSV.state == "hasData" && loadableSV.data.specializedView == sv; } getSettingsMenuItems(): ContextMenuItem[] { const menuItems: ContextMenuItem[] = []; menuItems.push({ label: "Copy Full Path", click: () => fireAndForget(async () => { const filePath = await globalStore.get(this.normFilePath); if (filePath == null) { return; } 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); }), }); const mimeType = jotaiLoadableValue(globalStore.get(this.fileMimeTypeLoadable), ""); if (mimeType == "directory") { menuItems.push({ label: "Open Terminal in New Block", click: () => fireAndForget(async () => { const fileInfo = await globalStore.get(this.statFile); const termBlockDef: BlockDef = { meta: { view: "term", controller: "shell", "cmd:cwd": fileInfo.dir, }, }; await createBlock(termBlockDef); }), }); } 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; } const openModalOpen = globalStore.get(this.openFileModal); if (!openModalOpen) { if (checkKeyPressed(e, "Cmd:o")) { this.updateOpenFileModalAndError(true); 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; } } function makePreviewModel(blockId: string, nodeModel: BlockNodeModel): PreviewModel { const previewModel = new PreviewModel(blockId, nodeModel); return previewModel; } function MarkdownPreview({ model }: SpecializedViewProps) { const connName = useAtomValue(model.connection); const fileInfo = useAtomValue(model.statFile); const resolveOpts: MarkdownResolveOpts = useMemo(() => { return { connName: connName, baseDir: fileInfo.dir, }; }, [connName, fileInfo.dir]); return (
); } function StreamingPreview({ model }: SpecializedViewProps) { const conn = useAtomValue(model.connection); const fileInfo = useAtomValue(model.statFile); const filePath = fileInfo.path; const usp = new URLSearchParams(); usp.set("path", filePath); if (conn != null) { usp.set("connection", conn); } const streamingUrl = getWebServerEndpoint() + "/wave/stream-file?" + usp.toString(); if (fileInfo.mimetype == "application/pdf") { return (