// Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 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 { WindowRpcClient } from "@/app/store/wshrpcutil"; import { Markdown } from "@/element/markdown"; import { NodeModel } from "@/layout/index"; import { atoms, createBlock, getConnStatusAtom, globalStore, refocusNode } from "@/store/global"; import { modalsModel } from "@/store/modalmodel"; import * as services from "@/store/services"; import * as WOS from "@/store/wos"; import { getWebServerEndpoint } from "@/util/endpoints"; import * as historyutil from "@/util/historyutil"; import * as keyutil from "@/util/keyutil"; import * as util from "@/util/util"; import { makeConnRoute } from "@/util/util"; import { Monaco } from "@monaco-editor/react"; import clsx from "clsx"; import * as jotai from "jotai"; import { loadable } from "jotai/utils"; import type * as MonacoTypes from "monaco-editor/esm/vs/editor/editor.api"; import * as React from "react"; import { createRef, useCallback, useState } from "react"; import { CenteredDiv } from "../../element/quickelems"; import { CodeEditor } from "../codeeditor/codeeditor"; import { CSVView } from "./csvview"; import { DirectoryPreview } from "./directorypreview"; import "./preview.less"; 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", ]; 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/") ); } const previewTipText = ` ### Preview Preview is the generic type of block used for viewing files. This can take many different forms based on the type of file being viewed. You can use \`wsh view [path]\` from any Wave terminal window to open a preview block with the contents of the specified path (e.g. \`wsh view .\` or \`wsh view ~/myimage.jpg\`). #### Directory When looking at a directory, preview will show a file viewer much like MacOS' *Finder* application or Windows' *File Explorer* application. This variant is slightly more geared toward software development with the focus on seeing what is shown by the \`ls -alh\` command. ##### View a New File The simplest way to view a new file is to double click its row in the file viewer. Alternatively, while the block is focused, you can use the ↑ and ↓ arrow keys to select a row and press enter to preview the associated file. ##### View the Parent Directory In the directory view, this is as simple as opening the \`..\` file as if it were a regular file. This can be done with the method above. You can also use the keyboard shortcut \`Cmd + ArrowUp\`. ##### Navigate Back and Forward When looking at a file, you can navigate back by clicking the back button in the block header or the keyboard shortcut \`Cmd + ArrowLeft\`. You can always navigate back and forward using \`Cmd + ArrowLeft\` and \`Cmd + ArrowRight\`. ##### Filter the List of Files While the block is focused, you can filter by filename by typing a substring of the filename you're working for. To clear the filter, you can click the ✕ on the filter dropdown or press esc. ##### Sort by a File Column To sort a file by a specific column, click on the header for that column. If you click the header again, it will reverse the sort order. ##### Hide and Show Hidden Files At the right of the block header, there is an 👁️ button. Clicking this button hides and shows hidden files. ##### Refresh the Directory At the right of the block header, there is a refresh button. Clicking this button refreshes the directory contents. ##### Navigate to Common Directories At the left of the block header, there is a file icon. Clicking and holding on this icon opens a menu where you can select a common folder to navigate to. The available options are *Home*, *Desktop*, *Downloads*, and *Root*. ##### Open a New Terminal in the Current Directory If you right click the header of the block (alternatively, click the gear icon), one of the menu items listed is **Open Terminal in New Block**. This will create a new terminal block at your current directory. ##### Open a New Terminal in a Child Directory If you want to open a terminal for a child directory instead, you can right click on that file's row to get the **Open Terminal in New Block** option. Clicking this will open a terminal at that directory. Note that this option is only available for children that are directories. ##### Open a New Preview for a Child To open a new Preview Block for a Child, you can right click on that file's row and select the **Open Preview in New Block** option. #### Markdown Opening a markdown file will bring up a view of the rendered markdown. These files cannot be edited in the preview at this time. #### Images/Media Opening a picture will bring up the image of that picture. Opening a video will bring up a player that lets you watch the video. `; export class PreviewModel implements ViewModel { viewType: string; blockId: string; nodeModel: NodeModel; blockAtom: jotai.Atom; viewIcon: jotai.Atom; viewName: jotai.Atom; viewText: jotai.Atom; preIconButton: jotai.Atom; endIconButtons: jotai.Atom; previewTextRef: React.RefObject; editMode: jotai.Atom; canPreview: jotai.PrimitiveAtom; specializedView: jotai.Atom>; loadableSpecializedView: jotai.Atom>; manageConnection: jotai.Atom; connStatus: jotai.Atom; metaFilePath: jotai.Atom; statFilePath: jotai.Atom>; normFilePath: jotai.Atom>; loadableStatFilePath: jotai.Atom>; loadableFileInfo: jotai.Atom>; connection: jotai.Atom; statFile: jotai.Atom>; fullFile: jotai.Atom>; fileMimeType: jotai.Atom>; fileMimeTypeLoadable: jotai.Atom>; fileContent: jotai.Atom>; newFileContent: jotai.PrimitiveAtom; openFileModal: jotai.PrimitiveAtom; openFileError: jotai.PrimitiveAtom; openFileModalGiveFocusRef: React.MutableRefObject<() => boolean>; markdownShowToc: jotai.PrimitiveAtom; monacoRef: React.MutableRefObject; showHiddenFiles: jotai.PrimitiveAtom; refreshVersion: jotai.PrimitiveAtom; refreshCallback: () => void; directoryKeyDownHandler: (waveEvent: WaveKeyboardEvent) => boolean; codeEditKeyDownHandler: (waveEvent: WaveKeyboardEvent) => boolean; setPreviewFileName(fileName: string) { services.ObjectService.UpdateObjectMeta(`block:${this.blockId}`, { file: fileName }); } constructor(blockId: string, nodeModel: NodeModel) { this.viewType = "preview"; this.blockId = blockId; this.nodeModel = nodeModel; this.showHiddenFiles = jotai.atom(true); this.refreshVersion = jotai.atom(0); this.previewTextRef = createRef(); this.openFileModal = jotai.atom(false); this.openFileError = jotai.atom(null) as jotai.PrimitiveAtom; this.openFileModalGiveFocusRef = createRef(); this.manageConnection = jotai.atom(true); this.blockAtom = WOS.getWaveObjectAtom(`block:${blockId}`); this.markdownShowToc = jotai.atom(false); this.monacoRef = createRef(); this.viewIcon = jotai.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 fileName = get(this.metaFilePath); const mimeTypeLoadable = get(this.fileMimeTypeLoadable); const mimeType = util.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 = jotai.atom((get) => { const blockData = get(this.blockAtom); return blockData?.meta?.edit ?? false; }); this.viewName = jotai.atom("Preview"); this.viewText = jotai.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 = "secondary"; if (get(this.newFileContent) !== null) { saveClassName = "primary"; } 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: this.handleFileSave.bind(this), }); if (get(this.canPreview)) { viewTextChildren.push({ elemtype: "textbutton", text: "Preview", className: "secondary border-radius-4 vertical-padding-2 horizontal-padding-10 font-size-11 font-weight-500", onClick: () => this.setEditMode(false), }); } } else if (get(this.canPreview)) { viewTextChildren.push({ elemtype: "textbutton", text: "Edit", className: "secondary border-radius-4 vertical-padding-2 horizontal-padding-10 font-size-11 font-weight-500", onClick: () => this.setEditMode(true), }); } return [ { elemtype: "div", children: viewTextChildren, }, ] as HeaderElem[]; }); this.preIconButton = jotai.atom((get) => { const connStatus = get(this.connStatus); if (connStatus?.status != "connected") { return null; } const mimeType = util.jotaiLoadableValue(get(this.fileMimeTypeLoadable), ""); if (mimeType == "directory") { return null; } return { elemtype: "iconbutton", icon: "chevron-left", click: this.goParentDirectory.bind(this), }; }); this.endIconButtons = jotai.atom((get) => { const connStatus = get(this.connStatus); if (connStatus?.status != "connected") { return null; } const mimeType = util.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); let tipIcon: IconButtonDecl[]; if (settings["tips:show"]) { tipIcon = [ { elemtype: "iconbutton", icon: "lightbulb", iconColor: "var(--warning-color)", className: "bulb", click: () => { const tips: UserInputRequest = { requestid: "", querytext: previewTipText, responsetype: "confirm", title: "Preview Tips", markdown: true, timeoutms: 0, checkboxmsg: "", publictext: true, }; modalsModel.pushModal("TipsModal", tips); }, }, ]; } else { tipIcon = []; } return [ ...tipIcon, { 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 = jotai.atom((get) => { const file = get(this.blockAtom)?.meta?.file; if (util.isBlank(file)) { return "~"; } return file; }); this.statFilePath = jotai.atom>(async (get) => { const fileInfo = await get(this.statFile); return fileInfo?.path; }); this.normFilePath = jotai.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 = jotai.atom((get) => { return get(this.blockAtom)?.meta?.connection; }); this.statFile = jotai.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 = jotai.atom>(async (get) => { const fileInfo = await get(this.statFile); return fileInfo?.mimetype; }); this.fileMimeTypeLoadable = loadable(this.fileMimeType); this.newFileContent = jotai.atom(null) as jotai.PrimitiveAtom; this.goParentDirectory = this.goParentDirectory.bind(this); const fullFileAtom = jotai.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; }); const fileContentAtom = jotai.atom>(async (get) => { const fullFile = await get(fullFileAtom); return util.base64ToString(fullFile?.data64); }); this.fullFile = fullFileAtom; this.fileContent = fileContentAtom; this.specializedView = jotai.atom>(async (get) => { return this.getSpecializedView(get); }); this.loadableSpecializedView = loadable(this.specializedView); this.canPreview = jotai.atom(false); this.loadableFileInfo = loadable(this.statFile); this.connStatus = jotai.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: jotai.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); if (fileInfo?.notfound) { return { errorStr: `File Not Found: ${fileInfo.path}` }; } 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)) { 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 = historyutil.goHistory("file", fileName, newPath, blockMeta); if (updateMeta == null) { return; } const blockOref = WOS.makeORef("block", this.blockId); services.ObjectService.UpdateObjectMeta(blockOref, updateMeta); } async goParentDirectory() { const blockMeta = globalStore.get(this.blockAtom)?.meta; const metaPath = globalStore.get(this.metaFilePath); const fileInfo = await globalStore.get(this.statFile); if (fileInfo == null) { return; } let newPath: string = null; if (!fileInfo.isdir) { newPath = fileInfo.dir; } else { const lastSlash = fileInfo.dir.lastIndexOf("/"); newPath = fileInfo.dir.slice(0, lastSlash); if (newPath.indexOf("/") == -1) { return; } } const updateMeta = historyutil.goHistory("file", metaPath, newPath, blockMeta); if (updateMeta == null) { return; } updateMeta.edit = false; const blockOref = WOS.makeORef("block", this.blockId); services.ObjectService.UpdateObjectMeta(blockOref, updateMeta); } goHistoryBack() { const blockMeta = globalStore.get(this.blockAtom)?.meta; const curPath = globalStore.get(this.metaFilePath); const updateMeta = historyutil.goHistoryBack("file", curPath, blockMeta, true); if (updateMeta == null) { return; } updateMeta.edit = false; const blockOref = WOS.makeORef("block", this.blockId); services.ObjectService.UpdateObjectMeta(blockOref, updateMeta); } goHistoryForward() { const blockMeta = globalStore.get(this.blockAtom)?.meta; const curPath = globalStore.get(this.metaFilePath); const updateMeta = historyutil.goHistoryForward("file", curPath, blockMeta); if (updateMeta == null) { return; } updateMeta.edit = false; const blockOref = WOS.makeORef("block", this.blockId); services.ObjectService.UpdateObjectMeta(blockOref, updateMeta); } setEditMode(edit: boolean) { const blockMeta = globalStore.get(this.blockAtom)?.meta; const blockOref = WOS.makeORef("block", this.blockId); 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 { services.FileService.SaveFile(conn, filePath, util.stringToBase64(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(WindowRpcClient, [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: async () => { const filePath = await globalStore.get(this.normFilePath); if (filePath == null) { return; } navigator.clipboard.writeText(filePath); }, }); menuItems.push({ label: "Copy File Name", click: async () => { const fileInfo = await globalStore.get(this.statFile); if (fileInfo == null || fileInfo.name == null) { return; } navigator.clipboard.writeText(fileInfo.name); }, }); const mimeType = util.jotaiLoadableValue(globalStore.get(this.fileMimeTypeLoadable), ""); if (mimeType == "directory") { menuItems.push({ label: "Open Terminal in New Block", click: 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); 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: this.handleFileSave.bind(this), }); menuItems.push({ label: "Revert File", click: this.handleFileRevert.bind(this), }); } } } 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 (keyutil.checkKeyPressed(e, "Cmd:ArrowLeft")) { this.goHistoryBack(); return true; } if (keyutil.checkKeyPressed(e, "Cmd:ArrowRight")) { this.goHistoryForward(); return true; } if (keyutil.checkKeyPressed(e, "Cmd:ArrowUp")) { // handle up directory this.goParentDirectory(); return true; } const openModalOpen = globalStore.get(this.openFileModal); if (!openModalOpen) { if (keyutil.checkKeyPressed(e, "Cmd:o")) { this.updateOpenFileModalAndError(true); return true; } } const canPreview = globalStore.get(this.canPreview); if (canPreview) { if (keyutil.checkKeyPressed(e, "Cmd:e")) { const editMode = globalStore.get(this.editMode); 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: NodeModel): PreviewModel { const previewModel = new PreviewModel(blockId, nodeModel); return previewModel; } function MarkdownPreview({ model }: SpecializedViewProps) { const connName = jotai.useAtomValue(model.connection); const fileInfo = jotai.useAtomValue(model.statFile); const resolveOpts: MarkdownResolveOpts = React.useMemo(() => { return { connName: connName, baseDir: fileInfo.dir, }; }, [connName, fileInfo.dir]); return (
); } function StreamingPreview({ model }: SpecializedViewProps) { const conn = jotai.useAtomValue(model.connection); const fileInfo = jotai.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 (